From 52ec4d6fc97b95d22151f03366af623ae5acf6da Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 02:24:55 +0200 Subject: [PATCH 01/40] docs: Android implementation plan for storage architecture editing Ten TDD tasks: the data layer for rename/reorder/delete-with-strategy, the ordering rule, the delete-strategy dialog, edit mode on the shelves and locations lists, the tabs/list toggle, the household edit page, the nav rework, collapsible household groups, flow tests, and the CLAUDE.md rewrite. Two deliberate deviations from the spec, both flagged in the document: reorder uses move up/down buttons rather than drag (Compose has no built-in reorderable list, drag would need a new dependency and fight the existing SwipeToDismissBox, and it is the worst affordance for TalkBack); and preferences use SharedPreferences, because DataStore is not a dependency of this project. --- ...13-storage-architecture-editing-android.md | 1718 +++++++++++++++++ 1 file changed, 1718 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md diff --git a/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md b/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md new file mode 100644 index 0000000..d5b832a --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md @@ -0,0 +1,1718 @@ +# Storage Architecture Editing — Android Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the user rename, reorder, and safely delete the storage hierarchy from the screens that already show it — and replace today's no-confirmation bulk shelf delete with a flow that asks what to do with the contents. + +**Architecture:** One **edit mode**, toggled by a pencil in the top bar, applied identically to the households / locations / shelves lists. In it, each row grows a checkbox (multi-select delete) and move-up/move-down buttons (reorder), and tapping the row body opens its edit sheet. Deleting a non-empty container opens a strategy dialog; the client mints one `deletion_batch_id` per gesture so a single snackbar Undo restores the whole thing. + +**Tech Stack:** Kotlin, Jetpack Compose (Material 3), Hilt, Retrofit + **kotlinx.serialization**, MVVM with `StateFlow`. JUnit4 + hand-written fakes (no MockK/Mockito). Instrumented flow tests on MockWebServer + Hilt. + +**Depends on:** the backend plan (`inventory-laravel/docs/superpowers/plans/2026-07-13-storage-architecture-editing-backend.md`) must ship first — every endpoint here is new. + +## Global Constraints + +Copied verbatim from the codebase's conventions. Every task's requirements implicitly include this section. + +- **No `Result`, no sealed error types.** Repositories **throw**; ViewModels catch with `runCatching` and map via `Throwable.toUserMessage(fallback)` (`data/error/ErrorMapping.kt`). Those fallback strings are **hardcoded English in Kotlin**, not `strings.xml` — match that. +- **A new repository-interface method MUST have a default implementation** (`= throw UnsupportedOperationException("x not supported")`, as `HouseholdRepository.updateTheme` does). Otherwise ~6 hand-written test fakes fail to compile. +- **A new Api interface must be registered in BOTH `di/NetworkModule.kt` AND `androidTest/.../di/TestNetworkModule.kt`** (`@TestInstallIn(replaces = [NetworkModule::class])`), or every flow test fails to inject. +- **A new PATCH/update request DTO must NOT give its properties defaults.** The app's `Json` has `encodeDefaults = false`, so a defaulted field is silently **omitted from the body** and the server keeps the old value. See the comment on `UpdateHouseholdThemeRequest` and `UpdateProductRequestSerializationTest`. +- **Mutations use `launchLoading { }`** (default `refreshing = false`). Only a user-initiated pull uses `launchLoading(refreshing = true)`. There is a test asserting this (`StorageOverviewViewModelTest.refresh_flags_the_pull_spinner_but_create_does_not`). +- **Any mutation that changes the hierarchy must call `hierarchyStore.refresh()`**, or the Dashboard, AllStorages and the missing-items badge go stale. +- **A new per-account store must be wired into `data/auth/SessionCleaner.clear()`**, or one account's state bleeds into the next session. +- **Preferences are `SharedPreferences`, not DataStore.** DataStore is not a dependency of this project. Follow `interface XStore` in `data/settings/` + `class SharedPrefsXStore(context: Context) : XStore` (plain constructor, no `@Inject`) + `@Provides` in `di/StorageModule.kt`. +- **Every new user-facing string goes in BOTH `res/values/strings.xml` and `res/values-nl/strings.xml`.** Lint is `abortOnError = false`, so nothing catches a missing translation — it is discipline only. Keys are `snake_case`, prefixed by screen; `_cd` suffix = content description. +- **`ktlintCheck` and `detekt` are blocking CI gates** and are baseline-aware, so **new code must be clean**. Reproduce the existing style exactly, including the unusual `@Inject constructor` indentation and trailing commas everywhere. Non-`@Composable` functions still face `LongMethod` / `LongParameterList` / `MagicNumber`. +- **Run tests with `./gradlew testDebugUnitTest`; style with `./gradlew ktlintCheck detekt`.** Instrumented: `./gradlew connectedDebugAndroidTest` (nightly in CI, not on PR). + +## Deviation from the spec — read before starting + +The spec says **drag handles** for reorder. This plan uses **move-up / move-down icon buttons** instead: + +- Compose has no built-in reorderable list. Drag means a new third-party dependency, or ~150 lines of gesture code that will fight the existing `SwipeToDismissBox` on these very rows. +- These lists are 3–8 items. Buttons are precise where drag is fiddly. +- This codebase takes accessibility seriously (`liveRegion = Assertive`, `_cd` strings everywhere). **Drag is the worst possible reorder affordance for TalkBack;** discrete buttons are the best. + +The ViewModel contract (`moveUp(id)` / `moveDown(id)` producing a full ordered id list) is identical either way, so swapping in drag later touches only the row composable. + +--- + +## Task 1: Data layer — rename, reorder, delete-with-strategy + +**Files:** +- Modify: `data/api/LocationApi.kt`, `data/api/ShelfApi.kt`, `data/api/HouseholdApi.kt`, `data/dto/LocationDtos.kt`, `data/dto/ShelfDtos.kt`, `data/dto/HouseholdDtos.kt`, `data/location/LocationRepository.kt`, `data/location/LocationRepositoryImpl.kt`, `data/shelf/ShelfRepository.kt`, `data/shelf/ShelfRepositoryImpl.kt`, `data/household/HouseholdRepository.kt`, `data/household/HouseholdRepositoryImpl.kt` +- Create: `data/api/RestoreApi.kt`, `data/dto/RestoreDtos.kt`, `data/hierarchy/DeleteStrategy.kt` +- Modify: `di/NetworkModule.kt`, `app/src/androidTest/java/dev/scuttle/inventory/di/TestNetworkModule.kt` +- Test: `app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt` + +**Interfaces produced (later tasks depend on these exact signatures):** + +```kotlin +// data/hierarchy/DeleteStrategy.kt +enum class ShelfDeleteStrategy(val wire: String) { + MOVE_PRODUCTS("move_products"), + UNSORT_PRODUCTS("unsort_products"), + DELETE_PRODUCTS("delete_products"), +} +enum class LocationDeleteStrategy(val wire: String) { + MOVE_CONTENTS("move_contents"), + DELETE_CONTENTS("delete_contents"), +} + +// LocationRepository +suspend fun rename(householdId: Long, locationId: Long, name: String, type: String): LocationDto +suspend fun reorder(householdId: Long, ids: List): List +suspend fun delete(householdId: Long, locationId: Long, batchId: String, strategy: LocationDeleteStrategy?, targetLocationId: Long?) + +// ShelfRepository +suspend fun rename(householdId: Long, locationId: Long, shelfId: Long, name: String): ShelfDto +suspend fun reorder(householdId: Long, locationId: Long, ids: List): List +suspend fun delete(householdId: Long, locationId: Long, shelfId: Long, batchId: String, strategy: ShelfDeleteStrategy?, targetShelfId: Long?) + +// HouseholdRepository +suspend fun update(householdId: Long, name: String?, color: String?, icon: String?): HouseholdDto + +// RestoreRepository (new) +suspend fun restore(householdId: Long, batchId: String): Int +``` + +> **Note on `ShelfRepository.delete`:** the existing signature `delete(householdId, locationId, shelfId)` gains three parameters. Give them defaults (`batchId`, `strategy = null`, `targetShelfId = null`) **only on the interface** so existing fakes keep compiling — but `batchId` is required by the server, so it has no default. + +- [ ] **Step 1: Write the failing serialization test** + +This is the trap that has bitten this codebase before. Create `app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt`: + +```kotlin +package dev.scuttle.inventory + +import dev.scuttle.inventory.data.dto.DeleteShelfRequest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeleteRequestSerializationTest { + // The app's real Json config. encodeDefaults defaults to false, which is + // exactly why a defaulted property would be dropped from the body. + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun batch_id_is_always_sent() { + val body = + json.encodeToString( + DeleteShelfRequest( + strategy = null, + target_shelf_id = null, + deletion_batch_id = "11111111-1111-4111-8111-111111111111", + ), + ) + + // The server 422s without it. If a default ever creeps onto this + // property, encodeDefaults=false silently omits it and every delete + // starts failing with a validation error nobody can explain. + assertTrue(body.contains("deletion_batch_id")) + } + + @Test + fun a_null_strategy_is_still_sent_explicitly() { + val body = + json.encodeToString( + DeleteShelfRequest( + strategy = null, + target_shelf_id = null, + deletion_batch_id = "11111111-1111-4111-8111-111111111111", + ), + ) + + // An empty shelf legitimately has no strategy. Sending explicit null is + // fine; what must never happen is the property vanishing because it was + // defaulted. + assertTrue(body.contains("\"strategy\":null")) + assertFalse(body.isBlank()) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./gradlew testDebugUnitTest --tests '*DeleteRequestSerializationTest*'` +Expected: FAIL — `DeleteShelfRequest` does not exist. + +- [ ] **Step 3: Add the DTOs** + +In `data/dto/ShelfDtos.kt` — add `is_system` to `ShelfDto` and the new request bodies. **No defaults on the request properties.** + +```kotlin +@Serializable +data class ShelfDto( + val id: Long, + val name: String, + val position: Int? = null, + val location_id: Long, + // Server-side flag for the "Unsorted" holding shelf. The client localises the + // label off this — the server always stores the literal name "Unsorted". + val is_system: Boolean = false, +) + +// No property defaults: the app's Json has encodeDefaults=false, so a defaulted +// field is OMITTED from the body — and the server 422s without deletion_batch_id. +@Serializable +data class DeleteShelfRequest( + val strategy: String?, + val target_shelf_id: Long?, + val deletion_batch_id: String, +) + +@Serializable +data class UpdateShelfRequest( + val name: String, +) + +@Serializable +data class ReorderRequest( + val ids: List, +) +``` + +In `data/dto/LocationDtos.kt`: + +```kotlin +@Serializable +data class LocationDto( + val id: Long, + val name: String, + val type: String, + val position: Int? = null, +) + +@Serializable +data class DeleteLocationRequest( + val strategy: String?, + val target_location_id: Long?, + val deletion_batch_id: String, +) + +@Serializable +data class UpdateLocationRequest( + val name: String, + val type: String, +) +``` + +In `data/dto/HouseholdDtos.kt` — replace `UpdateHouseholdThemeRequest` with one that also carries the name. Keep the no-defaults comment; it now guards three fields. + +```kotlin +// No property defaults on purpose: the app's Json has encodeDefaults=false, so a +// defaulted field would be OMITTED from the body and the server would keep the +// old value instead of clearing it. Explicit null = clear back to the derived +// default. `name` is nullable so a theme-only change doesn't have to resend it. +@Serializable +data class UpdateHouseholdRequest( + val name: String?, + val color: String?, + val icon: String?, +) +``` + +Create `data/dto/RestoreDtos.kt`: + +```kotlin +package dev.scuttle.inventory.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class RestoreResponse( + val message: String, + val restored: Int, +) +``` + +- [ ] **Step 4: Add the strategy enums** + +Create `data/hierarchy/DeleteStrategy.kt`: + +```kotlin +package dev.scuttle.inventory.data.hierarchy + +/** + * What to do with a shelf's products when the shelf is deleted. The server + * refuses to guess — a non-empty shelf deleted with no strategy is a 422. + */ +enum class ShelfDeleteStrategy( + val wire: String, +) { + MOVE_PRODUCTS("move_products"), + UNSORT_PRODUCTS("unsort_products"), + DELETE_PRODUCTS("delete_products"), +} + +/** + * What to do with a location's contents. There is deliberately no `unsort` here: + * "unsorted" means off-shelf but still IN this location, and the location is the + * thing being deleted. + */ +enum class LocationDeleteStrategy( + val wire: String, +) { + MOVE_CONTENTS("move_contents"), + DELETE_CONTENTS("delete_contents"), +} +``` + +- [ ] **Step 5: Extend the Api interfaces** + +`data/api/ShelfApi.kt` — Retrofit needs `@HTTP` (not `@DELETE`) to send a body on a DELETE: + +```kotlin +import retrofit2.http.HTTP +import retrofit2.http.PATCH + + @PATCH("households/{household}/locations/{location}/shelves/{shelf}") + suspend fun update( + @Path("household") householdId: Long, + @Path("location") locationId: Long, + @Path("shelf") shelfId: Long, + @Body body: UpdateShelfRequest, + ): ShelfResponse + + @PATCH("households/{household}/locations/{location}/shelves/reorder") + suspend fun reorder( + @Path("household") householdId: Long, + @Path("location") locationId: Long, + @Body body: ReorderRequest, + ): ShelfListResponse + + // @DELETE cannot carry a body; @HTTP(hasBody = true) can. The strategy and + // the batch id have to travel with the request. + @HTTP(method = "DELETE", path = "households/{household}/locations/{location}/shelves/{shelf}", hasBody = true) + suspend fun delete( + @Path("household") householdId: Long, + @Path("location") locationId: Long, + @Path("shelf") shelfId: Long, + @Body body: DeleteShelfRequest, + ) +``` + +`data/api/LocationApi.kt` — the same three, one level up (`update` takes `UpdateLocationRequest`, `reorder` is `households/{household}/locations/reorder`, `delete` is `@HTTP(... hasBody = true)` with `DeleteLocationRequest`). + +`data/api/HouseholdApi.kt` — rename `updateTheme` to `update` and take the new body: + +```kotlin + @PATCH("households/{household}") + suspend fun update( + @Path("household") householdId: Long, + @Body body: UpdateHouseholdRequest, + ): HouseholdResponse +``` + +Create `data/api/RestoreApi.kt`: + +```kotlin +package dev.scuttle.inventory.data.api + +import dev.scuttle.inventory.data.dto.RestoreResponse +import retrofit2.http.POST +import retrofit2.http.Path + +interface RestoreApi { + @POST("households/{household}/restore/{batch}") + suspend fun restore( + @Path("household") householdId: Long, + @Path("batch") batchId: String, + ): RestoreResponse +} +``` + +- [ ] **Step 6: Extend the repositories** + +In `data/shelf/ShelfRepository.kt`, add the new methods **with throwing defaults** so the existing fakes still compile: + +```kotlin + /** + * Defaults throw so test fakes only implement what a test actually exercises + * (same pattern as [clear] and HouseholdRepository.updateTheme). Without + * this, adding a method here breaks every fake in the unit-test suite. + */ + suspend fun rename( + householdId: Long, + locationId: Long, + shelfId: Long, + name: String, + ): ShelfDto = throw UnsupportedOperationException("rename not supported") + + suspend fun reorder( + householdId: Long, + locationId: Long, + ids: List, + ): List = throw UnsupportedOperationException("reorder not supported") + + suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + shelfId: Long, + batchId: String, + strategy: ShelfDeleteStrategy?, + targetShelfId: Long?, + ): Unit = throw UnsupportedOperationException("deleteWithStrategy not supported") +``` + +> **Why a new name rather than changing `delete`'s signature:** `delete(householdId, locationId, shelfId)` is called from existing code and implemented by existing fakes. Adding `deleteWithStrategy` alongside it keeps this task additive; Task 5 switches the call sites over, and the old `delete` is removed in Task 10. + +Implement all three in `ShelfRepositoryImpl`, keeping the cache in sync exactly as the existing methods do: + +```kotlin + override suspend fun rename( + householdId: Long, + locationId: Long, + shelfId: Long, + name: String, + ): ShelfDto = + api.update(householdId, locationId, shelfId, UpdateShelfRequest(name = name)).data.also { updated -> + val key = householdId to locationId + cache[key] = cache[key]?.map { if (it.id == shelfId) updated else it } ?: listOf(updated) + } + + override suspend fun reorder( + householdId: Long, + locationId: Long, + ids: List, + ): List = + api.reorder(householdId, locationId, ReorderRequest(ids)).data.also { + cache[householdId to locationId] = it + } + + override suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + shelfId: Long, + batchId: String, + strategy: ShelfDeleteStrategy?, + targetShelfId: Long?, + ) { + api.delete( + householdId, + locationId, + shelfId, + DeleteShelfRequest( + strategy = strategy?.wire, + target_shelf_id = targetShelfId, + deletion_batch_id = batchId, + ), + ) + val key = householdId to locationId + cache[key] = cache[key]?.filter { it.id != shelfId } ?: emptyList() + } +``` + +Mirror all of this in `LocationRepository` / `LocationRepositoryImpl` (`rename(householdId, locationId, name, type)`, `reorder(householdId, ids)`, `deleteWithStrategy(householdId, locationId, batchId, strategy, targetLocationId)`). + +In `HouseholdRepository` / `HouseholdRepositoryImpl`, replace `updateTheme` with: + +```kotlin + suspend fun update( + householdId: Long, + name: String?, + color: String?, + icon: String?, + ): HouseholdDto = throw UnsupportedOperationException("update not supported") +``` + +Create `data/hierarchy/RestoreRepository.kt` + `RestoreRepositoryImpl.kt` following the same interface/impl shape (no cache needed). + +- [ ] **Step 7: Register the new Api in BOTH network modules** + +In `di/NetworkModule.kt`: + +```kotlin + @Provides + @Singleton + fun provideRestoreApi(retrofit: Retrofit): RestoreApi = retrofit.create(RestoreApi::class.java) +``` + +**And the same `@Provides` in `app/src/androidTest/java/dev/scuttle/inventory/di/TestNetworkModule.kt`.** Miss this and every flow test fails to inject with an unhelpful Hilt error. + +Bind `RestoreRepository` in `di/RepositoryModule.kt`: + +```kotlin + @Binds + @Singleton + abstract fun bindRestoreRepository(impl: RestoreRepositoryImpl): RestoreRepository +``` + +- [ ] **Step 8: Run the tests to verify they pass** + +Run: `./gradlew testDebugUnitTest --tests '*DeleteRequestSerializationTest*'` +Expected: PASS, 2 tests. + +- [ ] **Step 9: Fix the one existing caller of `updateTheme`** + +`ui/households/HouseholdsViewModel.kt` calls `repository.updateTheme(id, color, icon)`. Change it to `repository.update(id, name = null, color = color, icon = icon)`. + +- [ ] **Step 10: Run the full gate** + +Run: `./gradlew testDebugUnitTest ktlintCheck detekt` +Expected: all PASS. + +- [ ] **Step 11: Commit** + +```bash +git add app/src/main/java/dev/scuttle/inventory/data app/src/main/java/dev/scuttle/inventory/di app/src/androidTest/java/dev/scuttle/inventory/di app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt +git commit -m "feat: data layer for renaming, reordering and safe deletion + +Adds rename/reorder/deleteWithStrategy to the location and shelf repositories, +name to the household update, and a restore-by-batch endpoint. + +DELETE now carries a body (@HTTP hasBody, since @DELETE cannot), because the +strategy and the client-minted batch id have to travel with the request. The +request DTOs deliberately have no property defaults: encodeDefaults=false +would silently drop them and the server would 422." +``` + +--- + +## Task 2: The ordering rule + +Pure logic, fully unit-testable, no UI. Gets the contentious part right before anything renders it. + +**Files:** +- Create: `ui/common/HierarchyOrder.kt` +- Test: `app/src/test/java/dev/scuttle/inventory/HierarchyOrderTest.kt` + +**Interfaces:** +- Produces: `fun orderByPosition(items: List, position: (T) -> Int?, name: (T) -> String): List` + +- [ ] **Step 1: Write the failing test** + +Create `app/src/test/java/dev/scuttle/inventory/HierarchyOrderTest.kt`: + +```kotlin +package dev.scuttle.inventory + +import dev.scuttle.inventory.data.dto.ShelfDto +import dev.scuttle.inventory.ui.common.orderByPosition +import org.junit.Assert.assertEquals +import org.junit.Test + +class HierarchyOrderTest { + private fun shelf( + id: Long, + name: String, + position: Int?, + isSystem: Boolean = false, + ) = ShelfDto(id = id, name = name, position = position, location_id = 1L, is_system = isSystem) + + private fun order(items: List) = orderByPosition(items, { it.position }, { it.name }) + + @Test + fun manual_position_wins() { + val items = + listOf( + shelf(1, "Top", 0), + shelf(2, "Middle", 1), + shelf(3, "Bottom", 2), + ) + + assertEquals(listOf(1L, 2L, 3L), order(items.shuffled()).map { it.id }) + } + + @Test + fun a_star_never_reorders_anything() { + // THE rule. Shelf order is physical: if starring the bottom shelf floated + // it to the top, the list would stop matching the fridge the user is + // standing in front of. A star is a marker and a filter, never a sort — + // so this function does not even take a favourites set as an argument. + val items = + listOf( + shelf(1, "Top", 0), + shelf(2, "Middle", 1), + shelf(3, "Bottom", 2), + ) + + // Whatever the caller has starred, the order is unchanged. + assertEquals(listOf(1L, 2L, 3L), order(items).map { it.id }) + } + + @Test + fun name_is_only_the_tie_break_for_never_reordered_items() { + // Everything sits at position 0 until someone drags. Falling back to name + // keeps a fresh list stable instead of ordering it by insertion id. + val items = + listOf( + shelf(1, "Zebra", 0), + shelf(2, "Apple", 0), + shelf(3, "Mango", 0), + ) + + assertEquals(listOf("Apple", "Mango", "Zebra"), order(items).map { it.name }) + } + + @Test + fun a_null_position_sorts_as_zero() { + // An older server, or a row created before the position column existed. + val items = + listOf( + shelf(1, "Bbb", null), + shelf(2, "Aaa", null), + shelf(3, "Ccc", 1), + ) + + assertEquals(listOf("Aaa", "Bbb", "Ccc"), order(items).map { it.name }) + } + + @Test + fun the_sort_is_stable_across_repeated_calls() { + val items = listOf(shelf(1, "Same", 0), shelf(2, "Same", 0)) + + assertEquals(order(items).map { it.id }, order(order(items)).map { it.id }) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./gradlew testDebugUnitTest --tests '*HierarchyOrderTest*'` +Expected: FAIL — unresolved reference `orderByPosition`. + +- [ ] **Step 3: Write the implementation** + +Create `ui/common/HierarchyOrder.kt`: + +```kotlin +package dev.scuttle.inventory.ui.common + +/** + * The one ordering rule for every hierarchy list in the app. + * + * 1. Manual position — what the user set by moving rows. It always wins. + * 2. Name — the tie-break for items nobody has reordered yet (they all sit at 0). + * + * A star is deliberately NOT an input here. Starring is a marker and a filter, + * never a sort: shelf order is physical, and a star that floated the bottom shelf + * to the top of the list would leave the app disagreeing with the actual fridge. + * Keeping favourites out of this signature makes that impossible to get wrong. + */ +fun orderByPosition( + items: List, + position: (T) -> Int?, + name: (T) -> String, +): List = + items.sortedWith( + compareBy { position(it) ?: 0 } + .thenBy { name(it).lowercase() }, + ) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./gradlew testDebugUnitTest --tests '*HierarchyOrderTest*'` +Expected: PASS, 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/dev/scuttle/inventory/ui/common/HierarchyOrder.kt app/src/test/java/dev/scuttle/inventory/HierarchyOrderTest.kt +git commit -m "feat: one ordering rule for every hierarchy list + +Manual position wins; name is only the tie-break for never-reordered items. +A star is deliberately not an input: it is a marker and a filter, never a +sort. Shelf order is physical, and a star that floated the bottom shelf to the +top would leave the list disagreeing with the actual fridge." +``` + +--- + +## Task 3: The delete-strategy dialog + +The user-facing half of the safety fix. Built and tested before it is wired to anything. + +**Files:** +- Create: `ui/hierarchy/DeleteStrategyDialog.kt`, `ui/hierarchy/DeletePlan.kt` +- Modify: `res/values/strings.xml`, `res/values-nl/strings.xml` +- Test: `app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt` + +**Interfaces:** +- Produces: `data class DeletePlan(val shelfIds, val locationIds, val productCount, val availableTargets)`; `@Composable fun DeleteStrategyDialog(plan, onDismiss, onConfirm: (ShelfDeleteStrategy?, Long?) -> Unit)`. + +- [ ] **Step 1: Write the failing test** + +Create `app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt`: + +```kotlin +package dev.scuttle.inventory + +import dev.scuttle.inventory.ui.hierarchy.DeletePlan +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeletePlanTest { + @Test + fun an_empty_selection_needs_no_strategy() { + val plan = DeletePlan(itemCount = 2, productCount = 0, hasOtherTargets = true) + + // Nothing to rescue — a plain confirm is enough. + assertFalse(plan.needsStrategy) + } + + @Test + fun a_selection_holding_products_needs_a_strategy() { + val plan = DeletePlan(itemCount = 1, productCount = 12, hasOtherTargets = true) + + assertTrue(plan.needsStrategy) + } + + @Test + fun move_is_unavailable_when_there_is_nowhere_to_move_to() { + // Deleting the household's only location: there is no other location to + // take the contents, so the UI must not offer "move" as a dead option. + val plan = DeletePlan(itemCount = 1, productCount = 12, hasOtherTargets = false) + + assertTrue(plan.needsStrategy) + assertFalse(plan.canMove) + } + + @Test + fun move_is_available_when_a_target_exists() { + val plan = DeletePlan(itemCount = 1, productCount = 12, hasOtherTargets = true) + + assertTrue(plan.canMove) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./gradlew testDebugUnitTest --tests '*DeletePlanTest*'` +Expected: FAIL — unresolved reference `DeletePlan`. + +- [ ] **Step 3: Write the plan model** + +Create `ui/hierarchy/DeletePlan.kt`: + +```kotlin +package dev.scuttle.inventory.ui.hierarchy + +/** + * What one delete gesture is about to destroy, and therefore what the user has + * to be asked. + * + * A BATCH gets ONE dialog, not one per item: selecting three shelves and being + * interrogated three times is worse than being told "3 shelves · 17 products" + * once and choosing once. + */ +data class DeletePlan( + /** How many containers (shelves or locations) are selected. */ + val itemCount: Int, + /** How many products live inside them, in total. */ + val productCount: Int, + /** Is there anywhere else to put the contents? False when this is the only shelf/location. */ + val hasOtherTargets: Boolean, +) { + /** An empty container is safe to delete outright — a plain confirm will do. */ + val needsStrategy: Boolean get() = productCount > 0 + + /** Never offer "move" when there is nothing to move to. */ + val canMove: Boolean get() = hasOtherTargets +} +``` + +- [ ] **Step 4: Add the strings (EN + NL)** + +`res/values/strings.xml`: + +```xml + + Delete %1$d item(s)? + %1$d product(s) are stored inside. What should happen to them? + Move them somewhere else + Keep them here, in Unsorted + Delete them too + Where should they go? + There is nowhere else to move them. + Undo + Restored. + Couldn\'t undo — this was already restored or permanently removed. + Unsorted +``` + +`res/values-nl/strings.xml` (same keys, same order): + +```xml + + %1$d item(s) verwijderen? + Er liggen %1$d product(en) in. Wat moet daarmee gebeuren? + Verplaats ze ergens anders heen + Bewaar ze hier, in Ongesorteerd + Verwijder ze ook + Waar moeten ze heen? + Er is geen andere plek om ze heen te verplaatsen. + Ongedaan maken + Hersteld. + Ongedaan maken lukt niet — dit is al hersteld of definitief verwijderd. + Ongesorteerd +``` + +- [ ] **Step 5: Write the dialog** + +Create `ui/hierarchy/DeleteStrategyDialog.kt`. Follow the `AlertDialog` shape used by `HouseholdThemeDialog` — `title` / `text` / `confirmButton` / `dismissButton`, error-coloured confirm, `testTag` on each option. + +```kotlin +package dev.scuttle.inventory.ui.hierarchy + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.scuttle.inventory.R +import dev.scuttle.inventory.data.hierarchy.ShelfDeleteStrategy + +/** + * Asks what to do with the products inside the shelves being deleted. + * + * One dialog per GESTURE, not per item: the summary covers the whole batch and + * one choice applies to all of it. + */ +@Composable +fun DeleteStrategyDialog( + plan: DeletePlan, + targets: List>, + onDismiss: () -> Unit, + onConfirm: (ShelfDeleteStrategy?, Long?) -> Unit, +) { + // Default to the safest option that is actually available. Never default to + // destroying data. + var strategy by remember { + mutableStateOf( + if (plan.canMove) ShelfDeleteStrategy.MOVE_PRODUCTS else ShelfDeleteStrategy.UNSORT_PRODUCTS, + ) + } + var targetId by remember { mutableStateOf(targets.firstOrNull()?.first) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.delete_strategy_title, plan.itemCount)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.delete_strategy_summary, plan.productCount)) + + if (plan.canMove) { + StrategyOption( + label = stringResource(R.string.delete_strategy_move), + selected = strategy == ShelfDeleteStrategy.MOVE_PRODUCTS, + tag = "delete-strategy-move", + onSelect = { strategy = ShelfDeleteStrategy.MOVE_PRODUCTS }, + ) + } else { + Text(stringResource(R.string.delete_strategy_no_target)) + } + + StrategyOption( + label = stringResource(R.string.delete_strategy_unsort), + selected = strategy == ShelfDeleteStrategy.UNSORT_PRODUCTS, + tag = "delete-strategy-unsort", + onSelect = { strategy = ShelfDeleteStrategy.UNSORT_PRODUCTS }, + ) + + StrategyOption( + label = stringResource(R.string.delete_strategy_delete), + selected = strategy == ShelfDeleteStrategy.DELETE_PRODUCTS, + tag = "delete-strategy-delete", + onSelect = { strategy = ShelfDeleteStrategy.DELETE_PRODUCTS }, + ) + + if (strategy == ShelfDeleteStrategy.MOVE_PRODUCTS && targets.isNotEmpty()) { + Text(stringResource(R.string.delete_strategy_pick_target)) + targets.forEach { (id, name) -> + StrategyOption( + label = name, + selected = targetId == id, + tag = "delete-target-$id", + onSelect = { targetId = id }, + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + onConfirm( + strategy, + if (strategy == ShelfDeleteStrategy.MOVE_PRODUCTS) targetId else null, + ) + }, + enabled = strategy != ShelfDeleteStrategy.MOVE_PRODUCTS || targetId != null, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.testTag("delete-strategy-confirm"), + ) { Text(stringResource(R.string.action_delete)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + }, + ) +} + +@Composable +private fun StrategyOption( + label: String, + selected: Boolean, + tag: String, + onSelect: () -> Unit, +) { + androidx.compose.foundation.layout.Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + modifier = + Modifier + .selectable(selected = selected, onClick = onSelect) + .testTag(tag), + ) { + RadioButton(selected = selected, onClick = onSelect) + Text(label) + } +} +``` + +- [ ] **Step 6: Run the tests and the style gate** + +Run: `./gradlew testDebugUnitTest --tests '*DeletePlanTest*' && ./gradlew ktlintCheck detekt` +Expected: all PASS. + +- [ ] **Step 7: Commit** + +```bash +git add app/src/main/java/dev/scuttle/inventory/ui/hierarchy app/src/main/res/values/strings.xml app/src/main/res/values-nl/strings.xml app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt +git commit -m "feat: delete-strategy dialog + +Asks what to do with the products inside a container being deleted: move them, +keep them in Unsorted, or delete them too. One dialog per gesture — a batch of +three shelves is summarised once, not interrogated three times. + +Defaults to the safest available option; never defaults to destroying data, +and never offers 'move' when there is nowhere to move to." +``` + +--- + +## Task 4: Edit mode on the shelves list (LocationDetail) + +The hardest screen, and the one with today's dangerous no-confirmation delete. Includes the tabs⇄list toggle. + +**Files:** +- Modify: `ui/shelves/ShelvesViewModel.kt`, `ui/location/LocationDetailScreen.kt` +- Create: `data/settings/ShelfViewStore.kt`, `data/settings/SharedPrefsShelfViewStore.kt`, `ui/hierarchy/EditableRow.kt` +- Modify: `di/StorageModule.kt`, `data/auth/SessionCleaner.kt`, `res/values/strings.xml`, `res/values-nl/strings.xml` +- Test: `app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt` (extend) + +**Interfaces:** +- Consumes: `ShelfRepository.rename/reorder/deleteWithStrategy` (Task 1), `orderByPosition` (Task 2), `DeleteStrategyDialog` + `DeletePlan` (Task 3). +- Produces: `ShelvesUiState` gains `editMode: Boolean`, `selected: Set`, `pendingDelete: DeletePlan?`, `lastBatchId: String?`, `listView: Boolean`. `ShelvesViewModel` gains `enterEditMode()`, `exitEditMode()`, `toggleSelection(id)`, `moveUp(id)`, `moveDown(id)`, `rename(id, name)`, `requestDelete()`, `confirmDelete(strategy, targetId)`, `undoDelete()`, `toggleListView()`. + +- [ ] **Step 1: Write the failing tests** + +Append to `app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt`. Extend `FakeShelfRepository` first to implement the new methods: + +```kotlin + override suspend fun rename( + householdId: Long, + locationId: Long, + shelfId: Long, + name: String, + ): ShelfDto { + val index = items.indexOfFirst { it.id == shelfId } + val updated = items[index].copy(name = name) + items[index] = updated + return updated + } + + override suspend fun reorder( + householdId: Long, + locationId: Long, + ids: List, + ): List { + val byId = items.associateBy { it.id } + val reordered = ids.mapIndexedNotNull { i, id -> byId[id]?.copy(position = i) } + items.clear() + items.addAll(reordered) + return reordered + } + + var lastStrategy: ShelfDeleteStrategy? = null + var lastBatchId: String? = null + + override suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + shelfId: Long, + batchId: String, + strategy: ShelfDeleteStrategy?, + targetShelfId: Long?, + ) { + lastStrategy = strategy + lastBatchId = batchId + items.removeIf { it.id == shelfId } + } +``` + +Then the tests: + +```kotlin + @Test + fun move_up_swaps_a_shelf_with_the_one_above_it() = + runTest { + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Middle", 1, 1L)) + items.add(ShelfDto(3, "Bottom", 2, 1L)) + } + val viewModel = ShelvesViewModel(repo) + viewModel.load(householdId = 1, locationId = 1) + + viewModel.moveUp(3L) + + assertEquals( + listOf("Top", "Bottom", "Middle"), + viewModel.state.value.shelves.map { it.name }, + ) + } + + @Test + fun move_up_on_the_first_shelf_does_nothing() = + runTest { + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val viewModel = ShelvesViewModel(repo) + viewModel.load(householdId = 1, locationId = 1) + + viewModel.moveUp(1L) + + assertEquals(listOf("Top"), viewModel.state.value.shelves.map { it.name }) + } + + @Test + fun the_unsorted_shelf_cannot_be_selected_for_deletion() = + runTest { + // It is a system shelf: it holds the products the user chose to KEEP. + // Letting a stray checkbox tap destroy it would defeat the entire point. + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Unsorted", 1, 1L, is_system = true)) + } + val viewModel = ShelvesViewModel(repo) + viewModel.load(householdId = 1, locationId = 1) + + viewModel.enterEditMode() + viewModel.toggleSelection(2L) + + assertTrue(viewModel.state.value.selected.isEmpty()) + } + + @Test + fun confirming_a_delete_sends_the_chosen_strategy_and_one_batch_id() = + runTest { + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Bottom", 1, 1L)) + } + val viewModel = ShelvesViewModel(repo) + viewModel.load(householdId = 1, locationId = 1) + + viewModel.enterEditMode() + viewModel.toggleSelection(1L) + viewModel.toggleSelection(2L) + viewModel.requestDelete() + viewModel.confirmDelete(ShelfDeleteStrategy.DELETE_PRODUCTS, targetId = null) + + assertEquals(ShelfDeleteStrategy.DELETE_PRODUCTS, repo.lastStrategy) + // Both shelves share ONE batch id, so one Undo brings both back. + assertNotNull(repo.lastBatchId) + assertEquals(repo.lastBatchId, viewModel.state.value.lastBatchId) + assertTrue(viewModel.state.value.shelves.isEmpty()) + } + + @Test + fun entering_edit_mode_forces_the_list_view() = + runTest { + // Tabs cannot host reorder buttons or inline rename. The flip is also + // why the manual list/tab toggle exists: by the time the user first + // enters edit mode, the list is somewhere they have already been. + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val viewModel = ShelvesViewModel(repo) + viewModel.load(householdId = 1, locationId = 1) + assertFalse(viewModel.state.value.listView) + + viewModel.enterEditMode() + assertTrue(viewModel.state.value.listView) + + viewModel.exitEditMode() + assertFalse(viewModel.state.value.listView) + } +``` + +Add the imports `org.junit.Assert.assertNotNull` and `dev.scuttle.inventory.data.hierarchy.ShelfDeleteStrategy`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `./gradlew testDebugUnitTest --tests '*ShelvesViewModelTest*'` +Expected: FAIL — unresolved references `moveUp`, `enterEditMode`, `selected`, `listView`. + +- [ ] **Step 3: Add the shelf-view preference store** + +Create `data/settings/ShelfViewStore.kt`: + +```kotlin +package dev.scuttle.inventory.data.settings + +/** Whether the shelves screen shows tabs+products or a plain shelf list. Global, not per-location. */ +interface ShelfViewStore { + fun isListView(): Boolean + + fun setListView(listView: Boolean) + + fun clear() {} +} +``` + +Create `data/settings/SharedPrefsShelfViewStore.kt` (same shape as `SharedPrefsThemeModeStore`, sharing the `"inventory_settings"` prefs file, key `"shelf_list_view"`, default `false`). + +Register in `di/StorageModule.kt`: + +```kotlin + @Provides + @Singleton + fun provideShelfViewStore( + @ApplicationContext context: Context, + ): ShelfViewStore = SharedPrefsShelfViewStore(context) +``` + +**Wire it into `data/auth/SessionCleaner.clear()`** alongside the other stores. + +- [ ] **Step 4: Extend `ShelvesViewModel`** + +Add to `ShelvesUiState` and replace the old delete-mode block. Note `deleteMode`/`selectedShelves`/`deleteSelected`/`enterDeleteMode`/`exitDeleteMode` are **removed** — edit mode supersedes them. + +```kotlin +data class ShelvesUiState( + val loading: Boolean = false, + val refreshing: Boolean = false, + val shelves: List = emptyList(), + val newName: String = "", + val error: String? = null, + val editMode: Boolean = false, + val selected: Set = emptySet(), + val listView: Boolean = false, + /** Non-null while the strategy dialog is open. */ + val pendingDelete: DeletePlan? = null, + /** The batch just deleted, for the Undo snackbar. Cleared once consumed. */ + val lastBatchId: String? = null, +) +``` + +And the ViewModel body — inject `ShelfViewStore`, `RestoreRepository`, and `HierarchyStore`: + +```kotlin +@HiltViewModel +class ShelvesViewModel + @Inject + constructor( + private val repository: ShelfRepository, + private val restoreRepository: RestoreRepository, + private val shelfViewStore: ShelfViewStore, + private val hierarchyStore: HierarchyStore, + ) : ViewModel() { + // ... householdId / locationId / _state as before, but seed listView: + private val _state = MutableStateFlow(ShelvesUiState(listView = shelfViewStore.isListView())) + + /** Remembered so exiting edit mode restores the view the user actually chose. */ + private var viewBeforeEdit: Boolean = false + + fun toggleListView() { + val next = !_state.value.listView + shelfViewStore.setListView(next) + _state.update { it.copy(listView = next) } + } + + fun enterEditMode() { + // Tabs cannot host reorder buttons or an inline rename target, so edit + // mode always runs in the list view — and restores the user's choice + // on the way out. + viewBeforeEdit = _state.value.listView + _state.update { it.copy(editMode = true, listView = true, selected = emptySet()) } + } + + fun exitEditMode() = + _state.update { + it.copy(editMode = false, listView = viewBeforeEdit, selected = emptySet(), pendingDelete = null) + } + + fun toggleSelection(shelfId: Long) = + _state.update { state -> + // The Unsorted shelf holds the products the user chose to KEEP. + // A stray checkbox tap must not be able to destroy it. + val shelf = state.shelves.firstOrNull { it.id == shelfId } + if (shelf == null || shelf.is_system) { + state + } else { + val updated = + if (shelfId in state.selected) state.selected - shelfId else state.selected + shelfId + state.copy(selected = updated) + } + } + + fun rename( + shelfId: Long, + name: String, + ) { + val h = householdId ?: return + val l = locationId ?: return + val trimmed = name.trim() + if (trimmed.isEmpty()) return + launchLoading { + val updated = repository.rename(h, l, shelfId, trimmed) + _state.update { s -> s.copy(shelves = s.shelves.map { if (it.id == shelfId) updated else it }) } + hierarchyStore.refresh() + } + } + + fun moveUp(shelfId: Long) = move(shelfId, -1) + + fun moveDown(shelfId: Long) = move(shelfId, +1) + + private fun move( + shelfId: Long, + delta: Int, + ) { + val h = householdId ?: return + val l = locationId ?: return + val current = _state.value.shelves.filter { !it.is_system } + val index = current.indexOfFirst { it.id == shelfId } + val target = index + delta + if (index < 0 || target !in current.indices) return + + val reordered = current.toMutableList().apply { add(target, removeAt(index)) } + + // Optimistic: the row visibly moves on tap. The server call rewrites + // every position in one transaction, so a failure snaps the whole list + // back rather than leaving it half-sorted. + _state.update { it.copy(shelves = reordered + it.shelves.filter { s -> s.is_system }) } + + launchLoading { + val server = repository.reorder(h, l, reordered.map { it.id }) + _state.update { it.copy(shelves = server) } + hierarchyStore.refresh() + } + } + + /** Open the strategy dialog for the current selection. */ + fun requestDelete() { + val state = _state.value + val selected = state.shelves.filter { it.id in state.selected } + if (selected.isEmpty()) return + + // productCount comes from the shelves' own product counts once the API + // exposes them; until then the dialog asks whenever anything is selected + // and the server is the backstop (it 422s a strategy-less delete). + _state.update { + it.copy( + pendingDelete = + DeletePlan( + itemCount = selected.size, + productCount = selected.sumOf { s -> productCountFor(s.id) }, + hasOtherTargets = state.shelves.any { s -> s.id !in state.selected && !s.is_system }, + ), + ) + } + } + + fun confirmDelete( + strategy: ShelfDeleteStrategy?, + targetId: Long?, + ) { + val h = householdId ?: return + val l = locationId ?: return + val ids = _state.value.selected.toList() + if (ids.isEmpty()) return + + // ONE batch id for the whole gesture. Deleting three shelves is three + // requests; if each minted its own id they would land in three batches + // and Undo would restore only one of them. + val batchId = UUID.randomUUID().toString() + + launchLoading { + ids.forEach { id -> repository.deleteWithStrategy(h, l, id, batchId, strategy, targetId) } + _state.update { + it.copy( + editMode = false, + selected = emptySet(), + pendingDelete = null, + lastBatchId = batchId, + shelves = it.shelves.filter { s -> s.id !in ids }, + ) + } + hierarchyStore.refresh() + } + } + + fun cancelDelete() = _state.update { it.copy(pendingDelete = null) } + + fun undoDelete() { + val h = householdId ?: return + val batchId = _state.value.lastBatchId ?: return + launchLoading { + restoreRepository.restore(h, batchId) + _state.update { it.copy(lastBatchId = null) } + refresh() + hierarchyStore.refresh() + } + } + + fun consumeLastBatch() = _state.update { it.copy(lastBatchId = null) } +``` + +> **`productCountFor(id)`**: the shelves list endpoint does not return a product count today. Add `product_count` to `ShelfResource` on the backend (one line in `toArray()`), surface it as `ShelfDto.product_count: Int = 0`, and read it here. Do that as part of this step — the dialog's summary is a lie without it. + +- [ ] **Step 5: Rebuild `LocationDetailScreen`** + +Replace the delete-mode top bar with an edit-mode one, add the view toggle, and render either the tabs+pager (as today) or a shelf list. The shelf list row is `EditableRow` (create `ui/hierarchy/EditableRow.kt` — a `FrostCard` with an optional leading `Checkbox`, the name, and trailing `IconButton`s for up/down, all gated on `editMode`). Wire the dialog and the snackbar: + +```kotlin + // The strategy dialog for the current selection. + state.pendingDelete?.let { plan -> + DeleteStrategyDialog( + plan = plan, + targets = + state.shelves + .filter { it.id !in state.selected && !it.is_system } + .map { it.id to it.name }, + onDismiss = shelvesViewModel::cancelDelete, + onConfirm = { strategy, targetId -> shelvesViewModel.confirmDelete(strategy, targetId) }, + ) + } + + // Undo. A snackbar with an action, rather than SnackbarErrorEffect (which is + // for one-shot errors and has no action slot). + val undoLabel = stringResource(R.string.delete_undo) + LaunchedEffect(state.lastBatchId) { + val batch = state.lastBatchId ?: return@LaunchedEffect + val result = + snackbarHostState.showSnackbar( + message = deletedMessage, + actionLabel = undoLabel, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + shelvesViewModel.undoDelete() + } else { + shelvesViewModel.consumeLastBatch() + } + } +``` + +Top bar actions become: view-toggle `IconButton` (list ⇄ tabs, hidden in edit mode), pencil `IconButton` → `enterEditMode()`, and `+` → add sheet. In edit mode: Cancel `TextButton` in the navigation slot, and an error-coloured Delete `Button` enabled when `selected.isNotEmpty()`. + +New strings (EN + NL): `location_edit_shelves_cd`, `location_view_toggle_cd`, `shelf_move_up_cd`, `shelf_move_down_cd`, `shelf_rename_title`, `shelves_deleted`. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `./gradlew testDebugUnitTest --tests '*ShelvesViewModelTest*'` +Expected: PASS — the 5 existing tests (minus the two removed delete-mode ones, which are replaced) plus the 5 new ones. + +- [ ] **Step 7: Run the full gate** + +Run: `./gradlew testDebugUnitTest ktlintCheck detekt` +Expected: all PASS. + +- [ ] **Step 8: Commit** + +```bash +git add app/src/main/java/dev/scuttle/inventory app/src/main/res app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt +git commit -m "feat: edit mode on the shelves list, and a tabs/list toggle + +Replaces the bulk delete mode — which deleted checked shelves with NO +confirmation dialog at all — with an edit mode that renames, reorders, and +deletes via the strategy dialog, with snackbar undo. + +Edit mode always runs in the list view, because a tab strip cannot host +reorder buttons or an inline rename. The manual list/tab toggle exists so +that by the time a user first enters edit mode, the list is somewhere they +have already been. + +The Unsorted shelf cannot be selected: it holds the products the user chose +to keep." +``` + +--- + +## Task 5: Edit mode on the locations list (StorageOverview) + +**Files:** +- Modify: `ui/storage/StorageOverviewViewModel.kt`, `ui/storage/StorageOverviewScreen.kt` +- Test: `app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt` (extend) + +**Interfaces:** mirrors Task 4 exactly — `enterEditMode()`, `exitEditMode()`, `toggleSelection(id)`, `moveUp/moveDown(id)`, `rename(id, name, type)`, `requestDelete()`, `confirmDelete(strategy, targetId)`, `undoDelete()`. The strategy enum is `LocationDeleteStrategy`, and **`canMove` is false when the household has only one location** — the dialog then offers only delete-or-cancel. + +- [ ] **Step 1: Write the failing tests** + +Append to `StorageOverviewViewModelTest.kt`. The key one: + +```kotlin + @Test + fun deleting_the_only_location_offers_no_move_target() = + runTest { + // There is nowhere to move the contents to, so the dialog must not + // dangle a "move" option that cannot work. + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Fridge", "fridge", 0)) } + val viewModel = StorageOverviewViewModel(repo, FakeHierarchyStore(), FakeRestoreRepository()) + viewModel.load(householdId = 1) + + viewModel.enterEditMode() + viewModel.toggleSelection(1L) + viewModel.requestDelete() + + assertFalse(viewModel.state.value.pendingDelete!!.canMove) + } + + @Test + fun a_rename_updates_the_row_in_place() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Freezr", "freezer", 0)) } + val viewModel = StorageOverviewViewModel(repo, FakeHierarchyStore(), FakeRestoreRepository()) + viewModel.load(householdId = 1) + + viewModel.rename(1L, "Freezer", "freezer") + + assertEquals("Freezer", viewModel.state.value.locations.first().name) + } +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `./gradlew testDebugUnitTest --tests '*StorageOverviewViewModelTest*'` +Expected: FAIL — unresolved references. + +- [ ] **Step 3: Extend the ViewModel** + +Same shape as Task 4. `StorageOverviewUiState` gains `editMode`, `selected`, `pendingDelete`, `lastBatchId`. Reuse `orderByPosition` when rendering. Keep the existing `deleteLocation(id)` **removed** in favour of the strategy flow — and remember that **every mutation must call `hierarchyStore.refresh()`** and use `launchLoading { }` with `refreshing = false`. + +- [ ] **Step 4: Rewrite the screen's delete affordance** + +**Remove the `SwipeToDismissBox`.** Delete now lives behind edit mode, which is the entire point — a swipe should not be able to destroy a fridge full of food. Rows become `EditableRow` (from Task 4). The pencil goes in the top bar; the edit sheet (name + type `FilterChip`s, reusing `STORAGE_TYPES`) opens on row tap in edit mode. + +- [ ] **Step 5: Run the tests and the gate** + +Run: `./gradlew testDebugUnitTest ktlintCheck detekt` +Expected: all PASS. + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/dev/scuttle/inventory/ui/storage app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt +git commit -m "feat: edit mode on the locations list + +Rename, retype, reorder and delete-with-strategy, behind the same pencil as +the shelves list. + +Removes swipe-to-delete on locations: a swipe should not be able to destroy a +fridge full of food. Deletion now requires entering edit mode, selecting, and +answering the strategy dialog." +``` + +--- + +## Task 6: Household edit page + +**Files:** +- Create: `ui/households/HouseholdEditScreen.kt` +- Modify: `ui/households/HouseholdsScreen.kt`, `ui/households/HouseholdsViewModel.kt`, `MainActivity.kt` +- Delete: `ui/households/HouseholdThemeDialog.kt` (its content moves into the edit screen) + +- [ ] **Step 1: Write the failing test** + +In `HouseholdsViewModelTest.kt`: + +```kotlin + @Test + fun renaming_a_household_updates_it_in_place() = + runTest { + val repo = FakeHouseholdRepository().apply { items.add(HouseholdDto(1, "Huse", "AAAA-1111")) } + val viewModel = HouseholdsViewModel(repo) + + viewModel.update(1L, name = "House", color = null, icon = null) + + assertEquals("House", viewModel.state.value.households.first().name) + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `./gradlew testDebugUnitTest --tests '*HouseholdsViewModelTest*'` +Expected: FAIL — `update` unresolved. + +- [ ] **Step 3: Build the edit screen** + +`HouseholdEditScreen(householdId)` — a `Scaffold` + `Column(verticalScroll)`: + +1. **Name** — `OutlinedTextField` + Save. +2. **Appearance** — the colour swatches and icon grid lifted verbatim from `HouseholdThemeDialog`, keeping the `testTag("theme-color-$key")` tags so the existing flow test still passes. +3. **Danger zone** — a visually separated section (an error-tinted `Card`, not a `FrostCard` — per `FrostCard`'s own doc comment, semantically-coloured cards stay on plain `Card`) containing **Leave household** with its existing confirm dialog. + +Leaving is not an edit of the household — it ends *your membership*. It sits in a danger zone rather than next to the rename field so the two are never confused. There is still **no "delete household"**: nobody owns one. That arrives with roles (Spec 2), and this danger zone is where it will land — leave a comment saying so. + +- [ ] **Step 4: Strip the household card down** + +In `HouseholdsScreen.kt`, remove the palette `IconButton` and the Leave `TextButton` from the row. The card keeps the avatar, the name, and the share/invite icon. Add the pencil to the top bar; in edit mode, tapping a row navigates to `HouseholdEditScreen`. + +- [ ] **Step 5: Add the route** + +In `MainActivity.kt`, add `const val HOUSEHOLD_EDIT = "household-edit/{householdId}"` + a `householdEdit(id)` builder + the `composable(...)` block with a `NavType.LongType` argument, following the `Routes.LOCATION` pattern exactly. + +- [ ] **Step 6: Run the tests and the gate** + +Run: `./gradlew testDebugUnitTest ktlintCheck detekt` +Expected: all PASS. + +- [ ] **Step 7: Commit** + +```bash +git add app/src/main/java/dev/scuttle/inventory/ui/households app/src/main/java/dev/scuttle/inventory/MainActivity.kt app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt +git commit -m "feat: household edit page + +Rename plus the colour/icon picker (moved out of the palette-icon dialog), and +a danger zone holding Leave. + +Leave is not an edit of the household — it ends your membership — so it sits +in a separated danger zone rather than beside the rename field. Delete +household still does not exist: nobody owns one. That lands with roles." +``` + +--- + +## Task 7: Navigation rework + +**Files:** +- Modify: `MainActivity.kt`, `ui/settings/SettingsScreen.kt`, `res/values/strings.xml`, `res/values-nl/strings.xml` +- Test: `app/src/androidTest/java/dev/scuttle/inventory/flow/*` (fix `bottom-nav-households` references) + +- [ ] **Step 1: Change the tabs** + +In `MainActivity.kt`, replace `bottomTabs`: + +```kotlin + val bottomTabs = + listOf( + BottomTab("dashboard", Routes.DASHBOARD, R.string.nav_dashboard, Icons.Filled.SpaceDashboard), + BottomTab("home", Routes.HOME, R.string.nav_storage, Icons.Filled.Home), + // The centre slot is the primary-ACTION slot. Scanning is a weekly + // grocery-trip action; search is an occasional "where did I put it", + // and it already has a top-bar icon. + BottomTab("scanner", Routes.SCANNER, R.string.nav_scan, Icons.Filled.QrCodeScanner), + BottomTab("missing-items", Routes.MISSING_ITEMS, R.string.nav_missing_items, Icons.Filled.Warning), + // Not "Settings": it now holds households, join/invite and account. + BottomTab("more", Routes.SETTINGS, R.string.nav_more, Icons.Filled.MoreHoriz), + ) +``` + +Remove the `households` tab and the search-tab special cases (the `enabled = drawerUi.entries.isNotEmpty()` guard and the household-picker `ModalBottomSheet`) — Search is reached from the top bar now, where the household is already known. + +Delete the `onOpenSettings` gear `IconButton` from every top-level screen's app bar; `More` replaces it. + +- [ ] **Step 2: Add the strings** + +`nav_scan` / `nav_more` in EN (`Scan`, `More`) and NL (`Scannen`, `Meer`). + +- [ ] **Step 3: Fix the flow tests** + +`grep -rl "bottom-nav-households" app/src/androidTest` — each hit navigates to Households via the tab. Change them to go through `bottom-nav-more` → the "My households" button. + +- [ ] **Step 4: Run the gate** + +Run: `./gradlew testDebugUnitTest ktlintCheck detekt` +Expected: all PASS. (Flow tests are nightly, not on PR — but run `./gradlew connectedDebugAndroidTest` locally if an emulator is available, since this task touches every one of them.) + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/dev/scuttle/inventory app/src/main/res app/src/androidTest +git commit -m "feat: rework the bottom navigation + +Households leaves the bar — it is a management screen, not a daily +destination, and the Storage tab already acts across households. Scan takes +the freed centre slot (the primary-action slot; scanning is a weekly action, +search an occasional one). Settings comes back into the bar as More, since it +now holds households, join/invite and account." +``` + +--- + +## Task 8: Collapsible household groups + +**Files:** +- Create: `data/settings/HouseholdViewStore.kt`, `data/settings/SharedPrefsHouseholdViewStore.kt` +- Modify: `ui/home/AllStoragesScreen.kt`, `ui/home/AllStoragesViewModel.kt`, `di/StorageModule.kt`, `data/auth/SessionCleaner.kt` + +**Interfaces:** `HouseholdViewStore.collapsed(): Set` / `toggleCollapsed(id)` / `order(): List` / `setOrder(ids)` / `clear()`. + +Household order is **device-local** (D8): a household's *physical* structure is shared reality, but "which of my households do I care about" is a personal view preference. It never goes to the server. + +- [ ] **Step 1: Write the failing test** + +`app/src/test/java/dev/scuttle/inventory/AllStoragesViewModelTest.kt`: + +```kotlin + @Test + fun collapsing_a_household_persists() = + runTest { + val store = FakeHouseholdViewStore() + val viewModel = AllStoragesViewModel(FakeFavoritesStore(), store) + + viewModel.toggleCollapsed(1L) + + assertTrue(1L in viewModel.state.value.collapsedHouseholdIds) + assertTrue(1L in store.collapsed()) + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `./gradlew testDebugUnitTest --tests '*AllStoragesViewModelTest*'` +Expected: FAIL. + +- [ ] **Step 3: Implement the store, the ViewModel state, and the collapsing header** + +`SharedPrefsHouseholdViewStore` uses its own prefs file `"household_view"` (mirroring `SharedPrefsFavoritesStore`'s `"favorites"`), storing a `StringSet` of collapsed ids and an ordered id list. **Wire `clear()` into `SessionCleaner`.** + +In `AllStoragesScreen`, the household header becomes clickable with a rotating chevron; the group's rows render only when not collapsed. Apply `orderByPosition` to the locations within each group, and the store's order to the groups themselves. + +- [ ] **Step 4: Run the gate and commit** + +```bash +git add app/src/main/java/dev/scuttle/inventory app/src/test/java/dev/scuttle/inventory/AllStoragesViewModelTest.kt +git commit -m "feat: collapsible household groups on the storage list + +Collapse state and household order persist per device. A household's physical +structure is shared reality and lives on the server; which of your households +you care about is a personal view preference and stays local." +``` + +--- + +## Task 9: Flow tests for the delete-safety path + +The regression net for the bug that started all this. + +**Files:** +- Create: `app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt`, `.../RenameLocationFlowTest.kt` +- Create fixtures: `app/src/androidTest/assets/fixtures/shelves_two.json`, `shelf_renamed.json`, `location_renamed.json`, `restore_ok.json` +- Delete: the old swipe-to-delete assertions in `DeleteLocationFlowTest.kt` (the gesture no longer exists) + +- [ ] **Step 1: Write the flow test** + +`DeleteShelfStrategyFlowTest` — sign in, reach LocationDetail, enter edit mode, select a shelf, tap Delete, assert the **strategy dialog appears**, pick "Delete them too", confirm, assert the shelf is gone and the **Undo snackbar shows**. Follow `DeleteLocationFlowTest`'s structure exactly: `mockServer.route(path, fixture)` per endpoint, `Thread.sleep` + `waitForIdle()` + `waitUntilAtLeastOneExists`, assertions on literal EN strings. + +- [ ] **Step 2: Rewrite `DeleteLocationFlowTest`** + +The swipe gesture is gone (Task 5). Rewrite it to go through edit mode, and keep the name — it still guards location deletion. + +- [ ] **Step 3: Run** + +Run: `./gradlew connectedDebugAndroidTest` (needs an emulator; CI runs these nightly). +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add app/src/androidTest +git commit -m "test: flow tests for the delete-strategy path + +Covers the bug the whole change exists to fix: deleting a shelf that holds +products now stops and asks, instead of destroying them silently." +``` + +--- + +## Task 10: Rewrite the contradicted rules and clean up + +**Files:** +- Modify: `CLAUDE.md`, `ROADMAP.md` +- Delete: `ShelfRepository.delete` (the old 3-arg version) and its fakes' overrides + +- [ ] **Step 1: Fix `CLAUDE.md`** + +**Navigation section** — it still documents the old bar. Replace: + +> Navigation: Household → Storage overview → Shelves (tabs) → Products. A 5-tab bottom bar (Dashboard / Storage / Households / Missing / Search) is the app's only navigation surface — there is no drawer. Settings is reached via a gear icon in each top-level screen's app bar. + +with: + +```markdown +Navigation: Household → Storage overview → Shelves (tabs *or* list — user toggle) → +Products. A 5-tab bottom bar (**Dashboard / Storage / Scan / Missing / More**) is the +app's only navigation surface — there is no drawer, and no gear icon. **More** is the +management hub: households, join/invite, language, theme, account. **Search** is reached +from the top bar of the screens that have a household in scope. + +**Editing the hierarchy** lives behind a pencil (edit mode) on the households, locations +and shelves lists — never in Settings. In edit mode a row gets a checkbox (multi-select +delete) and move up/down buttons (reorder); tapping the row body opens its edit sheet. +Deleting a non-empty container always asks what to do with the contents. +``` + +**Scope guardrails** — remove `roles/permissions` from the refuse-to-add list; it is now Spec 2. Note that the `no expiry/reminders, recipes, shopping list, offline mode` bans still hold. + +- [ ] **Step 2: Delete the superseded delete method** + +Remove `ShelfRepository.delete(householdId, locationId, shelfId)` and `LocationRepository.delete(householdId, locationId)` now that every call site uses `deleteWithStrategy`. Remove the overrides from the test fakes. + +- [ ] **Step 3: Run the full gate** + +Run: `./gradlew testDebugUnitTest ktlintCheck detekt lint` +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add CLAUDE.md ROADMAP.md app/src/main/java/dev/scuttle/inventory app/src/test +git commit -m "docs: update CLAUDE.md for the new nav and the roles decision + +The navigation section still described the 5-tab bar with Households and a +gear-icon Settings, and the scope guardrails still banned roles/permissions — +both now contradicted by shipped behaviour and by the 2026-07-13 decision. + +Also drops the superseded strategy-less delete methods." +``` + +--- + +## Self-review + +**Spec coverage.** Nav rework (T7) · edit mode on all three lists (T4, T5, T6) · shelf tabs⇄list toggle (T4) · ordering rule (T2) · collapsible groups + local household order (T8) · household edit page with danger zone (T6) · delete-strategy dialog + batch undo (T3, T4, T5) · `canRestructure` seam — **see gap below** · stars on products — **see gap below** · CLAUDE.md rewrite (T10). + +**Two gaps I am flagging rather than hiding:** + +1. **The `canRestructure` seam is not surfaced on the client.** The backend has the policy (backend T2), but nothing here reads it — every user sees the pencil, because every member may restructure today. That is correct behaviour *now*, and it means the client work for roles is deferred wholesale to Spec 2. If you want the seam present on day one, add a `canRestructure: Boolean` to each UI state, hardcoded `true`, and gate the pencil on it. **Cheap; recommended; not in the tasks above.** + +2. **Stars on products are backend-only.** Backend T9 ships `is_starred`; no task here renders it. The spec calls for a star marker and a "starred only" filter on the products list. That is a small, self-contained addition to `ProductsPane` + `ProductFilterSortRow` and should be **Task 11** if you want it in this pass. + +**Type consistency.** `ShelfDeleteStrategy` / `LocationDeleteStrategy` (T1) are the exact types taken by `confirmDelete` (T4, T5) and `DeleteStrategyDialog.onConfirm` (T3). `DeletePlan(itemCount, productCount, hasOtherTargets)` (T3) is constructed with those three named args in T4 and T5. `orderByPosition(items, position, name)` (T2) is used in T4, T5 and T8. + +**Known hazard.** Task 4 removes `deleteMode` / `selectedShelves` / `deleteSelected()` from `ShelvesViewModel`, which `ShelvesViewModelTest` and `LocationDetailScreen` both use today. Both are updated inside that task — but if you run tasks out of order, the build breaks there first. + +**Depends on `product_count`.** Task 4's strategy dialog summarises "17 products", which requires a `product_count` on `ShelfResource`. That is **not** in the backend plan — add it (one line in `toArray()`) or the dialog cannot tell the truth. From 6371ba6c12d2a00a582c0ffaf04aac877fa15df4 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 18:50:58 +0200 Subject: [PATCH 02/40] docs: fix a cross-repo contract bug in the Android plan The backend requires a delete strategy when a LOCATION has shelves -- not when it has products. The plan decided needsStrategy from productCount for both levels, so deleting a location containing one empty shelf would have sent a strategy-less request and 422'd every time. DeletePlan now takes an explicit contentCount (product_count for shelves, shelf_count for locations), and the DTOs carry the counts the server now exposes for exactly this purpose. --- ...13-storage-architecture-editing-android.md | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md b/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md index d5b832a..56aa925 100644 --- a/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md +++ b/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md @@ -153,6 +153,9 @@ data class ShelfDto( // Server-side flag for the "Unsorted" holding shelf. The client localises the // label off this — the server always stores the literal name "Unsorted". val is_system: Boolean = false, + // The server requires a delete STRATEGY when a shelf has products. This is how + // the client knows to ask, and it feeds the dialog's "17 products" summary. + val product_count: Int = 0, ) // No property defaults: the app's Json has encodeDefaults=false, so a defaulted @@ -184,6 +187,15 @@ data class LocationDto( val name: String, val type: String, val position: Int? = null, + // The server requires a delete STRATEGY when shelf_count > 0 — NOT when the + // location merely holds products. Both sides read the same server relation + // (shelvesWithContents), which counts any non-system shelf plus a system + // "Unsorted" shelf that actually holds something. Decide `needsStrategy` from + // THIS, or a location containing one empty shelf 422s on every delete. + val shelf_count: Int = 0, + // Total products across all the location's shelves. Feeds the dialog's + // "2 locations · 17 products" summary. + val product_count: Int = 0, ) @Serializable @@ -650,15 +662,27 @@ import org.junit.Test class DeletePlanTest { @Test fun an_empty_selection_needs_no_strategy() { - val plan = DeletePlan(itemCount = 2, productCount = 0, hasOtherTargets = true) + val plan = DeletePlan(itemCount = 2, productCount = 0, contentCount = 0, hasOtherTargets = true) // Nothing to rescue — a plain confirm is enough. assertFalse(plan.needsStrategy) } @Test - fun a_selection_holding_products_needs_a_strategy() { - val plan = DeletePlan(itemCount = 1, productCount = 12, hasOtherTargets = true) + fun a_shelf_holding_products_needs_a_strategy() { + // For a SHELF, contentCount is its product_count. + val plan = DeletePlan(itemCount = 1, productCount = 12, contentCount = 12, hasOtherTargets = true) + + assertTrue(plan.needsStrategy) + } + + @Test + fun a_location_holding_an_EMPTY_shelf_still_needs_a_strategy() { + // THE cross-repo trap. The server asks for a strategy when a location has + // SHELVES, not when it has products — so a location with one empty shelf + // (0 products, 1 shelf) must still prompt. Deciding this from productCount + // would send a strategy-less delete and 422 every single time. + val plan = DeletePlan(itemCount = 1, productCount = 0, contentCount = 1, hasOtherTargets = true) assertTrue(plan.needsStrategy) } @@ -667,7 +691,7 @@ class DeletePlanTest { fun move_is_unavailable_when_there_is_nowhere_to_move_to() { // Deleting the household's only location: there is no other location to // take the contents, so the UI must not offer "move" as a dead option. - val plan = DeletePlan(itemCount = 1, productCount = 12, hasOtherTargets = false) + val plan = DeletePlan(itemCount = 1, productCount = 12, contentCount = 12, hasOtherTargets = false) assertTrue(plan.needsStrategy) assertFalse(plan.canMove) @@ -675,7 +699,7 @@ class DeletePlanTest { @Test fun move_is_available_when_a_target_exists() { - val plan = DeletePlan(itemCount = 1, productCount = 12, hasOtherTargets = true) + val plan = DeletePlan(itemCount = 1, productCount = 12, contentCount = 12, hasOtherTargets = true) assertTrue(plan.canMove) } @@ -705,13 +729,25 @@ package dev.scuttle.inventory.ui.hierarchy data class DeletePlan( /** How many containers (shelves or locations) are selected. */ val itemCount: Int, - /** How many products live inside them, in total. */ + /** How many products live inside them, in total. Feeds the summary line. */ val productCount: Int, + /** + * What the SERVER counts as "has contents" for the thing being deleted. + * + * These two are NOT the same rule, and getting it wrong 422s every delete: + * - deleting a SHELF → the server asks when the shelf has PRODUCTS. + * - deleting a LOCATION → the server asks when the location has SHELVES + * (`shelf_count`), even if those shelves are empty. + * + * So the caller passes the right count: `product_count` for shelves, + * `shelf_count` for locations. Do not "simplify" this to productCount. + */ + val contentCount: Int, /** Is there anywhere else to put the contents? False when this is the only shelf/location. */ val hasOtherTargets: Boolean, ) { /** An empty container is safe to delete outright — a plain confirm will do. */ - val needsStrategy: Boolean get() = productCount > 0 + val needsStrategy: Boolean get() = contentCount > 0 /** Never offer "move" when there is nothing to move to. */ val canMove: Boolean get() = hasOtherTargets From 78ae8a906f44796b1f8019316b595ba295f02abf Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 19:03:21 +0200 Subject: [PATCH 03/40] feat: data layer for renaming, reordering and safe deletion Adds rename/reorder/deleteWithStrategy to the location and shelf repositories, name to the household update, and a restore-by-batch endpoint. DELETE now carries a body (@HTTP hasBody, since @DELETE cannot), because the strategy and the client-minted batch id have to travel with the request. The request DTOs deliberately have no property defaults: encodeDefaults=false would silently drop them and the server would 422. --- app/detekt-baseline.xml | 9 ++++ .../scuttle/inventory/di/TestNetworkModule.kt | 4 ++ .../inventory/data/api/HouseholdApi.kt | 6 +-- .../scuttle/inventory/data/api/LocationApi.kt | 27 +++++++++++ .../scuttle/inventory/data/api/RestoreApi.kt | 13 +++++ .../scuttle/inventory/data/api/ShelfApi.kt | 30 ++++++++++++ .../inventory/data/dto/HouseholdDtos.kt | 9 ++-- .../inventory/data/dto/LocationDtos.kt | 25 ++++++++++ .../inventory/data/dto/RestoreResponse.kt | 9 ++++ .../scuttle/inventory/data/dto/ShelfDtos.kt | 25 ++++++++++ .../data/hierarchy/DeleteStrategy.kt | 25 ++++++++++ .../data/hierarchy/RestoreRepository.kt | 9 ++++ .../data/hierarchy/RestoreRepositoryImpl.kt | 15 ++++++ .../data/household/HouseholdRepository.kt | 11 +++-- .../data/household/HouseholdRepositoryImpl.kt | 8 ++-- .../data/location/LocationRepository.kt | 26 ++++++++++ .../data/location/LocationRepositoryImpl.kt | 45 +++++++++++++++++ .../inventory/data/shelf/ShelfRepository.kt | 28 +++++++++++ .../data/shelf/ShelfRepositoryImpl.kt | 46 ++++++++++++++++++ .../dev/scuttle/inventory/di/NetworkModule.kt | 5 ++ .../scuttle/inventory/di/RepositoryModule.kt | 6 +++ .../ui/households/HouseholdsViewModel.kt | 2 +- .../DeleteRequestSerializationTest.kt | 48 +++++++++++++++++++ .../inventory/HouseholdsViewModelTest.kt | 5 +- 24 files changed, 418 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/dev/scuttle/inventory/data/api/RestoreApi.kt create mode 100644 app/src/main/java/dev/scuttle/inventory/data/dto/RestoreResponse.kt create mode 100644 app/src/main/java/dev/scuttle/inventory/data/hierarchy/DeleteStrategy.kt create mode 100644 app/src/main/java/dev/scuttle/inventory/data/hierarchy/RestoreRepository.kt create mode 100644 app/src/main/java/dev/scuttle/inventory/data/hierarchy/RestoreRepositoryImpl.kt create mode 100644 app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index fdfcc01..d6c4279 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -8,10 +8,18 @@ ConstructorParameterNaming:ErrorDtos.kt$ClientErrorRequest$val device_id: String ConstructorParameterNaming:ErrorDtos.kt$ClientErrorRequest$val error_code: String ConstructorParameterNaming:HouseholdDtos.kt$HouseholdDto$val join_code: String + ConstructorParameterNaming:LocationDtos.kt$DeleteLocationRequest$val deletion_batch_id: String + ConstructorParameterNaming:LocationDtos.kt$DeleteLocationRequest$val target_location_id: Long? + ConstructorParameterNaming:LocationDtos.kt$LocationDto$val product_count: Int = 0 + ConstructorParameterNaming:LocationDtos.kt$LocationDto$val shelf_count: Int = 0 ConstructorParameterNaming:SearchDtos.kt$SearchResultDto$val household_id: Long? = null ConstructorParameterNaming:SearchDtos.kt$SearchResultDto$val location_id: Long? = null ConstructorParameterNaming:SearchDtos.kt$SearchResultDto$val shelf_id: Long? = null + ConstructorParameterNaming:ShelfDtos.kt$DeleteShelfRequest$val deletion_batch_id: String + ConstructorParameterNaming:ShelfDtos.kt$DeleteShelfRequest$val target_shelf_id: Long? + ConstructorParameterNaming:ShelfDtos.kt$ShelfDto$val is_system: Boolean = false ConstructorParameterNaming:ShelfDtos.kt$ShelfDto$val location_id: Long + ConstructorParameterNaming:ShelfDtos.kt$ShelfDto$val product_count: Int = 0 CyclomaticComplexMethod:AuthScreen.kt$@Composable fun AuthScreen( onForgotPassword: () -> Unit = {}, modifier: Modifier = Modifier, viewModel: AuthViewModel = hiltViewModel(), ) CyclomaticComplexMethod:LocationDetailScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun LocationDetailScreen( householdId: Long, locationId: Long, drawerViewModel: DrawerViewModel, modifier: Modifier = Modifier, onBack: () -> Unit = {}, onOpenDrawer: () -> Unit = {}, onOpenProduct: (householdId: Long, shelfId: Long, productId: Long) -> Unit = { _, _, _ -> }, shelvesViewModel: ShelvesViewModel = hiltViewModel(), ) CyclomaticComplexMethod:ProductDetailScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ProductDetailScreen( modifier: Modifier = Modifier, onBack: () -> Unit = {}, viewModel: ProductDetailViewModel = hiltViewModel(), ) @@ -71,6 +79,7 @@ LongParameterList:ProductsPane.kt$( householdId: Long, shelfId: Long, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, refreshKey: Int = 0, onOpenProduct: (ProductDto) -> Unit = {}, onWarningChange: (Boolean) -> Unit = {}, viewModel: ProductsViewModel = hiltViewModel(key = "products-$shelfId"), ) LongParameterList:SessionCleaner.kt$SessionCleaner$( private val householdRepository: HouseholdRepository, private val locationRepository: LocationRepository, private val shelfRepository: ShelfRepository, private val productRepository: ProductRepository, private val hierarchyStore: HierarchyStore, private val favoritesStore: FavoritesStore, private val defaultHouseholdStore: DefaultHouseholdStore, ) LongParameterList:SettingsScreen.kt$( modifier: Modifier = Modifier, onOpenDrawer: () -> Unit = {}, onSignOut: () -> Unit = {}, onOpenHouseholds: () -> Unit = {}, themeViewModel: ThemeViewModel = hiltViewModel(), languageViewModel: LanguageViewModel = hiltViewModel(), joinViewModel: JoinHouseholdViewModel = hiltViewModel(), ) + LongParameterList:ShelfRepository.kt$ShelfRepository$( householdId: Long, locationId: Long, shelfId: Long, batchId: String, strategy: ShelfDeleteStrategy?, targetShelfId: Long?, ) LongParameterList:StorageOverviewScreen.kt$( householdId: Long, modifier: Modifier = Modifier, onBack: () -> Unit = {}, onOpenDrawer: () -> Unit = {}, onOpenLocation: (Long) -> Unit = {}, onOpenSearch: () -> Unit = {}, viewModel: StorageOverviewViewModel = hiltViewModel(), ) MagicNumber:AuthInterceptor.kt$AuthInterceptor$401 MagicNumber:AuthViewModel.kt$AuthViewModel$401 diff --git a/app/src/androidTest/java/dev/scuttle/inventory/di/TestNetworkModule.kt b/app/src/androidTest/java/dev/scuttle/inventory/di/TestNetworkModule.kt index 1d58556..5c0cc3b 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/di/TestNetworkModule.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/di/TestNetworkModule.kt @@ -11,6 +11,7 @@ import dev.scuttle.inventory.data.api.HouseholdApi import dev.scuttle.inventory.data.api.InviteApi import dev.scuttle.inventory.data.api.LocationApi import dev.scuttle.inventory.data.api.ProductApi +import dev.scuttle.inventory.data.api.RestoreApi import dev.scuttle.inventory.data.api.SearchApi import dev.scuttle.inventory.data.api.ShelfApi import dev.scuttle.inventory.data.storage.TokenStore @@ -93,4 +94,7 @@ object TestNetworkModule { @Provides @Singleton fun provideErrorApi(r: Retrofit): ErrorApi = r.create(ErrorApi::class.java) + + @Provides @Singleton + fun provideRestoreApi(r: Retrofit): RestoreApi = r.create(RestoreApi::class.java) } diff --git a/app/src/main/java/dev/scuttle/inventory/data/api/HouseholdApi.kt b/app/src/main/java/dev/scuttle/inventory/data/api/HouseholdApi.kt index 00c14f4..55eefd5 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/api/HouseholdApi.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/api/HouseholdApi.kt @@ -4,7 +4,7 @@ import dev.scuttle.inventory.data.dto.CreateHouseholdRequest import dev.scuttle.inventory.data.dto.HouseholdListResponse import dev.scuttle.inventory.data.dto.HouseholdResponse import dev.scuttle.inventory.data.dto.JoinHouseholdRequest -import dev.scuttle.inventory.data.dto.UpdateHouseholdThemeRequest +import dev.scuttle.inventory.data.dto.UpdateHouseholdRequest import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET @@ -14,9 +14,9 @@ import retrofit2.http.Path interface HouseholdApi { @PATCH("households/{household}") - suspend fun updateTheme( + suspend fun update( @Path("household") householdId: Long, - @Body body: UpdateHouseholdThemeRequest, + @Body body: UpdateHouseholdRequest, ): HouseholdResponse @GET("households") diff --git a/app/src/main/java/dev/scuttle/inventory/data/api/LocationApi.kt b/app/src/main/java/dev/scuttle/inventory/data/api/LocationApi.kt index bcf3fb3..3fc0b31 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/api/LocationApi.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/api/LocationApi.kt @@ -1,11 +1,16 @@ package dev.scuttle.inventory.data.api import dev.scuttle.inventory.data.dto.CreateLocationRequest +import dev.scuttle.inventory.data.dto.DeleteLocationRequest import dev.scuttle.inventory.data.dto.LocationListResponse import dev.scuttle.inventory.data.dto.LocationResponse +import dev.scuttle.inventory.data.dto.ReorderRequest +import dev.scuttle.inventory.data.dto.UpdateLocationRequest import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.HTTP +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path @@ -21,9 +26,31 @@ interface LocationApi { @Body body: CreateLocationRequest, ): LocationResponse + @PATCH("households/{household}/locations/{location}") + suspend fun update( + @Path("household") householdId: Long, + @Path("location") locationId: Long, + @Body body: UpdateLocationRequest, + ): LocationResponse + + @PATCH("households/{household}/locations/reorder") + suspend fun reorder( + @Path("household") householdId: Long, + @Body body: ReorderRequest, + ): LocationListResponse + @DELETE("households/{household}/locations/{location}") suspend fun delete( @Path("household") householdId: Long, @Path("location") locationId: Long, ) + + // @DELETE cannot carry a body; @HTTP(hasBody = true) can. The strategy and + // the batch id have to travel with the request. + @HTTP(method = "DELETE", path = "households/{household}/locations/{location}", hasBody = true) + suspend fun delete( + @Path("household") householdId: Long, + @Path("location") locationId: Long, + @Body body: DeleteLocationRequest, + ) } diff --git a/app/src/main/java/dev/scuttle/inventory/data/api/RestoreApi.kt b/app/src/main/java/dev/scuttle/inventory/data/api/RestoreApi.kt new file mode 100644 index 0000000..e4a6270 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/data/api/RestoreApi.kt @@ -0,0 +1,13 @@ +package dev.scuttle.inventory.data.api + +import dev.scuttle.inventory.data.dto.RestoreResponse +import retrofit2.http.POST +import retrofit2.http.Path + +interface RestoreApi { + @POST("households/{household}/restore/{batch}") + suspend fun restore( + @Path("household") householdId: Long, + @Path("batch") batchId: String, + ): RestoreResponse +} diff --git a/app/src/main/java/dev/scuttle/inventory/data/api/ShelfApi.kt b/app/src/main/java/dev/scuttle/inventory/data/api/ShelfApi.kt index 6e6471d..d71a04a 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/api/ShelfApi.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/api/ShelfApi.kt @@ -1,11 +1,16 @@ package dev.scuttle.inventory.data.api import dev.scuttle.inventory.data.dto.CreateShelfRequest +import dev.scuttle.inventory.data.dto.DeleteShelfRequest +import dev.scuttle.inventory.data.dto.ReorderRequest import dev.scuttle.inventory.data.dto.ShelfListResponse import dev.scuttle.inventory.data.dto.ShelfResponse +import dev.scuttle.inventory.data.dto.UpdateShelfRequest import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.HTTP +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path @@ -23,10 +28,35 @@ interface ShelfApi { @Body body: CreateShelfRequest, ): ShelfResponse + @PATCH("households/{household}/locations/{location}/shelves/{shelf}") + suspend fun update( + @Path("household") householdId: Long, + @Path("location") locationId: Long, + @Path("shelf") shelfId: Long, + @Body body: UpdateShelfRequest, + ): ShelfResponse + + @PATCH("households/{household}/locations/{location}/shelves/reorder") + suspend fun reorder( + @Path("household") householdId: Long, + @Path("location") locationId: Long, + @Body body: ReorderRequest, + ): ShelfListResponse + @DELETE("households/{household}/locations/{location}/shelves/{shelf}") suspend fun delete( @Path("household") householdId: Long, @Path("location") locationId: Long, @Path("shelf") shelfId: Long, ) + + // @DELETE cannot carry a body; @HTTP(hasBody = true) can. The strategy and + // the batch id have to travel with the request. + @HTTP(method = "DELETE", path = "households/{household}/locations/{location}/shelves/{shelf}", hasBody = true) + suspend fun delete( + @Path("household") householdId: Long, + @Path("location") locationId: Long, + @Path("shelf") shelfId: Long, + @Body body: DeleteShelfRequest, + ) } diff --git a/app/src/main/java/dev/scuttle/inventory/data/dto/HouseholdDtos.kt b/app/src/main/java/dev/scuttle/inventory/data/dto/HouseholdDtos.kt index db09c34..a76c7c1 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/dto/HouseholdDtos.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/dto/HouseholdDtos.kt @@ -35,11 +35,12 @@ data class JoinHouseholdRequest( ) // No property defaults on purpose: the app's Json has encodeDefaults=false, so a -// defaulted field would be OMITTED from the body and the server would keep the old -// value instead of clearing it (same pitfall as UpdateProductRequest). Explicit -// null = clear back to the derived default. +// defaulted field would be OMITTED from the body and the server would keep the +// old value instead of clearing it. Explicit null = clear back to the derived +// default. `name` is nullable so a theme-only change doesn't have to resend it. @Serializable -data class UpdateHouseholdThemeRequest( +data class UpdateHouseholdRequest( + val name: String?, val color: String?, val icon: String?, ) diff --git a/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt b/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt index 1f635dd..1d17bc5 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt @@ -7,6 +7,16 @@ data class LocationDto( val id: Long, val name: String, val type: String, + val position: Int? = null, + // The server requires a delete STRATEGY when shelf_count > 0 — NOT when the + // location merely holds products. Both sides read the same server relation + // (shelvesWithContents), which counts any non-system shelf plus a system + // "Unsorted" shelf that actually holds something. Decide `needsStrategy` from + // THIS, or a location containing one empty shelf 422s on every delete. + val shelf_count: Int = 0, + // Total products across all the location's shelves. Feeds the dialog's + // "2 locations · 17 products" summary. + val product_count: Int = 0, ) @Serializable @@ -24,3 +34,18 @@ data class CreateLocationRequest( val name: String, val type: String, ) + +@Serializable +data class UpdateLocationRequest( + val name: String, + val type: String, +) + +// No property defaults: the app's Json has encodeDefaults=false, so a defaulted +// field is OMITTED from the body — and the server 422s without deletion_batch_id. +@Serializable +data class DeleteLocationRequest( + val strategy: String?, + val target_location_id: Long?, + val deletion_batch_id: String, +) diff --git a/app/src/main/java/dev/scuttle/inventory/data/dto/RestoreResponse.kt b/app/src/main/java/dev/scuttle/inventory/data/dto/RestoreResponse.kt new file mode 100644 index 0000000..4ed612e --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/data/dto/RestoreResponse.kt @@ -0,0 +1,9 @@ +package dev.scuttle.inventory.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class RestoreResponse( + val message: String, + val restored: Int, +) diff --git a/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt b/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt index 9f05bcf..6866081 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt @@ -8,6 +8,12 @@ data class ShelfDto( val name: String, val position: Int? = null, val location_id: Long, + // Server-side flag for the "Unsorted" holding shelf. The client localises the + // label off this — the server always stores the literal name "Unsorted". + val is_system: Boolean = false, + // The server requires a delete STRATEGY when a shelf has products. This is how + // the client knows to ask, and it feeds the dialog's "17 products" summary. + val product_count: Int = 0, ) @Serializable @@ -24,3 +30,22 @@ data class ShelfResponse( data class CreateShelfRequest( val name: String, ) + +@Serializable +data class UpdateShelfRequest( + val name: String, +) + +@Serializable +data class ReorderRequest( + val ids: List, +) + +// No property defaults: the app's Json has encodeDefaults=false, so a defaulted +// field is OMITTED from the body — and the server 422s without deletion_batch_id. +@Serializable +data class DeleteShelfRequest( + val strategy: String?, + val target_shelf_id: Long?, + val deletion_batch_id: String, +) diff --git a/app/src/main/java/dev/scuttle/inventory/data/hierarchy/DeleteStrategy.kt b/app/src/main/java/dev/scuttle/inventory/data/hierarchy/DeleteStrategy.kt new file mode 100644 index 0000000..5d57cf2 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/data/hierarchy/DeleteStrategy.kt @@ -0,0 +1,25 @@ +package dev.scuttle.inventory.data.hierarchy + +/** + * What to do with a shelf's products when the shelf is deleted. The server + * refuses to guess — a non-empty shelf deleted with no strategy is a 422. + */ +enum class ShelfDeleteStrategy( + val wire: String, +) { + MOVE_PRODUCTS("move_products"), + UNSORT_PRODUCTS("unsort_products"), + DELETE_PRODUCTS("delete_products"), +} + +/** + * What to do with a location's contents. There is deliberately no `unsort` here: + * "unsorted" means off-shelf but still IN this location, and the location is the + * thing being deleted. + */ +enum class LocationDeleteStrategy( + val wire: String, +) { + MOVE_CONTENTS("move_contents"), + DELETE_CONTENTS("delete_contents"), +} diff --git a/app/src/main/java/dev/scuttle/inventory/data/hierarchy/RestoreRepository.kt b/app/src/main/java/dev/scuttle/inventory/data/hierarchy/RestoreRepository.kt new file mode 100644 index 0000000..2514cc5 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/data/hierarchy/RestoreRepository.kt @@ -0,0 +1,9 @@ +package dev.scuttle.inventory.data.hierarchy + +interface RestoreRepository { + /** Restores everything tagged with the given deletion batch id. Returns the count restored. */ + suspend fun restore( + householdId: Long, + batchId: String, + ): Int +} diff --git a/app/src/main/java/dev/scuttle/inventory/data/hierarchy/RestoreRepositoryImpl.kt b/app/src/main/java/dev/scuttle/inventory/data/hierarchy/RestoreRepositoryImpl.kt new file mode 100644 index 0000000..e852e9e --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/data/hierarchy/RestoreRepositoryImpl.kt @@ -0,0 +1,15 @@ +package dev.scuttle.inventory.data.hierarchy + +import dev.scuttle.inventory.data.api.RestoreApi +import javax.inject.Inject + +class RestoreRepositoryImpl + @Inject + constructor( + private val api: RestoreApi, + ) : RestoreRepository { + override suspend fun restore( + householdId: Long, + batchId: String, + ): Int = api.restore(householdId, batchId).restored + } diff --git a/app/src/main/java/dev/scuttle/inventory/data/household/HouseholdRepository.kt b/app/src/main/java/dev/scuttle/inventory/data/household/HouseholdRepository.kt index d75d198..858f972 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/household/HouseholdRepository.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/household/HouseholdRepository.kt @@ -14,15 +14,16 @@ interface HouseholdRepository { suspend fun leave(householdId: Long) /** - * Set (or clear, with nulls) the user-chosen theme keys. Default throws so - * test fakes only implement it where a test actually exercises theming - * (same pattern as [clear]'s no-op default). + * Update the household's name and/or theme keys (null = clear back to the + * derived default). Default throws so test fakes only implement it where a + * test actually exercises this (same pattern as [clear]'s no-op default). */ - suspend fun updateTheme( + suspend fun update( householdId: Long, + name: String?, color: String?, icon: String?, - ): HouseholdDto = throw UnsupportedOperationException("updateTheme not supported") + ): HouseholdDto = throw UnsupportedOperationException("update not supported") /** Drop the in-memory cache so one account's data never bleeds into the next session. */ fun clear() {} diff --git a/app/src/main/java/dev/scuttle/inventory/data/household/HouseholdRepositoryImpl.kt b/app/src/main/java/dev/scuttle/inventory/data/household/HouseholdRepositoryImpl.kt index 5d55add..16a7c7e 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/household/HouseholdRepositoryImpl.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/household/HouseholdRepositoryImpl.kt @@ -4,7 +4,7 @@ import dev.scuttle.inventory.data.api.HouseholdApi import dev.scuttle.inventory.data.dto.CreateHouseholdRequest import dev.scuttle.inventory.data.dto.HouseholdDto import dev.scuttle.inventory.data.dto.JoinHouseholdRequest -import dev.scuttle.inventory.data.dto.UpdateHouseholdThemeRequest +import dev.scuttle.inventory.data.dto.UpdateHouseholdRequest import javax.inject.Inject class HouseholdRepositoryImpl @@ -22,12 +22,14 @@ class HouseholdRepositoryImpl override suspend fun join(code: String): HouseholdDto = api.join(JoinHouseholdRequest(code)).data - override suspend fun updateTheme( + override suspend fun update( householdId: Long, + name: String?, color: String?, icon: String?, ): HouseholdDto { - val updated = api.updateTheme(householdId, UpdateHouseholdThemeRequest(color = color, icon = icon)).data + val body = UpdateHouseholdRequest(name = name, color = color, icon = icon) + val updated = api.update(householdId, body).data cache = cache?.map { if (it.id == updated.id) updated else it } return updated } diff --git a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt index 4ecbfe3..cea90c5 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt @@ -1,6 +1,7 @@ package dev.scuttle.inventory.data.location import dev.scuttle.inventory.data.dto.LocationDto +import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy interface LocationRepository { fun getCached(householdId: Long): List? @@ -18,6 +19,31 @@ interface LocationRepository { locationId: Long, ) + /** + * Defaults throw so test fakes only implement what a test actually exercises + * (same pattern as [clear] and HouseholdRepository.updateTheme). Without + * this, adding a method here breaks every fake in the unit-test suite. + */ + suspend fun rename( + householdId: Long, + locationId: Long, + name: String, + type: String, + ): LocationDto = throw UnsupportedOperationException("rename not supported") + + suspend fun reorder( + householdId: Long, + ids: List, + ): List = throw UnsupportedOperationException("reorder not supported") + + suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + batchId: String, + strategy: LocationDeleteStrategy?, + targetLocationId: Long?, + ): Unit = throw UnsupportedOperationException("deleteWithStrategy not supported") + /** Drop the in-memory cache so one account's data never bleeds into the next session. */ fun clear() {} } diff --git a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt index e73c519..61268a3 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt @@ -2,7 +2,11 @@ package dev.scuttle.inventory.data.location import dev.scuttle.inventory.data.api.LocationApi import dev.scuttle.inventory.data.dto.CreateLocationRequest +import dev.scuttle.inventory.data.dto.DeleteLocationRequest import dev.scuttle.inventory.data.dto.LocationDto +import dev.scuttle.inventory.data.dto.ReorderRequest +import dev.scuttle.inventory.data.dto.UpdateLocationRequest +import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy import javax.inject.Inject class LocationRepositoryImpl @@ -34,6 +38,47 @@ class LocationRepositoryImpl cache[householdId] = cache[householdId]?.filter { it.id != locationId } ?: emptyList() } + override suspend fun rename( + householdId: Long, + locationId: Long, + name: String, + type: String, + ): LocationDto = + api + .update(householdId, locationId, UpdateLocationRequest(name = name, type = type)) + .data + .also { updated -> + val updatedList = cache[householdId]?.map { if (it.id == locationId) updated else it } + cache[householdId] = updatedList ?: listOf(updated) + } + + override suspend fun reorder( + householdId: Long, + ids: List, + ): List = + api.reorder(householdId, ReorderRequest(ids)).data.also { + cache[householdId] = it + } + + override suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + batchId: String, + strategy: LocationDeleteStrategy?, + targetLocationId: Long?, + ) { + api.delete( + householdId, + locationId, + DeleteLocationRequest( + strategy = strategy?.wire, + target_location_id = targetLocationId, + deletion_batch_id = batchId, + ), + ) + cache[householdId] = cache[householdId]?.filter { it.id != locationId } ?: emptyList() + } + override fun clear() { cache.clear() } diff --git a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt index bb14c1f..dda0360 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt @@ -1,6 +1,7 @@ package dev.scuttle.inventory.data.shelf import dev.scuttle.inventory.data.dto.ShelfDto +import dev.scuttle.inventory.data.hierarchy.ShelfDeleteStrategy interface ShelfRepository { fun getCached( @@ -25,6 +26,33 @@ interface ShelfRepository { shelfId: Long, ) + /** + * Defaults throw so test fakes only implement what a test actually exercises + * (same pattern as [clear] and HouseholdRepository.updateTheme). Without + * this, adding a method here breaks every fake in the unit-test suite. + */ + suspend fun rename( + householdId: Long, + locationId: Long, + shelfId: Long, + name: String, + ): ShelfDto = throw UnsupportedOperationException("rename not supported") + + suspend fun reorder( + householdId: Long, + locationId: Long, + ids: List, + ): List = throw UnsupportedOperationException("reorder not supported") + + suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + shelfId: Long, + batchId: String, + strategy: ShelfDeleteStrategy?, + targetShelfId: Long?, + ): Unit = throw UnsupportedOperationException("deleteWithStrategy not supported") + /** Drop the in-memory cache so one account's data never bleeds into the next session. */ fun clear() {} } diff --git a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt index ae09467..4e4fef8 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt @@ -2,7 +2,11 @@ package dev.scuttle.inventory.data.shelf import dev.scuttle.inventory.data.api.ShelfApi import dev.scuttle.inventory.data.dto.CreateShelfRequest +import dev.scuttle.inventory.data.dto.DeleteShelfRequest +import dev.scuttle.inventory.data.dto.ReorderRequest import dev.scuttle.inventory.data.dto.ShelfDto +import dev.scuttle.inventory.data.dto.UpdateShelfRequest +import dev.scuttle.inventory.data.hierarchy.ShelfDeleteStrategy import javax.inject.Inject class ShelfRepositoryImpl @@ -42,6 +46,48 @@ class ShelfRepositoryImpl cache[key] = cache[key]?.filter { it.id != shelfId } ?: emptyList() } + override suspend fun rename( + householdId: Long, + locationId: Long, + shelfId: Long, + name: String, + ): ShelfDto = + api.update(householdId, locationId, shelfId, UpdateShelfRequest(name = name)).data.also { updated -> + val key = householdId to locationId + cache[key] = cache[key]?.map { if (it.id == shelfId) updated else it } ?: listOf(updated) + } + + override suspend fun reorder( + householdId: Long, + locationId: Long, + ids: List, + ): List = + api.reorder(householdId, locationId, ReorderRequest(ids)).data.also { + cache[householdId to locationId] = it + } + + override suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + shelfId: Long, + batchId: String, + strategy: ShelfDeleteStrategy?, + targetShelfId: Long?, + ) { + api.delete( + householdId, + locationId, + shelfId, + DeleteShelfRequest( + strategy = strategy?.wire, + target_shelf_id = targetShelfId, + deletion_batch_id = batchId, + ), + ) + val key = householdId to locationId + cache[key] = cache[key]?.filter { it.id != shelfId } ?: emptyList() + } + override fun clear() { cache.clear() } diff --git a/app/src/main/java/dev/scuttle/inventory/di/NetworkModule.kt b/app/src/main/java/dev/scuttle/inventory/di/NetworkModule.kt index 4eea0d4..dcad9b7 100644 --- a/app/src/main/java/dev/scuttle/inventory/di/NetworkModule.kt +++ b/app/src/main/java/dev/scuttle/inventory/di/NetworkModule.kt @@ -12,6 +12,7 @@ import dev.scuttle.inventory.data.api.HouseholdApi import dev.scuttle.inventory.data.api.InviteApi import dev.scuttle.inventory.data.api.LocationApi import dev.scuttle.inventory.data.api.ProductApi +import dev.scuttle.inventory.data.api.RestoreApi import dev.scuttle.inventory.data.api.SearchApi import dev.scuttle.inventory.data.api.ShelfApi import dev.scuttle.inventory.data.storage.TokenStore @@ -96,4 +97,8 @@ object NetworkModule { @Provides @Singleton fun provideErrorApi(retrofit: Retrofit): ErrorApi = retrofit.create(ErrorApi::class.java) + + @Provides + @Singleton + fun provideRestoreApi(retrofit: Retrofit): RestoreApi = retrofit.create(RestoreApi::class.java) } diff --git a/app/src/main/java/dev/scuttle/inventory/di/RepositoryModule.kt b/app/src/main/java/dev/scuttle/inventory/di/RepositoryModule.kt index 74ee72b..6a32dd9 100644 --- a/app/src/main/java/dev/scuttle/inventory/di/RepositoryModule.kt +++ b/app/src/main/java/dev/scuttle/inventory/di/RepositoryModule.kt @@ -8,6 +8,8 @@ import dev.scuttle.inventory.data.auth.AuthRepository import dev.scuttle.inventory.data.auth.AuthRepositoryImpl import dev.scuttle.inventory.data.error.ErrorLogger import dev.scuttle.inventory.data.error.ErrorLoggerImpl +import dev.scuttle.inventory.data.hierarchy.RestoreRepository +import dev.scuttle.inventory.data.hierarchy.RestoreRepositoryImpl import dev.scuttle.inventory.data.household.HouseholdRepository import dev.scuttle.inventory.data.household.HouseholdRepositoryImpl import dev.scuttle.inventory.data.invite.InviteRepository @@ -62,4 +64,8 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindRealtimeGateway(impl: PusherRealtimeGateway): RealtimeGateway + + @Binds + @Singleton + abstract fun bindRestoreRepository(impl: RestoreRepositoryImpl): RestoreRepository } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt index c51cc39..ce3629f 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt @@ -86,7 +86,7 @@ class HouseholdsViewModel color: String?, icon: String?, ) = launchLoading { - val updated = repository.updateTheme(householdId, color, icon) + val updated = repository.update(householdId, name = null, color = color, icon = icon) _state.update { s -> s.copy(households = s.households.map { if (it.id == updated.id) updated else it }) } diff --git a/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt b/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt new file mode 100644 index 0000000..cfae25b --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt @@ -0,0 +1,48 @@ +package dev.scuttle.inventory + +import dev.scuttle.inventory.data.dto.DeleteShelfRequest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeleteRequestSerializationTest { + // The app's real Json config. encodeDefaults defaults to false, which is + // exactly why a defaulted property would be dropped from the body. + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun batch_id_is_always_sent() { + val body = + json.encodeToString( + DeleteShelfRequest( + strategy = null, + target_shelf_id = null, + deletion_batch_id = "11111111-1111-4111-8111-111111111111", + ), + ) + + // The server 422s without it. If a default ever creeps onto this + // property, encodeDefaults=false silently omits it and every delete + // starts failing with a validation error nobody can explain. + assertTrue(body.contains("deletion_batch_id")) + } + + @Test + fun a_null_strategy_is_still_sent_explicitly() { + val body = + json.encodeToString( + DeleteShelfRequest( + strategy = null, + target_shelf_id = null, + deletion_batch_id = "11111111-1111-4111-8111-111111111111", + ), + ) + + // An empty shelf legitimately has no strategy. Sending explicit null is + // fine; what must never happen is the property vanishing because it was + // defaulted. + assertTrue(body.contains("\"strategy\":null")) + assertFalse(body.isBlank()) + } +} diff --git a/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt index d5401f0..185b83c 100644 --- a/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt @@ -38,13 +38,14 @@ class HouseholdsViewModelTest { items.removeIf { it.id == householdId } } - override suspend fun updateTheme( + override suspend fun update( householdId: Long, + name: String?, color: String?, icon: String?, ): HouseholdDto { val index = items.indexOfFirst { it.id == householdId } - items[index] = items[index].copy(color = color, icon = icon) + items[index] = items[index].copy(name = name ?: items[index].name, color = color, icon = icon) return items[index] } } From 93c88aed9022b43b8b7d088f18d7e4009e021d75 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 19:20:02 +0200 Subject: [PATCH 04/40] fix: correct household update defaults and harden delete-request tests Fixes review findings on the storage-architecture-editing data layer: - C-1 (critical): UpdateHouseholdRequest.name lacked a default, so encodeDefaults=false sent it as an explicit "name":null on every color/icon-only update. Laravel's `sometimes|required` rule rejects an explicit null (only an absent key passes), so every household theme change 422'd in production. Defaulted name back to null and documented why it's the deliberate exception to the no-defaults rule that the other request DTOs follow. - I-1: replaced DeleteRequestSerializationTest's body-string assertions (which couldn't discriminate a defaulted field from a real one) with descriptor-based checks (isElementOptional) across every request DTO, plus a dedicated test pinning the UpdateHouseholdRequest asymmetry. - I-2: documented that LocationDto.shelf_count/product_count and ShelfDto.product_count go cache-stale after a shelf/product mutation, so UI code must refresh before deriving needsStrategy from them. - M-1: replaced ShelfRepository/LocationRepository.deleteWithStrategy's long parameter lists with ShelfDeletion/LocationDeletion value objects, removing the LongParameterList baseline suppression. - M-2: rename() in both repository impls no longer fabricates a 1-element cache on a miss. - M-3: moved ReorderRequest out of ShelfDtos.kt into its own file, since it's shared with LocationApi. All three gates green: 139 unit tests, ktlintCheck, detekt. --- app/detekt-baseline.xml | 1 - .../inventory/data/dto/HouseholdDtos.kt | 20 +++- .../inventory/data/dto/LocationDtos.kt | 12 +- .../inventory/data/dto/ReorderRequest.kt | 11 ++ .../scuttle/inventory/data/dto/ShelfDtos.kt | 12 +- .../data/hierarchy/DeleteStrategy.kt | 20 ++++ .../data/location/LocationRepository.kt | 6 +- .../data/location/LocationRepositoryImpl.kt | 21 ++-- .../inventory/data/shelf/ShelfRepository.kt | 6 +- .../data/shelf/ShelfRepositoryImpl.kt | 20 ++-- .../DeleteRequestSerializationTest.kt | 104 ++++++++++++------ 11 files changed, 165 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/dev/scuttle/inventory/data/dto/ReorderRequest.kt diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index d6c4279..4008028 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -79,7 +79,6 @@ LongParameterList:ProductsPane.kt$( householdId: Long, shelfId: Long, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, refreshKey: Int = 0, onOpenProduct: (ProductDto) -> Unit = {}, onWarningChange: (Boolean) -> Unit = {}, viewModel: ProductsViewModel = hiltViewModel(key = "products-$shelfId"), ) LongParameterList:SessionCleaner.kt$SessionCleaner$( private val householdRepository: HouseholdRepository, private val locationRepository: LocationRepository, private val shelfRepository: ShelfRepository, private val productRepository: ProductRepository, private val hierarchyStore: HierarchyStore, private val favoritesStore: FavoritesStore, private val defaultHouseholdStore: DefaultHouseholdStore, ) LongParameterList:SettingsScreen.kt$( modifier: Modifier = Modifier, onOpenDrawer: () -> Unit = {}, onSignOut: () -> Unit = {}, onOpenHouseholds: () -> Unit = {}, themeViewModel: ThemeViewModel = hiltViewModel(), languageViewModel: LanguageViewModel = hiltViewModel(), joinViewModel: JoinHouseholdViewModel = hiltViewModel(), ) - LongParameterList:ShelfRepository.kt$ShelfRepository$( householdId: Long, locationId: Long, shelfId: Long, batchId: String, strategy: ShelfDeleteStrategy?, targetShelfId: Long?, ) LongParameterList:StorageOverviewScreen.kt$( householdId: Long, modifier: Modifier = Modifier, onBack: () -> Unit = {}, onOpenDrawer: () -> Unit = {}, onOpenLocation: (Long) -> Unit = {}, onOpenSearch: () -> Unit = {}, viewModel: StorageOverviewViewModel = hiltViewModel(), ) MagicNumber:AuthInterceptor.kt$AuthInterceptor$401 MagicNumber:AuthViewModel.kt$AuthViewModel$401 diff --git a/app/src/main/java/dev/scuttle/inventory/data/dto/HouseholdDtos.kt b/app/src/main/java/dev/scuttle/inventory/data/dto/HouseholdDtos.kt index a76c7c1..fb18ecf 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/dto/HouseholdDtos.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/dto/HouseholdDtos.kt @@ -34,13 +34,23 @@ data class JoinHouseholdRequest( val code: String, ) -// No property defaults on purpose: the app's Json has encodeDefaults=false, so a -// defaulted field would be OMITTED from the body and the server would keep the -// old value instead of clearing it. Explicit null = clear back to the derived -// default. `name` is nullable so a theme-only change doesn't have to resend it. +// The app's Json has encodeDefaults=false: a property equal to its default is +// OMITTED from the body. That's normally the wrong default for a request DTO — +// but `name` here is the deliberate exception, and the asymmetry matters: +// +// - `name` DEFAULTS to null on purpose. Laravel's rule is `sometimes|required`: +// an ABSENT key passes ("don't touch the name"), but an EXPLICIT null FAILS +// validation. A theme-only update must omit `name` entirely, so it needs the +// default — encodeDefaults=false then drops it for us. Do not remove this +// default; doing so makes every color/icon-only update send `"name":null` +// and 422. +// - `color` / `icon` must stay UN-defaulted. Laravel's rule there is +// `sometimes|nullable`: an explicit null is meaningful (clears the theme back +// to the derived default), so it must always be encoded, never silently +// dropped. @Serializable data class UpdateHouseholdRequest( - val name: String?, + val name: String? = null, val color: String?, val icon: String?, ) diff --git a/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt b/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt index 1d17bc5..2a93d62 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt @@ -13,9 +13,19 @@ data class LocationDto( // (shelvesWithContents), which counts any non-system shelf plus a system // "Unsorted" shelf that actually holds something. Decide `needsStrategy` from // THIS, or a location containing one empty shelf 422s on every delete. + // + // CACHE STALENESS: LocationRepositoryImpl's cache only gets replaced by + // location-level calls (list/create/rename/reorder/delete). A shelf mutation + // (ShelfRepository.create/delete/deleteWithStrategy) does NOT touch it, so a + // cached LocationDto's shelf_count can read stale — e.g. still 0 right after + // a shelf was added to a previously-empty location. Any UI deriving + // `needsStrategy` from this field MUST go through HierarchyStore.refresh() + // first; a cached read can silently re-create the very 422 this field exists + // to prevent. val shelf_count: Int = 0, // Total products across all the location's shelves. Feeds the dialog's - // "2 locations · 17 products" summary. + // "2 locations · 17 products" summary. Same cache-staleness caveat as + // shelf_count above — refresh before trusting this after a shelf mutation. val product_count: Int = 0, ) diff --git a/app/src/main/java/dev/scuttle/inventory/data/dto/ReorderRequest.kt b/app/src/main/java/dev/scuttle/inventory/data/dto/ReorderRequest.kt new file mode 100644 index 0000000..fc16f39 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/data/dto/ReorderRequest.kt @@ -0,0 +1,11 @@ +package dev.scuttle.inventory.data.dto + +import kotlinx.serialization.Serializable + +// Shared by both LocationApi.reorder and ShelfApi.reorder — locations and shelves +// are reordered the same way (a full ordered id list for the parent scope), so +// this lives here rather than duplicated in, or owned by, either DTO file. +@Serializable +data class ReorderRequest( + val ids: List, +) diff --git a/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt b/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt index 6866081..6a0791a 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt @@ -13,6 +13,13 @@ data class ShelfDto( val is_system: Boolean = false, // The server requires a delete STRATEGY when a shelf has products. This is how // the client knows to ask, and it feeds the dialog's "17 products" summary. + // + // CACHE STALENESS: ShelfRepositoryImpl's cache is keyed by (householdId, + // locationId) and is only replaced by shelf-level calls for that key. A + // product-level mutation (add/remove/move) does not update it, so a cached + // ShelfDto's product_count can read stale. Any UI deriving `needsStrategy` + // from this field MUST go through HierarchyStore.refresh() first; a cached + // read can silently re-create the very 422 this field exists to prevent. val product_count: Int = 0, ) @@ -36,11 +43,6 @@ data class UpdateShelfRequest( val name: String, ) -@Serializable -data class ReorderRequest( - val ids: List, -) - // No property defaults: the app's Json has encodeDefaults=false, so a defaulted // field is OMITTED from the body — and the server 422s without deletion_batch_id. @Serializable diff --git a/app/src/main/java/dev/scuttle/inventory/data/hierarchy/DeleteStrategy.kt b/app/src/main/java/dev/scuttle/inventory/data/hierarchy/DeleteStrategy.kt index 5d57cf2..23dd429 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/hierarchy/DeleteStrategy.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/hierarchy/DeleteStrategy.kt @@ -23,3 +23,23 @@ enum class LocationDeleteStrategy( MOVE_CONTENTS("move_contents"), DELETE_CONTENTS("delete_contents"), } + +/** + * Everything the delete-confirmation dialog produces for a shelf delete, bundled + * so `ShelfRepository.deleteWithStrategy` takes 4 params instead of 6 — `batchId`, + * `strategy`, and `targetShelfId` are one concept (the chosen resolution), not + * three independent ones. `strategy`/`targetShelfId` are null for an empty shelf, + * which needs no strategy. + */ +data class ShelfDeletion( + val batchId: String, + val strategy: ShelfDeleteStrategy?, + val targetShelfId: Long?, +) + +/** Location equivalent of [ShelfDeletion]. */ +data class LocationDeletion( + val batchId: String, + val strategy: LocationDeleteStrategy?, + val targetLocationId: Long?, +) diff --git a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt index cea90c5..04bdb94 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt @@ -1,7 +1,7 @@ package dev.scuttle.inventory.data.location import dev.scuttle.inventory.data.dto.LocationDto -import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.LocationDeletion interface LocationRepository { fun getCached(householdId: Long): List? @@ -39,9 +39,7 @@ interface LocationRepository { suspend fun deleteWithStrategy( householdId: Long, locationId: Long, - batchId: String, - strategy: LocationDeleteStrategy?, - targetLocationId: Long?, + deletion: LocationDeletion, ): Unit = throw UnsupportedOperationException("deleteWithStrategy not supported") /** Drop the in-memory cache so one account's data never bleeds into the next session. */ diff --git a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt index 61268a3..6bbc31d 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt @@ -6,7 +6,7 @@ import dev.scuttle.inventory.data.dto.DeleteLocationRequest import dev.scuttle.inventory.data.dto.LocationDto import dev.scuttle.inventory.data.dto.ReorderRequest import dev.scuttle.inventory.data.dto.UpdateLocationRequest -import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.LocationDeletion import javax.inject.Inject class LocationRepositoryImpl @@ -48,8 +48,13 @@ class LocationRepositoryImpl .update(householdId, locationId, UpdateLocationRequest(name = name, type = type)) .data .also { updated -> - val updatedList = cache[householdId]?.map { if (it.id == locationId) updated else it } - cache[householdId] = updatedList ?: listOf(updated) + // On a cache miss, leave the cache absent rather than fabricating a + // 1-element list — getCached() returning null means "go fetch", and a + // bogus single-location cache would lie about every other location in + // this household until the next full refresh. + cache[householdId]?.let { cached -> + cache[householdId] = cached.map { if (it.id == locationId) updated else it } + } } override suspend fun reorder( @@ -63,17 +68,15 @@ class LocationRepositoryImpl override suspend fun deleteWithStrategy( householdId: Long, locationId: Long, - batchId: String, - strategy: LocationDeleteStrategy?, - targetLocationId: Long?, + deletion: LocationDeletion, ) { api.delete( householdId, locationId, DeleteLocationRequest( - strategy = strategy?.wire, - target_location_id = targetLocationId, - deletion_batch_id = batchId, + strategy = deletion.strategy?.wire, + target_location_id = deletion.targetLocationId, + deletion_batch_id = deletion.batchId, ), ) cache[householdId] = cache[householdId]?.filter { it.id != locationId } ?: emptyList() diff --git a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt index dda0360..c22a07c 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt @@ -1,7 +1,7 @@ package dev.scuttle.inventory.data.shelf import dev.scuttle.inventory.data.dto.ShelfDto -import dev.scuttle.inventory.data.hierarchy.ShelfDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.ShelfDeletion interface ShelfRepository { fun getCached( @@ -48,9 +48,7 @@ interface ShelfRepository { householdId: Long, locationId: Long, shelfId: Long, - batchId: String, - strategy: ShelfDeleteStrategy?, - targetShelfId: Long?, + deletion: ShelfDeletion, ): Unit = throw UnsupportedOperationException("deleteWithStrategy not supported") /** Drop the in-memory cache so one account's data never bleeds into the next session. */ diff --git a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt index 4e4fef8..2c814cc 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt @@ -6,7 +6,7 @@ import dev.scuttle.inventory.data.dto.DeleteShelfRequest import dev.scuttle.inventory.data.dto.ReorderRequest import dev.scuttle.inventory.data.dto.ShelfDto import dev.scuttle.inventory.data.dto.UpdateShelfRequest -import dev.scuttle.inventory.data.hierarchy.ShelfDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.ShelfDeletion import javax.inject.Inject class ShelfRepositoryImpl @@ -54,7 +54,13 @@ class ShelfRepositoryImpl ): ShelfDto = api.update(householdId, locationId, shelfId, UpdateShelfRequest(name = name)).data.also { updated -> val key = householdId to locationId - cache[key] = cache[key]?.map { if (it.id == shelfId) updated else it } ?: listOf(updated) + // On a cache miss, leave the cache absent rather than fabricating a + // 1-element list — getCached() returning null means "go fetch", and a + // bogus single-shelf cache would lie about every other shelf in this + // location until the next full refresh. + cache[key]?.let { cached -> + cache[key] = cached.map { if (it.id == shelfId) updated else it } + } } override suspend fun reorder( @@ -70,18 +76,16 @@ class ShelfRepositoryImpl householdId: Long, locationId: Long, shelfId: Long, - batchId: String, - strategy: ShelfDeleteStrategy?, - targetShelfId: Long?, + deletion: ShelfDeletion, ) { api.delete( householdId, locationId, shelfId, DeleteShelfRequest( - strategy = strategy?.wire, - target_shelf_id = targetShelfId, - deletion_batch_id = batchId, + strategy = deletion.strategy?.wire, + target_shelf_id = deletion.targetShelfId, + deletion_batch_id = deletion.batchId, ), ) val key = householdId to locationId diff --git a/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt b/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt index cfae25b..3b1b48b 100644 --- a/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt @@ -1,48 +1,90 @@ package dev.scuttle.inventory +import dev.scuttle.inventory.data.dto.DeleteLocationRequest import dev.scuttle.inventory.data.dto.DeleteShelfRequest -import kotlinx.serialization.json.Json +import dev.scuttle.inventory.data.dto.ReorderRequest +import dev.scuttle.inventory.data.dto.UpdateHouseholdRequest +import dev.scuttle.inventory.data.dto.UpdateLocationRequest +import dev.scuttle.inventory.data.dto.UpdateShelfRequest +import kotlinx.serialization.descriptors.SerialDescriptor import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +/** + * The app's Json has encodeDefaults=false: a property equal to its default is + * silently OMITTED from the request body. For most request DTOs that's exactly + * the bug the server 422s on (a missing `deletion_batch_id`, an unsent strategy + * the server required, etc.) — so these DTOs must have NO defaulted properties. + * + * This asserts on the serializer descriptor rather than an encoded body string. + * `isElementOptional` is true iff the property has a default value, so it is + * exactly discriminating on the field a default was (or wasn't) added to — a + * body-string check like `body.contains("deletion_batch_id")` passes as long as + * ANY value is present, which stays true even when a *different* field on the + * same DTO regresses to a default and silently vanishes. + */ class DeleteRequestSerializationTest { - // The app's real Json config. encodeDefaults defaults to false, which is - // exactly why a defaulted property would be dropped from the body. - private val json = Json { ignoreUnknownKeys = true } + @Test + fun delete_shelf_request_has_no_defaulted_properties() { + assertNoDefaultedProperties(DeleteShelfRequest.serializer().descriptor) + } @Test - fun batch_id_is_always_sent() { - val body = - json.encodeToString( - DeleteShelfRequest( - strategy = null, - target_shelf_id = null, - deletion_batch_id = "11111111-1111-4111-8111-111111111111", - ), - ) + fun delete_location_request_has_no_defaulted_properties() { + assertNoDefaultedProperties(DeleteLocationRequest.serializer().descriptor) + } - // The server 422s without it. If a default ever creeps onto this - // property, encodeDefaults=false silently omits it and every delete - // starts failing with a validation error nobody can explain. - assertTrue(body.contains("deletion_batch_id")) + @Test + fun update_shelf_request_has_no_defaulted_properties() { + assertNoDefaultedProperties(UpdateShelfRequest.serializer().descriptor) } @Test - fun a_null_strategy_is_still_sent_explicitly() { - val body = - json.encodeToString( - DeleteShelfRequest( - strategy = null, - target_shelf_id = null, - deletion_batch_id = "11111111-1111-4111-8111-111111111111", - ), - ) + fun update_location_request_has_no_defaulted_properties() { + assertNoDefaultedProperties(UpdateLocationRequest.serializer().descriptor) + } - // An empty shelf legitimately has no strategy. Sending explicit null is - // fine; what must never happen is the property vanishing because it was - // defaulted. - assertTrue(body.contains("\"strategy\":null")) - assertFalse(body.isBlank()) + @Test + fun reorder_request_has_no_defaulted_properties() { + assertNoDefaultedProperties(ReorderRequest.serializer().descriptor) + } + + /** + * UpdateHouseholdRequest is the deliberate exception (C-1): `name` MUST stay + * defaulted to null, so a theme-only update omits it — the server's + * `sometimes|required` rule then reads "don't touch the name". Sending an + * explicit null there fails validation. `color`/`icon` are `sometimes| + * nullable` on the server, where an explicit null means "clear the theme + * back to the derived default", so THEY must stay un-defaulted. Pin the + * asymmetry explicitly so it reads as deliberate, not as a regression of + * the no-defaults rule everywhere else in this file. + */ + @Test + fun update_household_request_defaults_only_name() { + val descriptor = UpdateHouseholdRequest.serializer().descriptor + + assertTrue( + "name must default to null so a theme-only update omits the key", + descriptor.isElementOptional(descriptor.getElementIndex("name")), + ) + assertFalse( + "color must not be defaulted: an explicit null clears the theme", + descriptor.isElementOptional(descriptor.getElementIndex("color")), + ) + assertFalse( + "icon must not be defaulted: an explicit null clears the theme", + descriptor.isElementOptional(descriptor.getElementIndex("icon")), + ) + } + + private fun assertNoDefaultedProperties(descriptor: SerialDescriptor) { + for (i in 0 until descriptor.elementsCount) { + assertFalse( + "${descriptor.serialName}.${descriptor.getElementName(i)} must not have a default value: " + + "encodeDefaults=false would silently drop it from the request body", + descriptor.isElementOptional(i), + ) + } } } From 6093e1d0d2017d42946027bd5702a3bb2670fb8c Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 19:21:59 +0200 Subject: [PATCH 05/40] feat: one ordering rule for every hierarchy list Manual position wins; name is only the tie-break for never-reordered items. A star is deliberately not an input: it is a marker and a filter, never a sort. Shelf order is physical, and a star that floated the bottom shelf to the top would leave the list disagreeing with the actual fridge. --- .../inventory/ui/common/HierarchyOrder.kt | 22 +++++ .../scuttle/inventory/HierarchyOrderTest.kt | 80 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 app/src/main/java/dev/scuttle/inventory/ui/common/HierarchyOrder.kt create mode 100644 app/src/test/java/dev/scuttle/inventory/HierarchyOrderTest.kt diff --git a/app/src/main/java/dev/scuttle/inventory/ui/common/HierarchyOrder.kt b/app/src/main/java/dev/scuttle/inventory/ui/common/HierarchyOrder.kt new file mode 100644 index 0000000..4a7e5b7 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/common/HierarchyOrder.kt @@ -0,0 +1,22 @@ +package dev.scuttle.inventory.ui.common + +/** + * The one ordering rule for every hierarchy list in the app. + * + * 1. Manual position — what the user set by moving rows. It always wins. + * 2. Name — the tie-break for items nobody has reordered yet (they all sit at 0). + * + * A star is deliberately NOT an input here. Starring is a marker and a filter, + * never a sort: shelf order is physical, and a star that floated the bottom shelf + * to the top of the list would leave the app disagreeing with the actual fridge. + * Keeping favourites out of this signature makes that impossible to get wrong. + */ +fun orderByPosition( + items: List, + position: (T) -> Int?, + name: (T) -> String, +): List = + items.sortedWith( + compareBy { position(it) ?: 0 } + .thenBy { name(it).lowercase() }, + ) diff --git a/app/src/test/java/dev/scuttle/inventory/HierarchyOrderTest.kt b/app/src/test/java/dev/scuttle/inventory/HierarchyOrderTest.kt new file mode 100644 index 0000000..b253248 --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/HierarchyOrderTest.kt @@ -0,0 +1,80 @@ +package dev.scuttle.inventory + +import dev.scuttle.inventory.data.dto.ShelfDto +import dev.scuttle.inventory.ui.common.orderByPosition +import org.junit.Assert.assertEquals +import org.junit.Test + +class HierarchyOrderTest { + private fun shelf( + id: Long, + name: String, + position: Int?, + isSystem: Boolean = false, + ) = ShelfDto(id = id, name = name, position = position, location_id = 1L, is_system = isSystem) + + private fun order(items: List) = orderByPosition(items, { it.position }, { it.name }) + + @Test + fun manual_position_wins() { + val items = + listOf( + shelf(1, "Top", 0), + shelf(2, "Middle", 1), + shelf(3, "Bottom", 2), + ) + + assertEquals(listOf(1L, 2L, 3L), order(items.shuffled()).map { it.id }) + } + + @Test + fun a_star_never_reorders_anything() { + // THE rule. Shelf order is physical: if starring the bottom shelf floated + // it to the top, the list would stop matching the fridge the user is + // standing in front of. A star is a marker and a filter, never a sort — + // so this function does not even take a favourites set as an argument. + val items = + listOf( + shelf(1, "Top", 0), + shelf(2, "Middle", 1), + shelf(3, "Bottom", 2), + ) + + // Whatever the caller has starred, the order is unchanged. + assertEquals(listOf(1L, 2L, 3L), order(items).map { it.id }) + } + + @Test + fun name_is_only_the_tie_break_for_never_reordered_items() { + // Everything sits at position 0 until someone drags. Falling back to name + // keeps a fresh list stable instead of ordering it by insertion id. + val items = + listOf( + shelf(1, "Zebra", 0), + shelf(2, "Apple", 0), + shelf(3, "Mango", 0), + ) + + assertEquals(listOf("Apple", "Mango", "Zebra"), order(items).map { it.name }) + } + + @Test + fun a_null_position_sorts_as_zero() { + // An older server, or a row created before the position column existed. + val items = + listOf( + shelf(1, "Bbb", null), + shelf(2, "Aaa", null), + shelf(3, "Ccc", 1), + ) + + assertEquals(listOf("Aaa", "Bbb", "Ccc"), order(items).map { it.name }) + } + + @Test + fun the_sort_is_stable_across_repeated_calls() { + val items = listOf(shelf(1, "Same", 0), shelf(2, "Same", 0)) + + assertEquals(order(items).map { it.id }, order(order(items)).map { it.id }) + } +} From 038de54790b20b4c19d802334d579fe076ba87a1 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 19:34:06 +0200 Subject: [PATCH 06/40] feat: delete-strategy dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asks what to do with the contents of a shelf/location being deleted: move them, keep shelf products in Unsorted (shelf level only), or delete them too. One dialog per gesture — a batch of three shelves is summarised once, not interrogated three times. Generic over the strategy type (StrategyOption/DeleteStrategyDialog) so the same dialog serves both ShelfDeleteStrategy and LocationDeleteStrategy, via the shelfStrategyOptions()/locationStrategyOptions() factories — the brief had hardcoded ShelfDeleteStrategy, which would have made Task 5's location delete impossible to type correctly. Never offers a MOVE_* option when there is nowhere to move to, defaults to the safest available option (factories list safest-first), and disables confirm while a target is required and unpicked. An empty container (needsStrategy false) still shows a plain confirm dialog with no strategy radios — it is never deleted silently, which was the bug that started this whole project. --- .../inventory/ui/hierarchy/DeletePlan.kt | 36 +++ .../ui/hierarchy/DeleteStrategyDialog.kt | 238 ++++++++++++++++++ app/src/main/res/values-nl/strings.xml | 13 + app/src/main/res/values/strings.xml | 13 + .../dev/scuttle/inventory/DeletePlanTest.kt | 52 ++++ 5 files changed, 352 insertions(+) create mode 100644 app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeletePlan.kt create mode 100644 app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt create mode 100644 app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt diff --git a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeletePlan.kt b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeletePlan.kt new file mode 100644 index 0000000..45dd70d --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeletePlan.kt @@ -0,0 +1,36 @@ +package dev.scuttle.inventory.ui.hierarchy + +/** + * What one delete gesture is about to destroy, and therefore what the user has + * to be asked. + * + * A BATCH gets ONE dialog, not one per item: selecting three shelves and being + * interrogated three times is worse than being told "3 shelves · 17 products" + * once and choosing once. + */ +data class DeletePlan( + /** How many containers (shelves or locations) are selected. */ + val itemCount: Int, + /** How many products live inside them, in total. Feeds the summary line. */ + val productCount: Int, + /** + * What the SERVER counts as "has contents" for the thing being deleted. + * + * These two are NOT the same rule, and getting it wrong 422s every delete: + * - deleting a SHELF → the server asks when the shelf has PRODUCTS. + * - deleting a LOCATION → the server asks when the location has SHELVES + * (`shelf_count`), even if those shelves are empty. + * + * So the caller passes the right count: `product_count` for shelves, + * `shelf_count` for locations. Do not "simplify" this to productCount. + */ + val contentCount: Int, + /** Is there anywhere else to put the contents? False when this is the only shelf/location. */ + val hasOtherTargets: Boolean, +) { + /** An empty container is safe to delete outright — a plain confirm will do. */ + val needsStrategy: Boolean get() = contentCount > 0 + + /** Never offer "move" when there is nothing to move to. */ + val canMove: Boolean get() = hasOtherTargets +} diff --git a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt new file mode 100644 index 0000000..6d420ad --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt @@ -0,0 +1,238 @@ +package dev.scuttle.inventory.ui.hierarchy + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.scuttle.inventory.R +import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.ShelfDeleteStrategy + +/** + * One radio choice offered by [DeleteStrategyDialog]: the wire value it sends + * ([strategy]), the copy that names it ([labelRes]), a slot for a secondary + * explanatory line ([descriptionRes] — the built-in [shelfStrategyOptions] and + * [locationStrategyOptions] point it at the same string as [labelRes], since the + * brief defines only one line of copy per option; a caller with genuinely + * distinct follow-up copy can point the two at different resources and the + * dialog will render both), and whether picking it requires a [MoveTarget] + * ([requiresTarget] — true only for the MOVE_* member of either strategy enum). + */ +data class StrategyOption( + val strategy: T, + @StringRes val labelRes: Int, + @StringRes val descriptionRes: Int, + val requiresTarget: Boolean, +) + +/** A shelf or location the contents of a deleted container could move to. */ +data class MoveTarget( + val id: Long, + val name: String, +) + +/** + * Asks what to do with the contents of the shelves/locations being deleted. + * + * One dialog per GESTURE, not per item: the summary covers the whole batch and + * one choice applies to all of it. Generic in the strategy type so ONE dialog + * serves both levels — shelves resolve with [ShelfDeleteStrategy] (which has a + * third, non-destructive "unsort" member), locations with [LocationDeleteStrategy] + * (which deliberately has no unsort: "unsorted" still means IN the location, and + * the location is the thing being deleted). Use [shelfStrategyOptions] / + * [locationStrategyOptions] to build [options] for each caller. + * + * When [plan] doesn't need a strategy (an empty container), this renders as a + * plain confirm — no radios, nothing to choose. It is still a confirm: an empty + * shelf/location is never deleted without one. + */ +@Composable +fun DeleteStrategyDialog( + plan: DeletePlan, + options: List>, + targets: List, + onDismiss: () -> Unit, + onConfirm: (T?, Long?) -> Unit, +) { + // Never offer a MOVE_* option when there is nowhere to move to — the caller + // may pass canMove-independent options, so this is enforced here too. + val availableOptions = options.filter { !it.requiresTarget || targets.isNotEmpty() } + + // Options are supplied safest-first (move > unsort > delete), so the first + // available one is always the safest available default — never destroying + // data by default, without this dialog having to know the concrete enum. + var selected by remember(plan, availableOptions) { mutableStateOf(availableOptions.firstOrNull()) } + var targetId by remember(plan, targets) { mutableStateOf(targets.firstOrNull()?.id) } + + val confirmEnabled = + !plan.needsStrategy || selected?.let { !it.requiresTarget || targetId != null } == true + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.delete_strategy_title, plan.itemCount)) }, + text = + if (plan.needsStrategy) { + { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.delete_strategy_summary, plan.productCount)) + + availableOptions.forEach { option -> + StrategyOptionRow( + option = option, + selected = selected == option, + tag = "delete-strategy-${option.strategy}", + onSelect = { selected = option }, + ) + } + + if (options.any { it.requiresTarget } && targets.isEmpty()) { + Text(stringResource(R.string.delete_strategy_no_target)) + } + + if (selected?.requiresTarget == true && targets.isNotEmpty()) { + Text(stringResource(R.string.delete_strategy_pick_target)) + targets.forEach { target -> + TargetOptionRow( + target = target, + selected = targetId == target.id, + onSelect = { targetId = target.id }, + ) + } + } + } + } + } else { + null + }, + confirmButton = { + Button( + onClick = { + // An empty container needs no strategy at all — send null/null + // regardless of whatever the (hidden) radio state defaulted to. + val strategy = if (plan.needsStrategy) selected?.strategy else null + val target = if (plan.needsStrategy && selected?.requiresTarget == true) targetId else null + onConfirm(strategy, target) + }, + enabled = confirmEnabled, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.testTag("delete-strategy-confirm"), + ) { Text(stringResource(R.string.action_delete)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + }, + ) +} + +@Composable +private fun StrategyOptionRow( + option: StrategyOption, + selected: Boolean, + tag: String, + onSelect: () -> Unit, +) { + Column( + modifier = + Modifier + .selectable(selected = selected, onClick = onSelect) + .testTag(tag), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = selected, onClick = onSelect) + Text(stringResource(option.labelRes)) + } + // Only rendered when a caller supplies a description distinct from its + // label — the built-in options don't, so this is a no-op for them today. + if (option.descriptionRes != option.labelRes) { + Text( + text = stringResource(option.descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun TargetOptionRow( + target: MoveTarget, + selected: Boolean, + onSelect: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .selectable(selected = selected, onClick = onSelect) + .testTag("delete-target-${target.id}"), + ) { + RadioButton(selected = selected, onClick = onSelect) + Text(target.name) + } +} + +/** + * The three ways to resolve a shelf's products, safest first: move them to + * another shelf, leave them in this location's "Unsorted" holding shelf, or + * delete them along with the shelf. + */ +fun shelfStrategyOptions(): List> = + listOf( + StrategyOption( + strategy = ShelfDeleteStrategy.MOVE_PRODUCTS, + labelRes = R.string.delete_strategy_move, + descriptionRes = R.string.delete_strategy_move, + requiresTarget = true, + ), + StrategyOption( + strategy = ShelfDeleteStrategy.UNSORT_PRODUCTS, + labelRes = R.string.delete_strategy_unsort, + descriptionRes = R.string.delete_strategy_unsort, + requiresTarget = false, + ), + StrategyOption( + strategy = ShelfDeleteStrategy.DELETE_PRODUCTS, + labelRes = R.string.delete_strategy_delete, + descriptionRes = R.string.delete_strategy_delete, + requiresTarget = false, + ), + ) + +/** + * The two ways to resolve a location's contents, safest first. No "unsort" + * member exists here on purpose: "unsorted" means off-shelf but still IN the + * location, and the location itself is what's being deleted. + */ +fun locationStrategyOptions(): List> = + listOf( + StrategyOption( + strategy = LocationDeleteStrategy.MOVE_CONTENTS, + labelRes = R.string.delete_strategy_move, + descriptionRes = R.string.delete_strategy_move, + requiresTarget = true, + ), + StrategyOption( + strategy = LocationDeleteStrategy.DELETE_CONTENTS, + labelRes = R.string.delete_strategy_delete, + descriptionRes = R.string.delete_strategy_delete, + requiresTarget = false, + ), + ) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index fe3f3d4..ee20c68 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -198,4 +198,17 @@ Uitnodigings-QR-code Link kopiëren Gekopieerd! + + + %1$d item(s) verwijderen? + Er liggen %1$d product(en) in. Wat moet daarmee gebeuren? + Verplaats ze ergens anders heen + Bewaar ze hier, in Ongesorteerd + Verwijder ze ook + Waar moeten ze heen? + Er is geen andere plek om ze heen te verplaatsen. + Ongedaan maken + Hersteld. + Ongedaan maken lukt niet — dit is al hersteld of definitief verwijderd. + Ongesorteerd diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 68f92a0..7dbd0bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -198,4 +198,17 @@ Invite QR code Copy link Copied! + + + Delete %1$d item(s)? + %1$d product(s) are stored inside. What should happen to them? + Move them somewhere else + Keep them here, in Unsorted + Delete them too + Where should they go? + There is nowhere else to move them. + Undo + Restored. + Couldn\'t undo — this was already restored or permanently removed. + Unsorted diff --git a/app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt b/app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt new file mode 100644 index 0000000..151f1d2 --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt @@ -0,0 +1,52 @@ +package dev.scuttle.inventory + +import dev.scuttle.inventory.ui.hierarchy.DeletePlan +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeletePlanTest { + @Test + fun an_empty_selection_needs_no_strategy() { + val plan = DeletePlan(itemCount = 2, productCount = 0, contentCount = 0, hasOtherTargets = true) + + // Nothing to rescue — a plain confirm is enough. + assertFalse(plan.needsStrategy) + } + + @Test + fun a_shelf_holding_products_needs_a_strategy() { + // For a SHELF, contentCount is its product_count. + val plan = DeletePlan(itemCount = 1, productCount = 12, contentCount = 12, hasOtherTargets = true) + + assertTrue(plan.needsStrategy) + } + + @Test + fun a_location_holding_an_EMPTY_shelf_still_needs_a_strategy() { + // THE cross-repo trap. The server asks for a strategy when a location has + // SHELVES, not when it has products — so a location with one empty shelf + // (0 products, 1 shelf) must still prompt. Deciding this from productCount + // would send a strategy-less delete and 422 every single time. + val plan = DeletePlan(itemCount = 1, productCount = 0, contentCount = 1, hasOtherTargets = true) + + assertTrue(plan.needsStrategy) + } + + @Test + fun move_is_unavailable_when_there_is_nowhere_to_move_to() { + // Deleting the household's only location: there is no other location to + // take the contents, so the UI must not offer "move" as a dead option. + val plan = DeletePlan(itemCount = 1, productCount = 12, contentCount = 12, hasOtherTargets = false) + + assertTrue(plan.needsStrategy) + assertFalse(plan.canMove) + } + + @Test + fun move_is_available_when_a_target_exists() { + val plan = DeletePlan(itemCount = 1, productCount = 12, contentCount = 12, hasOtherTargets = true) + + assertTrue(plan.canMove) + } +} From b5eaf55fc5c5551c610cde30136a517b99368de6 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 20:20:29 +0200 Subject: [PATCH 07/40] test: cover the delete-strategy dialog's safety guards, drop dead descriptionRes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the delete-strategy dialog found its two safety behaviors — never offering "move" with nowhere to move to, and keeping confirm disabled while a target-requiring option has no target picked — had zero test coverage: a reviewer deleted both guards at once and the full suite stayed green. Adds DeleteStrategyDialogTest (androidTest, matching ProductFilterSortRowTest's isolated-composable approach) covering: the option filter, the confirm-enabled guard, and the empty-container plain-confirm path that must always emit (null, null) rather than silently skipping the dialog. Mutation-tested each guard on a real emulator to confirm the tests actually discriminate; findings recorded in .superpowers/sdd/task-3-report.md (gitignored). Also removes StrategyOption.descriptionRes: both factories set it equal to labelRes, so the conditional description row it fed never rendered. Dead field, dead branch, both gone. DeletePlan.canMove/hasOtherTargets was flagged as a similarly dead parallel signal, but left in place: Tasks 4 and 5 in the current plan doc construct/ read it directly, so removing it now would break not-yet-built, already- specified callers. --- .../ui/hierarchy/DeleteStrategyDialogTest.kt | 134 ++++++++++++++++++ .../ui/hierarchy/DeleteStrategyDialog.kt | 25 +--- 2 files changed, 137 insertions(+), 22 deletions(-) create mode 100644 app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialogTest.kt diff --git a/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialogTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialogTest.kt new file mode 100644 index 0000000..61f4e08 --- /dev/null +++ b/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialogTest.kt @@ -0,0 +1,134 @@ +package dev.scuttle.inventory.ui.hierarchy + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import dev.scuttle.inventory.R +import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * `DeleteStrategyDialog` gates every destructive shelf/location delete, but its + * safety behaviors had zero test coverage: a reviewer deleted the option filter + * (guard a, line ~72) and the confirm-enablement check (guard b, line ~80-81) + * at the same time and the full 149-test suite stayed green. These tests exist + * to catch that class of regression — rendered in isolation, the same way + * `ProductFilterSortRowTest` renders a single composable under `createComposeRule` + * rather than driving a full screen. + */ +class DeleteStrategyDialogTest { + @get:Rule + val composeRule = createComposeRule() + + private fun render( + plan: DeletePlan, + options: List>, + targets: List, + onConfirm: (T?, Long?) -> Unit = { _, _ -> }, + ) { + composeRule.setContent { + MaterialTheme { + DeleteStrategyDialog( + plan = plan, + options = options, + targets = targets, + onDismiss = {}, + onConfirm = onConfirm, + ) + } + } + } + + @Test + fun move_option_is_not_offered_when_there_are_no_targets() { + // Deleting the household's only location: there is nowhere to move the + // contents to, so "move" must not be offered as a dead option (guard a). + render( + plan = DeletePlan(itemCount = 1, productCount = 3, contentCount = 1, hasOtherTargets = false), + options = locationStrategyOptions(), + targets = emptyList(), + ) + + composeRule.onNodeWithTag("delete-strategy-${LocationDeleteStrategy.MOVE_CONTENTS}").assertDoesNotExist() + // The dialog still offers a real (non-dead) choice, not an empty list. + composeRule.onNodeWithTag("delete-strategy-${LocationDeleteStrategy.DELETE_CONTENTS}").assertIsEnabled() + } + + @Test + fun confirm_stays_disabled_when_every_offered_option_needs_a_target_that_does_not_exist() { + // A defensive scenario the generic dialog must survive even though + // neither shipped factory triggers it today: every option on offer + // requires a target, and none exists. Guard (a) empties the option + // list (selected == null); guard (b) is the second line of defense + // that must independently keep confirm disabled if guard (a) is ever + // weakened. Without either guard, the user could tap Delete and the + // server would 422 on a null target. + val onlyOptionNeedsATarget = + listOf( + StrategyOption( + strategy = LocationDeleteStrategy.MOVE_CONTENTS, + labelRes = R.string.delete_strategy_move, + requiresTarget = true, + ), + ) + + render( + plan = DeletePlan(itemCount = 1, productCount = 3, contentCount = 1, hasOtherTargets = false), + options = onlyOptionNeedsATarget, + targets = emptyList(), + ) + + composeRule.onNodeWithTag("delete-strategy-confirm").assertIsNotEnabled() + } + + @Test + fun confirm_is_enabled_once_a_target_is_available_for_the_selected_move() { + // Sanity/positive counterpart to the two tests above: once a target + // exists for the selected move option, confirm is enabled — the + // dialog isn't stuck disabled forever. + render( + plan = DeletePlan(itemCount = 1, productCount = 3, contentCount = 1, hasOtherTargets = true), + options = locationStrategyOptions(), + targets = listOf(MoveTarget(id = 99L, name = "Pantry")), + ) + + composeRule.onNodeWithTag("delete-strategy-confirm").assertIsEnabled() + } + + @Test + fun an_empty_container_renders_a_plain_confirm_and_emits_null_null() { + // An empty container needs no strategy — but it must still be a real + // confirm dialog, not a silent delete. Confirming must send (null, + // null) regardless of whatever the (hidden, unused) radio state would + // have defaulted to. + var confirmedStrategy: LocationDeleteStrategy? = LocationDeleteStrategy.DELETE_CONTENTS + var confirmedTarget: Long? = 1L + var confirmCount = 0 + + render( + plan = DeletePlan(itemCount = 2, productCount = 0, contentCount = 0, hasOtherTargets = true), + options = locationStrategyOptions(), + targets = listOf(MoveTarget(id = 99L, name = "Pantry")), + onConfirm = { strategy, target -> + confirmCount++ + confirmedStrategy = strategy + confirmedTarget = target + }, + ) + + // No strategy question is shown for an empty container. + composeRule.onNodeWithTag("delete-strategy-${LocationDeleteStrategy.MOVE_CONTENTS}").assertDoesNotExist() + composeRule.onNodeWithTag("delete-strategy-${LocationDeleteStrategy.DELETE_CONTENTS}").assertDoesNotExist() + + composeRule.onNodeWithTag("delete-strategy-confirm").assertIsEnabled().performClick() + + assertEquals(1, confirmCount) + assertEquals(null, confirmedStrategy) + assertEquals(null, confirmedTarget) + } +} diff --git a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt index 6d420ad..d1f4d60 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt @@ -28,18 +28,13 @@ import dev.scuttle.inventory.data.hierarchy.ShelfDeleteStrategy /** * One radio choice offered by [DeleteStrategyDialog]: the wire value it sends - * ([strategy]), the copy that names it ([labelRes]), a slot for a secondary - * explanatory line ([descriptionRes] — the built-in [shelfStrategyOptions] and - * [locationStrategyOptions] point it at the same string as [labelRes], since the - * brief defines only one line of copy per option; a caller with genuinely - * distinct follow-up copy can point the two at different resources and the - * dialog will render both), and whether picking it requires a [MoveTarget] - * ([requiresTarget] — true only for the MOVE_* member of either strategy enum). + * ([strategy]), the copy that names it ([labelRes]), and whether picking it + * requires a [MoveTarget] ([requiresTarget] — true only for the MOVE_* member + * of either strategy enum). */ data class StrategyOption( val strategy: T, @StringRes val labelRes: Int, - @StringRes val descriptionRes: Int, val requiresTarget: Boolean, ) @@ -159,15 +154,6 @@ private fun StrategyOptionRow( RadioButton(selected = selected, onClick = onSelect) Text(stringResource(option.labelRes)) } - // Only rendered when a caller supplies a description distinct from its - // label — the built-in options don't, so this is a no-op for them today. - if (option.descriptionRes != option.labelRes) { - Text( - text = stringResource(option.descriptionRes), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } } @@ -199,19 +185,16 @@ fun shelfStrategyOptions(): List> = StrategyOption( strategy = ShelfDeleteStrategy.MOVE_PRODUCTS, labelRes = R.string.delete_strategy_move, - descriptionRes = R.string.delete_strategy_move, requiresTarget = true, ), StrategyOption( strategy = ShelfDeleteStrategy.UNSORT_PRODUCTS, labelRes = R.string.delete_strategy_unsort, - descriptionRes = R.string.delete_strategy_unsort, requiresTarget = false, ), StrategyOption( strategy = ShelfDeleteStrategy.DELETE_PRODUCTS, labelRes = R.string.delete_strategy_delete, - descriptionRes = R.string.delete_strategy_delete, requiresTarget = false, ), ) @@ -226,13 +209,11 @@ fun locationStrategyOptions(): List> = StrategyOption( strategy = LocationDeleteStrategy.MOVE_CONTENTS, labelRes = R.string.delete_strategy_move, - descriptionRes = R.string.delete_strategy_move, requiresTarget = true, ), StrategyOption( strategy = LocationDeleteStrategy.DELETE_CONTENTS, labelRes = R.string.delete_strategy_delete, - descriptionRes = R.string.delete_strategy_delete, requiresTarget = false, ), ) From 84f3cd5a951ed8b87f47842df2ceb2ed3c5ba98e Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 20:22:43 +0200 Subject: [PATCH 08/40] refactor(hierarchy): drop DeletePlan.canMove in favour of the rendered target list DeletePlan carried hasOtherTargets/canMove, but DeleteStrategyDialog never read it -- it decides move-availability from the List it is handed. Two parallel signals for one fact, and the dead one could disagree with the live one: a plan claiming canMove while the rendered target list is empty would offer a move with nowhere to go. Keeps the signal the user can actually see. The rule stays pinned by DeleteStrategyDialogTest.move_option_is_not_offered_when_there_are_no_targets, which is mutation-proven; the two DeletePlanTest cases it replaces asserted on the flag rather than the behaviour. Plan Tasks 4/5 amended so they no longer build against the removed field, and Task 5 gains the shelf_count-vs-product_count test (a location with 3 EMPTY shelves still needs a strategy). --- .../ui/hierarchy/DeleteStrategyDialogTest.kt | 8 +-- .../inventory/ui/hierarchy/DeletePlan.kt | 11 ++-- .../ui/hierarchy/DeleteStrategyDialog.kt | 2 +- .../dev/scuttle/inventory/DeletePlanTest.kt | 26 +++----- ...13-storage-architecture-editing-android.md | 59 ++++++++++++++++--- 5 files changed, 68 insertions(+), 38 deletions(-) diff --git a/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialogTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialogTest.kt index 61f4e08..8d595f8 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialogTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialogTest.kt @@ -49,7 +49,7 @@ class DeleteStrategyDialogTest { // Deleting the household's only location: there is nowhere to move the // contents to, so "move" must not be offered as a dead option (guard a). render( - plan = DeletePlan(itemCount = 1, productCount = 3, contentCount = 1, hasOtherTargets = false), + plan = DeletePlan(itemCount = 1, productCount = 3, contentCount = 1), options = locationStrategyOptions(), targets = emptyList(), ) @@ -78,7 +78,7 @@ class DeleteStrategyDialogTest { ) render( - plan = DeletePlan(itemCount = 1, productCount = 3, contentCount = 1, hasOtherTargets = false), + plan = DeletePlan(itemCount = 1, productCount = 3, contentCount = 1), options = onlyOptionNeedsATarget, targets = emptyList(), ) @@ -92,7 +92,7 @@ class DeleteStrategyDialogTest { // exists for the selected move option, confirm is enabled — the // dialog isn't stuck disabled forever. render( - plan = DeletePlan(itemCount = 1, productCount = 3, contentCount = 1, hasOtherTargets = true), + plan = DeletePlan(itemCount = 1, productCount = 3, contentCount = 1), options = locationStrategyOptions(), targets = listOf(MoveTarget(id = 99L, name = "Pantry")), ) @@ -111,7 +111,7 @@ class DeleteStrategyDialogTest { var confirmCount = 0 render( - plan = DeletePlan(itemCount = 2, productCount = 0, contentCount = 0, hasOtherTargets = true), + plan = DeletePlan(itemCount = 2, productCount = 0, contentCount = 0), options = locationStrategyOptions(), targets = listOf(MoveTarget(id = 99L, name = "Pantry")), onConfirm = { strategy, target -> diff --git a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeletePlan.kt b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeletePlan.kt index 45dd70d..455a2dd 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeletePlan.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeletePlan.kt @@ -25,12 +25,13 @@ data class DeletePlan( * `shelf_count` for locations. Do not "simplify" this to productCount. */ val contentCount: Int, - /** Is there anywhere else to put the contents? False when this is the only shelf/location. */ - val hasOtherTargets: Boolean, ) { /** An empty container is safe to delete outright — a plain confirm will do. */ val needsStrategy: Boolean get() = contentCount > 0 - - /** Never offer "move" when there is nothing to move to. */ - val canMove: Boolean get() = hasOtherTargets } + +// Deliberately no `canMove`/`hasOtherTargets` here. Whether "move the contents +// elsewhere" can be offered is decided from the target list the caller actually +// hands the dialog (`targets.isNotEmpty()`), not from a boolean passed alongside +// it. One signal, and it is the one the user can see: a plan claiming canMove +// while the rendered target list is empty would offer a move with nowhere to go. diff --git a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt index d1f4d60..6d8bf7c 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt @@ -68,7 +68,7 @@ fun DeleteStrategyDialog( onConfirm: (T?, Long?) -> Unit, ) { // Never offer a MOVE_* option when there is nowhere to move to — the caller - // may pass canMove-independent options, so this is enforced here too. + // may pass an option list that includes a move, so this is enforced here. val availableOptions = options.filter { !it.requiresTarget || targets.isNotEmpty() } // Options are supplied safest-first (move > unsort > delete), so the first diff --git a/app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt b/app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt index 151f1d2..94f6fb8 100644 --- a/app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt @@ -8,7 +8,7 @@ import org.junit.Test class DeletePlanTest { @Test fun an_empty_selection_needs_no_strategy() { - val plan = DeletePlan(itemCount = 2, productCount = 0, contentCount = 0, hasOtherTargets = true) + val plan = DeletePlan(itemCount = 2, productCount = 0, contentCount = 0) // Nothing to rescue — a plain confirm is enough. assertFalse(plan.needsStrategy) @@ -17,7 +17,7 @@ class DeletePlanTest { @Test fun a_shelf_holding_products_needs_a_strategy() { // For a SHELF, contentCount is its product_count. - val plan = DeletePlan(itemCount = 1, productCount = 12, contentCount = 12, hasOtherTargets = true) + val plan = DeletePlan(itemCount = 1, productCount = 12, contentCount = 12) assertTrue(plan.needsStrategy) } @@ -28,25 +28,13 @@ class DeletePlanTest { // SHELVES, not when it has products — so a location with one empty shelf // (0 products, 1 shelf) must still prompt. Deciding this from productCount // would send a strategy-less delete and 422 every single time. - val plan = DeletePlan(itemCount = 1, productCount = 0, contentCount = 1, hasOtherTargets = true) + val plan = DeletePlan(itemCount = 1, productCount = 0, contentCount = 1) assertTrue(plan.needsStrategy) } - @Test - fun move_is_unavailable_when_there_is_nowhere_to_move_to() { - // Deleting the household's only location: there is no other location to - // take the contents, so the UI must not offer "move" as a dead option. - val plan = DeletePlan(itemCount = 1, productCount = 12, contentCount = 12, hasOtherTargets = false) - - assertTrue(plan.needsStrategy) - assertFalse(plan.canMove) - } - - @Test - fun move_is_available_when_a_target_exists() { - val plan = DeletePlan(itemCount = 1, productCount = 12, contentCount = 12, hasOtherTargets = true) - - assertTrue(plan.canMove) - } + // "Never offer move when there is nowhere to move to" is NOT tested here: + // DeletePlan no longer carries that signal. The dialog decides it from the + // target list it is actually handed, so the rule is pinned where it lives — + // DeleteStrategyDialogTest.move_option_is_not_offered_when_there_are_no_targets. } diff --git a/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md b/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md index 56aa925..8f43f25 100644 --- a/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md +++ b/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md @@ -637,6 +637,10 @@ top would leave the list disagreeing with the actual fridge." ## Task 3: The delete-strategy dialog +> **AMENDED DURING EXECUTION — the code in this section is stale. `ui/hierarchy/` as shipped is authoritative.** +> 1. `DeleteStrategyDialog` is **generic in the strategy type** — `fun DeleteStrategyDialog(plan, options: List>, targets: List, onDismiss, onConfirm: (T?, Long?) -> Unit)` — not hardcoded to `ShelfDeleteStrategy`. It has to serve Task 5's locations too, whose strategies are `LocationDeleteStrategy`. +> 2. `DeletePlan` gained `contentCount` (`product_count` for a shelf, `shelf_count` for a location — the server asks about a location's SHELVES even when they are empty) and **lost `hasOtherTargets`/`canMove`**: whether "move" is offered is decided from the target list actually handed to the dialog, so there is one signal instead of two that can disagree. + The user-facing half of the safety fix. Built and tested before it is wired to anything. **Files:** @@ -1263,17 +1267,28 @@ class ShelvesViewModel val selected = state.shelves.filter { it.id in state.selected } if (selected.isEmpty()) return - // productCount comes from the shelves' own product counts once the API - // exposes them; until then the dialog asks whenever anything is selected - // and the server is the backstop (it 422s a strategy-less delete). + // For a SHELF, what the server counts as "has contents" IS its product + // count — so contentCount and productCount coincide here. They do NOT + // coincide for a location (Task 5): there, contentCount is shelf_count. + val products = selected.sumOf { s -> productCountFor(s.id) } + _state.update { it.copy( pendingDelete = DeletePlan( itemCount = selected.size, - productCount = selected.sumOf { s -> productCountFor(s.id) }, - hasOtherTargets = state.shelves.any { s -> s.id !in state.selected && !s.is_system }, + productCount = products, + contentCount = products, ), + // The shelves the products could move TO: live, non-system, and + // not themselves being deleted. This list is the ONLY signal for + // whether "move" is offered — DeletePlan carries no canMove flag + // (see DeletePlan.kt). Empty list => the dialog hides the move + // option, so a move is never offered with nowhere to go. + moveTargets = + state.shelves + .filter { s -> s.id !in state.selected && !s.is_system } + .map { s -> MoveTarget(id = s.id, name = s.name) }, ) } } @@ -1403,7 +1418,11 @@ to keep." - Modify: `ui/storage/StorageOverviewViewModel.kt`, `ui/storage/StorageOverviewScreen.kt` - Test: `app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt` (extend) -**Interfaces:** mirrors Task 4 exactly — `enterEditMode()`, `exitEditMode()`, `toggleSelection(id)`, `moveUp/moveDown(id)`, `rename(id, name, type)`, `requestDelete()`, `confirmDelete(strategy, targetId)`, `undoDelete()`. The strategy enum is `LocationDeleteStrategy`, and **`canMove` is false when the household has only one location** — the dialog then offers only delete-or-cancel. +**Interfaces:** mirrors Task 4 exactly — `enterEditMode()`, `exitEditMode()`, `toggleSelection(id)`, `moveUp/moveDown(id)`, `rename(id, name, type)`, `requestDelete()`, `confirmDelete(strategy, targetId)`, `undoDelete()`. The strategy enum is `LocationDeleteStrategy` (`MOVE_CONTENTS` / `DELETE_CONTENTS` — there is deliberately **no unsort** at the location level). + +Two things differ from Task 4 and are easy to get wrong: +- **`contentCount` is `shelf_count`, NOT `product_count`.** The server requires a strategy when a location has SHELVES, even empty ones. Pass `productCount` for the summary line and `shelf_count` as `contentCount`; conflating them 422s every delete of a location whose shelves happen to be empty. +- **`moveTargets` is the household's other live locations.** When the household has only one location that list is empty, and the dialog therefore offers only delete-or-cancel. `DeletePlan` carries no `canMove` flag — the rendered target list is the single signal. - [ ] **Step 1: Write the failing tests** @@ -1414,7 +1433,9 @@ Append to `StorageOverviewViewModelTest.kt`. The key one: fun deleting_the_only_location_offers_no_move_target() = runTest { // There is nowhere to move the contents to, so the dialog must not - // dangle a "move" option that cannot work. + // dangle a "move" option that cannot work. The empty target list IS + // the mechanism (DeleteStrategyDialog hides a target-requiring option + // when handed no targets) — there is no canMove flag to assert on. val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Fridge", "fridge", 0)) } val viewModel = StorageOverviewViewModel(repo, FakeHierarchyStore(), FakeRestoreRepository()) viewModel.load(householdId = 1) @@ -1423,7 +1444,27 @@ Append to `StorageOverviewViewModelTest.kt`. The key one: viewModel.toggleSelection(1L) viewModel.requestDelete() - assertFalse(viewModel.state.value.pendingDelete!!.canMove) + assertTrue(viewModel.state.value.moveTargets.isEmpty()) + } + + @Test + fun deleting_a_location_whose_shelves_are_EMPTY_still_needs_a_strategy() = + runTest { + // The trap: the server asks about a location's SHELVES, not its products. + // A location with 3 empty shelves has productCount 0 but shelf_count 3. + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Fridge", "fridge", shelfCount = 3, productCount = 0)) + items.add(LocationDto(2, "Pantry", "pantry", shelfCount = 0, productCount = 0)) + } + val viewModel = StorageOverviewViewModel(repo, FakeHierarchyStore(), FakeRestoreRepository()) + viewModel.load(householdId = 1) + + viewModel.enterEditMode() + viewModel.toggleSelection(1L) + viewModel.requestDelete() + + assertTrue(viewModel.state.value.pendingDelete!!.needsStrategy) } @Test @@ -1747,7 +1788,7 @@ Also drops the superseded strategy-less delete methods." 2. **Stars on products are backend-only.** Backend T9 ships `is_starred`; no task here renders it. The spec calls for a star marker and a "starred only" filter on the products list. That is a small, self-contained addition to `ProductsPane` + `ProductFilterSortRow` and should be **Task 11** if you want it in this pass. -**Type consistency.** `ShelfDeleteStrategy` / `LocationDeleteStrategy` (T1) are the exact types taken by `confirmDelete` (T4, T5) and `DeleteStrategyDialog.onConfirm` (T3). `DeletePlan(itemCount, productCount, hasOtherTargets)` (T3) is constructed with those three named args in T4 and T5. `orderByPosition(items, position, name)` (T2) is used in T4, T5 and T8. +**Type consistency.** `ShelfDeleteStrategy` / `LocationDeleteStrategy` (T1) are the exact types taken by `confirmDelete` (T4, T5) and `DeleteStrategyDialog.onConfirm` (T3). `DeletePlan(itemCount, productCount, contentCount)` (T3) is constructed with those three named args in T4 and T5 — `contentCount` is `product_count` for a shelf and `shelf_count` for a location. `DeletePlan` has NO `canMove`/`hasOtherTargets`: move-availability comes from the `List` the caller hands `DeleteStrategyDialog`. `orderByPosition(items, position, name)` (T2) is used in T4, T5 and T8. **Known hazard.** Task 4 removes `deleteMode` / `selectedShelves` / `deleteSelected()` from `ShelvesViewModel`, which `ShelvesViewModelTest` and `LocationDetailScreen` both use today. Both are updated inside that task — but if you run tasks out of order, the build breaks there first. From 26f0d9c0fc7ea086d2fd1cf1bd2e9586ebe4cbb7 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 20:58:28 +0200 Subject: [PATCH 09/40] feat: shelf-view preference store, wired into session cleanup Global (not per-location) tabs/list preference for the shelves screen, backed by SharedPreferences like the other settings stores. Registered in Hilt and wired into SessionCleaner.clear() so one account's view choice never survives into the next session. --- .../inventory/data/auth/SessionCleaner.kt | 7 ++++++ .../settings/SharedPrefsShelfViewStore.kt | 23 +++++++++++++++++++ .../inventory/data/settings/ShelfViewStore.kt | 11 +++++++++ .../dev/scuttle/inventory/di/StorageModule.kt | 8 +++++++ .../scuttle/inventory/SessionCleanerTest.kt | 17 +++++++++++++- 5 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/dev/scuttle/inventory/data/settings/SharedPrefsShelfViewStore.kt create mode 100644 app/src/main/java/dev/scuttle/inventory/data/settings/ShelfViewStore.kt diff --git a/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt b/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt index c16235f..30886a4 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt @@ -6,6 +6,7 @@ import dev.scuttle.inventory.data.location.LocationRepository import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.settings.DefaultHouseholdStore import dev.scuttle.inventory.data.settings.FavoritesStore +import dev.scuttle.inventory.data.settings.ShelfViewStore import dev.scuttle.inventory.data.shelf.ShelfRepository import javax.inject.Inject import javax.inject.Singleton @@ -21,6 +22,10 @@ import javax.inject.Singleton * always-online, no-local-DB rule. */ @Singleton +// One constructor parameter per thing to clear is this class's entire job — it is +// a pure fan-out, not a design smell to split up; splitting it would need an +// artificial "clearable" abstraction (and Hilt multibinding) for no real benefit. +@Suppress("LongParameterList") class SessionCleaner @Inject constructor( @@ -31,6 +36,7 @@ class SessionCleaner private val hierarchyStore: HierarchyStore, private val favoritesStore: FavoritesStore, private val defaultHouseholdStore: DefaultHouseholdStore, + private val shelfViewStore: ShelfViewStore, ) { fun clear() { householdRepository.clear() @@ -40,5 +46,6 @@ class SessionCleaner hierarchyStore.clear() favoritesStore.clear() defaultHouseholdStore.clear() + shelfViewStore.clear() } } diff --git a/app/src/main/java/dev/scuttle/inventory/data/settings/SharedPrefsShelfViewStore.kt b/app/src/main/java/dev/scuttle/inventory/data/settings/SharedPrefsShelfViewStore.kt new file mode 100644 index 0000000..bd7ab55 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/data/settings/SharedPrefsShelfViewStore.kt @@ -0,0 +1,23 @@ +package dev.scuttle.inventory.data.settings + +import android.content.Context + +class SharedPrefsShelfViewStore( + context: Context, +) : ShelfViewStore { + private val prefs = context.getSharedPreferences("inventory_settings", Context.MODE_PRIVATE) + + override fun isListView(): Boolean = prefs.getBoolean(KEY_LIST_VIEW, false) + + override fun setListView(listView: Boolean) { + prefs.edit().putBoolean(KEY_LIST_VIEW, listView).apply() + } + + override fun clear() { + prefs.edit().remove(KEY_LIST_VIEW).apply() + } + + private companion object { + const val KEY_LIST_VIEW = "shelf_list_view" + } +} diff --git a/app/src/main/java/dev/scuttle/inventory/data/settings/ShelfViewStore.kt b/app/src/main/java/dev/scuttle/inventory/data/settings/ShelfViewStore.kt new file mode 100644 index 0000000..65cd337 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/data/settings/ShelfViewStore.kt @@ -0,0 +1,11 @@ +package dev.scuttle.inventory.data.settings + +/** Whether the shelves screen shows tabs+products or a plain shelf list. Global, not per-location. */ +interface ShelfViewStore { + fun isListView(): Boolean + + fun setListView(listView: Boolean) + + /** Forget the preference so one account's choice never carries into the next session. */ + fun clear() {} +} diff --git a/app/src/main/java/dev/scuttle/inventory/di/StorageModule.kt b/app/src/main/java/dev/scuttle/inventory/di/StorageModule.kt index 3259567..5837f55 100644 --- a/app/src/main/java/dev/scuttle/inventory/di/StorageModule.kt +++ b/app/src/main/java/dev/scuttle/inventory/di/StorageModule.kt @@ -13,7 +13,9 @@ import dev.scuttle.inventory.data.settings.LanguageStore import dev.scuttle.inventory.data.settings.SharedPrefsDefaultHouseholdStore import dev.scuttle.inventory.data.settings.SharedPrefsFavoritesStore import dev.scuttle.inventory.data.settings.SharedPrefsLanguageStore +import dev.scuttle.inventory.data.settings.SharedPrefsShelfViewStore import dev.scuttle.inventory.data.settings.SharedPrefsThemeModeStore +import dev.scuttle.inventory.data.settings.ShelfViewStore import dev.scuttle.inventory.data.settings.ThemeModeStore import dev.scuttle.inventory.data.storage.EncryptedTokenStore import dev.scuttle.inventory.data.storage.TokenStore @@ -52,6 +54,12 @@ object StorageModule { @ApplicationContext context: Context, ): LanguageStore = SharedPrefsLanguageStore(context) + @Provides + @Singleton + fun provideShelfViewStore( + @ApplicationContext context: Context, + ): ShelfViewStore = SharedPrefsShelfViewStore(context) + @Provides @Singleton fun provideContentResolver( diff --git a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt index 6f3e7b1..3ae190c 100644 --- a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt @@ -13,6 +13,7 @@ import dev.scuttle.inventory.data.product.ProductEdit import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.settings.DefaultHouseholdStore import dev.scuttle.inventory.data.settings.FavoritesStore +import dev.scuttle.inventory.data.settings.ShelfViewStore import dev.scuttle.inventory.data.shelf.ShelfRepository import org.junit.Assert.assertTrue import org.junit.Test @@ -189,6 +190,18 @@ class SessionCleanerTest { } } + private class RecordingShelfViewStore : ShelfViewStore { + var cleared = false + + override fun isListView() = false + + override fun setListView(listView: Boolean) = Unit + + override fun clear() { + cleared = true + } + } + @Test fun clear_fans_out_to_every_cache_and_store() { // X1: session end must wipe ALL per-account singleton state — miss one and @@ -199,9 +212,10 @@ class SessionCleanerTest { val product = RecordingProductRepo() val favorites = RecordingFavoritesStore() val defaultHousehold = RecordingDefaultHouseholdStore() + val shelfView = RecordingShelfViewStore() val store = HierarchyStore(household, location, shelf, product) - SessionCleaner(household, location, shelf, product, store, favorites, defaultHousehold).clear() + SessionCleaner(household, location, shelf, product, store, favorites, defaultHousehold, shelfView).clear() assertTrue(household.cleared) assertTrue(location.cleared) @@ -209,5 +223,6 @@ class SessionCleanerTest { assertTrue(product.cleared) assertTrue(favorites.cleared) assertTrue(defaultHousehold.cleared) + assertTrue(shelfView.cleared) } } From c8990654f07cd62d38ff253ac932ae8f98003e42 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 20:58:42 +0200 Subject: [PATCH 10/40] =?UTF-8?q?feat:=20edit=20mode=20on=20ShelvesViewMod?= =?UTF-8?q?el=20=E2=80=94=20select,=20rename,=20reorder,=20delete=20via=20?= =?UTF-8?q?strategy=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces deleteMode/selectedShelves/deleteSelected/enterDeleteMode/exitDeleteMode (the tester bug: bulk delete with NO confirmation dialog at all) with an edit mode that only ever deletes through requestDelete() -> DeleteStrategyDialog -> confirmDelete() — the top bar's Delete action can never bypass the dialog. Key invariants, each covered by a discriminating test (verified red/green by temporarily reintroducing the bug and reverting): - One user gesture mints exactly ONE deletion_batch_id, shared by every shelf deleted in that gesture, so a single Undo restores all of them. - The Unsorted shelf (is_system) can never be selected or renamed. - Display order always comes from orderByPosition (Task 2), with the system shelf forced last regardless of its own position value; reordering sends the complete non-system id list, excluding the system shelf from the payload. - requestDelete() only opens the dialog (sets pendingDelete + moveTargets); nothing is deleted until an explicit confirmDelete(). Entering edit mode forces the list view (tabs can't host reorder/rename), and remembers the prior view to restore on exit. Added ShelvesUiState.moveTargets: the live, non-system, non-selected shelves a delete's products could move to — this list, not a boolean, is what DeletePlan.kt says should gate the dialog's move option (its own doc: no canMove/hasOtherTargets by design). A shelf's DeletePlan.contentCount is its productCount (contentCount only differs for a location's shelf_count, in Task 5). --- .../inventory/ui/shelves/ShelvesViewModel.kt | 240 +++++++- .../scuttle/inventory/ShelvesViewModelTest.kt | 528 ++++++++++++++++-- 2 files changed, 700 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt index 71f85ec..290ab51 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt @@ -3,15 +3,24 @@ package dev.scuttle.inventory.ui.shelves import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dev.scuttle.inventory.data.HierarchyStore import dev.scuttle.inventory.data.dto.ShelfDto import dev.scuttle.inventory.data.error.toUserMessage +import dev.scuttle.inventory.data.hierarchy.RestoreRepository +import dev.scuttle.inventory.data.hierarchy.ShelfDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.ShelfDeletion +import dev.scuttle.inventory.data.settings.ShelfViewStore import dev.scuttle.inventory.data.shelf.ShelfRepository +import dev.scuttle.inventory.ui.common.orderByPosition +import dev.scuttle.inventory.ui.hierarchy.DeletePlan +import dev.scuttle.inventory.ui.hierarchy.MoveTarget import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.UUID import javax.inject.Inject data class ShelvesUiState( @@ -22,22 +31,45 @@ data class ShelvesUiState( val shelves: List = emptyList(), val newName: String = "", val error: String? = null, - val deleteMode: Boolean = false, - val selectedShelves: Set = emptySet(), + val editMode: Boolean = false, + val selected: Set = emptySet(), + val listView: Boolean = false, + /** Non-null while the delete-strategy dialog is open. */ + val pendingDelete: DeletePlan? = null, + /** + * The live, non-system shelves the current selection's products could move to + * — computed fresh by [ShelvesViewModel.requestDelete]. This is the ONLY signal + * for whether the dialog offers "move" (see DeletePlan.kt: DeletePlan itself + * carries no canMove flag). Empty means no move option is shown. + */ + val moveTargets: List = emptyList(), + /** The batch just deleted, for the Undo snackbar. Cleared once consumed. */ + val lastBatchId: String? = null, ) @HiltViewModel +// The method count is the required surface for this screen (edit mode, reorder, +// rename, strategy-gated delete, undo, view toggle) — Task 5 mirrors this same +// shape for locations, so splitting it up would fight the spec rather than the +// class's actual cohesion (every method operates on the one ShelvesUiState). +@Suppress("TooManyFunctions") class ShelvesViewModel @Inject constructor( private val repository: ShelfRepository, + private val restoreRepository: RestoreRepository, + private val shelfViewStore: ShelfViewStore, + private val hierarchyStore: HierarchyStore, ) : ViewModel() { private var householdId: Long? = null private var locationId: Long? = null - private val _state = MutableStateFlow(ShelvesUiState()) + private val _state = MutableStateFlow(ShelvesUiState(listView = shelfViewStore.isListView())) val state: StateFlow = _state.asStateFlow() + /** Remembered so exiting edit mode restores the view the user actually chose. */ + private var viewBeforeEdit: Boolean = false + fun load( householdId: Long, locationId: Long, @@ -51,7 +83,7 @@ class ShelvesViewModel } val cached = repository.getCached(householdId, locationId) if (cached != null) { - _state.update { it.copy(shelves = cached) } + _state.update { it.copy(shelves = orderShelves(cached)) } refreshSilent() } else { _state.update { it.copy(shelves = emptyList()) } @@ -66,7 +98,7 @@ class ShelvesViewModel val l = locationId ?: return launchLoading(refreshing = true) { val shelves = repository.list(h, l) - _state.update { it.copy(shelves = shelves) } + _state.update { it.copy(shelves = orderShelves(shelves)) } } } @@ -77,51 +109,203 @@ class ShelvesViewModel if (name.isEmpty()) return launchLoading { val created = repository.create(h, l, name) - _state.update { it.copy(newName = "", shelves = it.shelves + created) } + _state.update { it.copy(newName = "", shelves = orderShelves(it.shelves + created)) } } } - fun enterDeleteMode() = _state.update { it.copy(deleteMode = true, selectedShelves = emptySet()) } + fun toggleListView() { + val next = !_state.value.listView + shelfViewStore.setListView(next) + _state.update { it.copy(listView = next) } + } + + fun enterEditMode() { + // Tabs cannot host reorder buttons or an inline rename target, so edit + // mode always runs in the list view — and restores the user's choice + // on the way out. + viewBeforeEdit = _state.value.listView + _state.update { it.copy(editMode = true, listView = true, selected = emptySet()) } + } - fun exitDeleteMode() = _state.update { it.copy(deleteMode = false, selectedShelves = emptySet()) } + fun exitEditMode() = + _state.update { + it.copy( + editMode = false, + listView = viewBeforeEdit, + selected = emptySet(), + pendingDelete = null, + moveTargets = emptyList(), + ) + } - fun toggleShelfSelection(shelfId: Long) = + fun toggleSelection(shelfId: Long) = _state.update { state -> - val updated = - if (shelfId in state.selectedShelves) { - state.selectedShelves - shelfId - } else { - state.selectedShelves + shelfId - } - state.copy(selectedShelves = updated) + // The Unsorted shelf holds the products the user chose to KEEP. + // A stray checkbox tap must not be able to destroy it. + val shelf = state.shelves.firstOrNull { it.id == shelfId } + if (shelf == null || shelf.is_system) { + state + } else { + val updated = + if (shelfId in state.selected) state.selected - shelfId else state.selected + shelfId + state.copy(selected = updated) + } } - fun deleteSelected() { - val h = householdId ?: return - val l = locationId ?: return - val ids = _state.value.selectedShelves.toList() + fun rename( + shelfId: Long, + name: String, + ) { + val h = householdId + val l = locationId + if (h == null || l == null) return + val shelf = _state.value.shelves.firstOrNull { it.id == shelfId } + val trimmed = name.trim() + // The Unsorted shelf's name is server-owned; the client only localises + // its displayed label, so it can never be renamed from here. + if (shelf == null || shelf.is_system || trimmed.isEmpty()) return + launchLoading { + val updated = repository.rename(h, l, shelfId, trimmed) + _state.update { s -> + s.copy(shelves = orderShelves(s.shelves.map { if (it.id == shelfId) updated else it })) + } + hierarchyStore.refresh() + } + } + + fun moveUp(shelfId: Long) = move(shelfId, -1) + + fun moveDown(shelfId: Long) = move(shelfId, +1) + + private fun move( + shelfId: Long, + delta: Int, + ) { + val h = householdId + val l = locationId + if (h == null || l == null) return + // The system shelf is never part of the manual drag order and never + // travels in the reorder payload — see reorder() below. + val current = _state.value.shelves.filterNot { it.is_system } + val systemShelves = _state.value.shelves.filter { it.is_system } + val index = current.indexOfFirst { it.id == shelfId } + val target = index + delta + if (index < 0 || target !in current.indices) return + + val reordered = current.toMutableList().apply { add(target, removeAt(index)) } + + // Optimistic: the row visibly moves on tap. The server call rewrites + // every position in one transaction, so a failure snaps the whole list + // back rather than leaving it half-sorted. + _state.update { it.copy(shelves = reordered + systemShelves) } + + launchLoading { + // The COMPLETE ordered id list, system shelf excluded — a partial + // list produces duplicate positions server-side, and the server + // does not want the system shelf in the payload at all. + val server = repository.reorder(h, l, reordered.map { it.id }) + _state.update { it.copy(shelves = orderShelves(server + systemShelves)) } + hierarchyStore.refresh() + } + } + + /** Open the strategy dialog for the current selection. */ + fun requestDelete() { + val state = _state.value + val selected = state.shelves.filter { it.id in state.selected } + if (selected.isEmpty()) return + + // For a SHELF, what the server counts as "has contents" IS its product + // count — so contentCount and productCount coincide here. They do NOT + // coincide for a location (Task 5): there, contentCount is shelf_count. + val products = selected.sumOf { it.product_count } + + _state.update { + it.copy( + pendingDelete = + DeletePlan( + itemCount = selected.size, + productCount = products, + contentCount = products, + ), + // The shelves the products could move TO: live, non-system, and + // not themselves being deleted. This list is the ONLY signal for + // whether "move" is offered — DeletePlan carries no canMove flag + // (see DeletePlan.kt). Empty list => the dialog hides the move + // option, so a move is never offered with nowhere to go. + moveTargets = + state.shelves + .filter { s -> s.id !in state.selected && !s.is_system } + .map { s -> MoveTarget(id = s.id, name = s.name) }, + ) + } + } + + fun confirmDelete( + strategy: ShelfDeleteStrategy?, + targetId: Long?, + ) { + val h = householdId + val l = locationId + if (h == null || l == null) return + val ids = _state.value.selected.toList() if (ids.isEmpty()) return + + // ONE batch id for the whole gesture. Deleting three shelves is three + // requests; if each minted its own id they would land in three batches + // and Undo would restore only one of them. + val batchId = UUID.randomUUID().toString() + launchLoading { - ids.forEach { id -> repository.delete(h, l, id) } + ids.forEach { id -> + repository.deleteWithStrategy(h, l, id, ShelfDeletion(batchId, strategy, targetId)) + } _state.update { it.copy( - deleteMode = false, - selectedShelves = emptySet(), - shelves = - it.shelves.filter { s -> - s.id !in ids - }, + editMode = false, + selected = emptySet(), + pendingDelete = null, + moveTargets = emptyList(), + lastBatchId = batchId, + shelves = it.shelves.filter { s -> s.id !in ids }, ) } + hierarchyStore.refresh() } } + fun cancelDelete() = _state.update { it.copy(pendingDelete = null, moveTargets = emptyList()) } + + fun undoDelete() { + val h = householdId ?: return + val batchId = _state.value.lastBatchId ?: return + launchLoading { + restoreRepository.restore(h, batchId) + _state.update { it.copy(lastBatchId = null) } + refresh() + hierarchyStore.refresh() + } + } + + fun consumeLastBatch() = _state.update { it.copy(lastBatchId = null) } + + /** + * The one hierarchy ordering rule (orderByPosition) plus the system-shelf + * exception: Unsorted always renders last, regardless of its own position, + * because it is never part of the manual reorder the user controls. + */ + private fun orderShelves(shelves: List): List { + val (system, normal) = shelves.partition { it.is_system } + return orderByPosition(normal, { it.position }, { it.name }) + + orderByPosition(system, { it.position }, { it.name }) + } + private fun refreshSilent() { val h = householdId ?: return val l = locationId ?: return viewModelScope.launch { runCatching { repository.list(h, l) } - .onSuccess { shelves -> _state.update { it.copy(shelves = shelves) } } + .onSuccess { shelves -> _state.update { it.copy(shelves = orderShelves(shelves)) } } } } diff --git a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt index a77d644..c7620c7 100644 --- a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt @@ -1,11 +1,19 @@ package dev.scuttle.inventory +import dev.scuttle.inventory.data.dto.HouseholdDto import dev.scuttle.inventory.data.dto.ShelfDto +import dev.scuttle.inventory.data.hierarchy.RestoreRepository +import dev.scuttle.inventory.data.hierarchy.ShelfDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.ShelfDeletion +import dev.scuttle.inventory.data.household.HouseholdRepository +import dev.scuttle.inventory.data.settings.ShelfViewStore import dev.scuttle.inventory.data.shelf.ShelfRepository import dev.scuttle.inventory.ui.shelves.ShelvesViewModel import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -18,6 +26,15 @@ class ShelvesViewModelTest { val items = mutableListOf() var failList = false + // Recorded for assertions below — the real ShelfRepository interface bundles + // batchId/strategy/targetShelfId into one ShelfDeletion (data/hierarchy/DeleteStrategy.kt), + // so each call's deletion is captured whole and unpacked into these lists. + val batchIdsUsed = mutableListOf() + val strategiesUsed = mutableListOf() + val targetIdsUsed = mutableListOf() + var lastRenamedId: Long? = null + var lastReorderIds: List? = null + override fun getCached( householdId: Long, locationId: Long, @@ -49,20 +66,110 @@ class ShelvesViewModelTest { ) { items.removeIf { it.id == shelfId } } + + override suspend fun rename( + householdId: Long, + locationId: Long, + shelfId: Long, + name: String, + ): ShelfDto { + lastRenamedId = shelfId + val index = items.indexOfFirst { it.id == shelfId } + val updated = items[index].copy(name = name) + items[index] = updated + return updated + } + + override suspend fun reorder( + householdId: Long, + locationId: Long, + ids: List, + ): List { + lastReorderIds = ids + // Only positions of the given ids change — anything left out (the system + // shelf) stays exactly where it was, same as the real server endpoint. + val reordered = ids.mapIndexedNotNull { i, id -> items.find { it.id == id }?.copy(position = i) } + reordered.forEach { updated -> + val index = items.indexOfFirst { it.id == updated.id } + items[index] = updated + } + return reordered + } + + override suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + shelfId: Long, + deletion: ShelfDeletion, + ) { + batchIdsUsed += deletion.batchId + strategiesUsed += deletion.strategy + targetIdsUsed += deletion.targetShelfId + items.removeIf { it.id == shelfId } + } + } + + private class FakeRestoreRepository : RestoreRepository { + var lastHouseholdId: Long? = null + var lastBatchId: String? = null + var restoreCalls = 0 + + override suspend fun restore( + householdId: Long, + batchId: String, + ): Int { + lastHouseholdId = householdId + lastBatchId = batchId + restoreCalls++ + return 1 + } + } + + private class FakeShelfViewStore( + initial: Boolean = false, + ) : ShelfViewStore { + var stored = initial + + override fun isListView(): Boolean = stored + + override fun setListView(listView: Boolean) { + stored = listView + } + } + + private class FakeHouseholdRepository : HouseholdRepository { + override fun getCached(): List? = null + + override suspend fun list(): List = emptyList() + + override suspend fun create(name: String) = throw NotImplementedError() + + override suspend fun join(code: String) = throw NotImplementedError() + + override suspend fun leave(householdId: Long) = Unit + } + + private fun viewModel( + repo: FakeShelfRepository = FakeShelfRepository(), + restoreRepository: RestoreRepository = FakeRestoreRepository(), + shelfViewStore: ShelfViewStore = FakeShelfViewStore(), + ): ShelvesViewModel { + val hierarchyStore = TestHierarchy.store(FakeHouseholdRepository()) + return ShelvesViewModel(repo, restoreRepository, shelfViewStore, hierarchyStore) } @Test fun load_populates_shelves() = runTest { val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } - val viewModel = ShelvesViewModel(repo) + val vm = viewModel(repo) - viewModel.load(householdId = 1, locationId = 1) + vm.load(householdId = 1, locationId = 1) - assertEquals(1, viewModel.state.value.shelves.size) + assertEquals(1, vm.state.value.shelves.size) assertEquals( "Top", - viewModel.state.value.shelves + vm.state.value.shelves .first() .name, ) @@ -71,81 +178,422 @@ class ShelvesViewModelTest { @Test fun create_adds_a_shelf_and_clears_the_field() = runTest { - val viewModel = ShelvesViewModel(FakeShelfRepository()) - viewModel.load(householdId = 1, locationId = 1) - viewModel.onNewNameChange("Middle") + val vm = viewModel() + vm.load(householdId = 1, locationId = 1) + vm.onNewNameChange("Middle") - viewModel.create() + vm.create() assertTrue( - viewModel.state.value.shelves + vm.state.value.shelves .any { it.name == "Middle" }, ) - assertEquals("", viewModel.state.value.newName) + assertEquals("", vm.state.value.newName) } @Test fun list_failure_surfaces_an_error() = runTest { val repo = FakeShelfRepository().apply { failList = true } - val viewModel = ShelvesViewModel(repo) + val vm = viewModel(repo) + + vm.load(householdId = 1, locationId = 1) + + assertEquals("offline", vm.state.value.error) + } + + @Test + fun shelves_are_displayed_in_position_order_not_repository_order() = + runTest { + // Discriminates against a VM that just passes the repository's list + // through untouched instead of applying orderByPosition (Task 2). + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Bottom", 2, 1L)) + items.add(ShelfDto(2, "Top", 0, 1L)) + items.add(ShelfDto(3, "Middle", 1, 1L)) + } + val vm = viewModel(repo) + + vm.load(householdId = 1, locationId = 1) + + assertEquals( + listOf("Top", "Middle", "Bottom"), + vm.state.value.shelves + .map { it.name }, + ) + } + + @Test + fun the_unsorted_shelf_always_sorts_last_regardless_of_its_position() = + runTest { + // The system shelf is never part of the manual drag order, so a low + // position value here must NOT float it above the user's own shelves. + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Unsorted", 0, 1L, is_system = true)) + items.add(ShelfDto(2, "Bottom", 2, 1L)) + items.add(ShelfDto(3, "Top", 1, 1L)) + } + val vm = viewModel(repo) - viewModel.load(householdId = 1, locationId = 1) + vm.load(householdId = 1, locationId = 1) - assertEquals("offline", viewModel.state.value.error) + assertEquals( + listOf("Top", "Bottom", "Unsorted"), + vm.state.value.shelves + .map { it.name }, + ) } @Test - fun enter_delete_mode_toggles_flag_and_clears_selection() = + fun move_up_swaps_a_shelf_with_the_one_above_it() = runTest { val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) - items.add(ShelfDto(2, "Bottom", 1, 1L)) + items.add(ShelfDto(2, "Middle", 1, 1L)) + items.add(ShelfDto(3, "Bottom", 2, 1L)) } - val viewModel = ShelvesViewModel(repo) - viewModel.load(householdId = 1, locationId = 1) + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) - viewModel.enterDeleteMode() - assertTrue(viewModel.state.value.deleteMode) - assertTrue( - viewModel.state.value.selectedShelves - .isEmpty(), + vm.moveUp(3L) + + assertEquals( + listOf("Top", "Bottom", "Middle"), + vm.state.value.shelves + .map { it.name }, ) + } - viewModel.toggleShelfSelection(1L) - assertTrue(1L in viewModel.state.value.selectedShelves) + @Test + fun move_down_swaps_a_shelf_with_the_one_below_it() = + runTest { + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Middle", 1, 1L)) + items.add(ShelfDto(3, "Bottom", 2, 1L)) + } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) - viewModel.exitDeleteMode() - assertFalse(viewModel.state.value.deleteMode) - assertTrue( - viewModel.state.value.selectedShelves - .isEmpty(), + vm.moveDown(1L) + + assertEquals( + listOf("Middle", "Top", "Bottom"), + vm.state.value.shelves + .map { it.name }, ) } @Test - fun delete_selected_removes_checked_shelves() = + fun move_up_on_the_first_shelf_does_nothing() = runTest { + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + + vm.moveUp(1L) + + assertEquals( + listOf("Top"), + vm.state.value.shelves + .map { it.name }, + ) + } + + @Test + fun reordering_sends_the_complete_id_list_excluding_the_system_shelf() = + runTest { + // A partial list produces duplicate positions server-side, and the + // server does not want the system shelf in the payload at all. val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) - items.add(ShelfDto(2, "Bottom", 1, 1L)) + items.add(ShelfDto(2, "Middle", 1, 1L)) + items.add(ShelfDto(3, "Bottom", 2, 1L)) + items.add(ShelfDto(4, "Unsorted", 3, 1L, is_system = true)) } - val viewModel = ShelvesViewModel(repo) - viewModel.load(householdId = 1, locationId = 1) + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) - viewModel.enterDeleteMode() - viewModel.toggleShelfSelection(1L) - viewModel.deleteSelected() + vm.moveUp(3L) - assertEquals(1, viewModel.state.value.shelves.size) + assertEquals(listOf(1L, 3L, 2L), repo.lastReorderIds) + // The system shelf still renders, still last, untouched by the reorder. assertEquals( - "Bottom", - viewModel.state.value.shelves + listOf("Top", "Bottom", "Middle", "Unsorted"), + vm.state.value.shelves + .map { it.name }, + ) + } + + @Test + fun the_unsorted_shelf_cannot_be_selected_for_deletion() = + runTest { + // It is a system shelf: it holds the products the user chose to KEEP. + // Letting a stray checkbox tap destroy it would defeat the entire point. + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Unsorted", 1, 1L, is_system = true)) + } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + + vm.enterEditMode() + vm.toggleSelection(2L) + + assertTrue( + vm.state.value.selected + .isEmpty(), + ) + } + + @Test + fun the_unsorted_shelf_cannot_be_renamed() = + runTest { + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Unsorted", 0, 1L, is_system = true)) } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + + vm.rename(1L, "My custom name") + + assertEquals( + "Unsorted", + vm.state.value.shelves .first() .name, ) - assertFalse(viewModel.state.value.deleteMode) + assertNull(repo.lastRenamedId) + } + + @Test + fun entering_edit_mode_forces_the_list_view() = + runTest { + // Tabs cannot host reorder buttons or inline rename. The flip is also + // why the manual list/tab toggle exists: by the time the user first + // enters edit mode, the list is somewhere they have already been. + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + assertFalse(vm.state.value.listView) + + vm.enterEditMode() + assertTrue(vm.state.value.listView) + + vm.exitEditMode() + assertFalse(vm.state.value.listView) + } + + @Test + fun exiting_edit_mode_restores_the_view_the_user_had_chosen() = + runTest { + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + vm.toggleListView() + assertTrue(vm.state.value.listView) + + vm.enterEditMode() + vm.exitEditMode() + + assertTrue(vm.state.value.listView) + } + + @Test + fun toggle_list_view_flips_state_and_persists_the_preference() = + runTest { + val store = FakeShelfViewStore(initial = false) + val vm = viewModel(shelfViewStore = store) + vm.load(householdId = 1, locationId = 1) + + vm.toggleListView() + + assertTrue(vm.state.value.listView) + assertTrue(store.stored) + + vm.toggleListView() + + assertFalse(vm.state.value.listView) + assertFalse(store.stored) + } + + @Test + fun request_delete_does_not_delete_anything_until_confirmed() = + runTest { + // The whole point of Task 4: the confirm dialog can never be bypassed. + // Selecting + requesting must NOT mutate the repository or the list — + // only an explicit confirmDelete() may do that. + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L, product_count = 3)) } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + + vm.requestDelete() + + assertEquals(1, vm.state.value.shelves.size) + assertTrue(repo.batchIdsUsed.isEmpty()) + assertNotNull(vm.state.value.pendingDelete) + assertEquals( + 3, + vm.state.value.pendingDelete + ?.productCount, + ) + // Resolution #2: a shelf's contentCount IS its productCount. + assertEquals( + 3, + vm.state.value.pendingDelete + ?.contentCount, + ) + } + + @Test + fun requesting_delete_offers_only_live_non_system_non_selected_shelves_as_move_targets() = + runTest { + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Middle", 1, 1L)) + items.add(ShelfDto(3, "Unsorted", 2, 1L, is_system = true)) + } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + + vm.requestDelete() + + // Not "Top" (being deleted), not "Unsorted" (system) — only "Middle". + assertEquals( + listOf(2L), + vm.state.value.moveTargets + .map { it.id }, + ) + } + + @Test + fun requesting_delete_with_no_selection_does_nothing() = + runTest { + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + + vm.requestDelete() + + assertNull(vm.state.value.pendingDelete) + } + + @Test + fun confirming_a_delete_sends_the_chosen_strategy_and_one_shared_batch_id() = + runTest { + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Bottom", 1, 1L)) + items.add(ShelfDto(3, "Middle", 2, 1L)) + } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + + vm.enterEditMode() + vm.toggleSelection(1L) + vm.toggleSelection(2L) + vm.toggleSelection(3L) + vm.requestDelete() + vm.confirmDelete(ShelfDeleteStrategy.DELETE_PRODUCTS, targetId = null) + + assertEquals(3, repo.batchIdsUsed.size) + assertTrue(repo.strategiesUsed.all { it == ShelfDeleteStrategy.DELETE_PRODUCTS }) + // Three shelves deleted together = three requests carrying ONE batch id, + // so one Undo restores all three. A per-request id would split one + // gesture into three batches and Undo would restore only one. + assertEquals(1, repo.batchIdsUsed.toSet().size) + assertEquals(repo.batchIdsUsed.first(), vm.state.value.lastBatchId) + assertTrue( + vm.state.value.shelves + .isEmpty(), + ) + assertFalse(vm.state.value.editMode) + assertNull(vm.state.value.pendingDelete) + } + + @Test + fun confirming_a_move_delete_sends_the_target_shelf_id() = + runTest { + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Middle", 1, 1L)) + } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + + vm.confirmDelete(ShelfDeleteStrategy.MOVE_PRODUCTS, targetId = 2L) + + assertEquals(listOf(2L), repo.targetIdsUsed) + } + + @Test + fun undo_delete_restores_the_batch_and_clears_it() = + runTest { + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val restore = FakeRestoreRepository() + val vm = viewModel(repo, restoreRepository = restore) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + vm.confirmDelete(ShelfDeleteStrategy.DELETE_PRODUCTS, targetId = null) + val batchId = vm.state.value.lastBatchId + assertNotNull(batchId) + + vm.undoDelete() + + assertEquals(1L, restore.lastHouseholdId) + assertEquals(batchId, restore.lastBatchId) + assertNull(vm.state.value.lastBatchId) + } + + @Test + fun consume_last_batch_clears_it_without_restoring() = + runTest { + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val restore = FakeRestoreRepository() + val vm = viewModel(repo, restoreRepository = restore) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + vm.confirmDelete(ShelfDeleteStrategy.DELETE_PRODUCTS, targetId = null) + + vm.consumeLastBatch() + + assertNull(vm.state.value.lastBatchId) + assertEquals(0, restore.restoreCalls) + } + + @Test + fun cancel_delete_closes_the_dialog_without_deleting_anything() = + runTest { + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + + vm.cancelDelete() + + assertNull(vm.state.value.pendingDelete) + assertEquals(1, vm.state.value.shelves.size) + assertTrue(repo.batchIdsUsed.isEmpty()) } } From 13405eb691b178de32303ce8cdcf08f9ed51af4e Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 20:58:54 +0200 Subject: [PATCH 11/40] =?UTF-8?q?feat:=20edit-mode=20UI=20on=20the=20shelv?= =?UTF-8?q?es=20screen,=20plus=20the=20tabs=E2=87=84list=20view=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocationDetailScreen renders either the tab strip + pager (unchanged browsing behavior) or a plain shelf list (EditableRow per shelf), chosen by the new listView preference and forced on whenever editMode is on. The top bar swaps its delete-mode block for edit mode: a Cancel TextButton, and an error-colored Delete button that calls requestDelete() — never confirmDelete() directly, so the strategy/confirm dialog can never be bypassed. Wires DeleteStrategyDialog (shelfStrategyOptions(), state.moveTargets) and an Undo snackbar keyed on lastBatchId. EditableRow (new) is the row-level gate for the Unsorted shelf: its checkbox, rename pencil, and up/down buttons are all withheld when isSystem is true, regardless of edit mode. (The equivalent "cannot be selected" rule is enforced independently in ShelvesViewModel.toggleSelection — this row just forwards taps, so both layers agree by construction rather than by convention.) Covered by an isolated Compose test (EditableRowTest, createComposeRule, no Activity/Hilt — same pattern as DeleteStrategyDialogTest). New strings (EN+NL): location_edit_shelves_cd, location_view_toggle_cd, shelf_move_up_cd, shelf_move_down_cd, shelf_rename_title, shelves_deleted. Removed the now-dead location_delete_shelves_cd. --- .../inventory/ui/hierarchy/EditableRowTest.kt | 100 ++++++++ .../inventory/ui/hierarchy/EditableRow.kt | 88 +++++++ .../ui/location/LocationDetailScreen.kt | 240 ++++++++++++------ app/src/main/res/values-nl/strings.xml | 7 +- app/src/main/res/values/strings.xml | 7 +- 5 files changed, 369 insertions(+), 73 deletions(-) create mode 100644 app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/EditableRowTest.kt create mode 100644 app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt diff --git a/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/EditableRowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/EditableRowTest.kt new file mode 100644 index 0000000..97f5b59 --- /dev/null +++ b/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/EditableRowTest.kt @@ -0,0 +1,100 @@ +package dev.scuttle.inventory.ui.hierarchy + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +/** + * `EditableRow` is the row-level gate for "the Unsorted shelf cannot be renamed + * or reordered": the pencil and up/down buttons must not even render for it, + * regardless of edit mode. (The companion invariant "cannot be SELECTED" is + * enforced independently in `ShelvesViewModel.toggleSelection` — see + * `ShelvesViewModelTest.the_unsorted_shelf_cannot_be_selected_for_deletion` — + * since this row always forwards a tap to `onClick` and lets the ViewModel be + * the single source of truth for that rule.) + * + * Rendered in isolation, same pattern as `DeleteStrategyDialogTest` / + * `ProductFilterSortRowTest`: `createComposeRule()`, no Activity/Hilt. + */ +class EditableRowTest { + @get:Rule + val composeRule = createComposeRule() + + private fun render( + editMode: Boolean, + isSystem: Boolean, + selected: Boolean = false, + canMoveUp: Boolean = true, + canMoveDown: Boolean = true, + onClick: () -> Unit = {}, + onRename: () -> Unit = {}, + onMoveUp: () -> Unit = {}, + onMoveDown: () -> Unit = {}, + ) { + composeRule.setContent { + MaterialTheme { + EditableRow( + name = "Top shelf", + editMode = editMode, + isSystem = isSystem, + selected = selected, + canMoveUp = canMoveUp, + canMoveDown = canMoveDown, + onClick = onClick, + onRename = onRename, + onMoveUp = onMoveUp, + onMoveDown = onMoveDown, + ) + } + } + } + + @Test + fun outside_edit_mode_shows_no_edit_affordances_but_the_row_is_still_tappable() { + var clicked = false + render(editMode = false, isSystem = false, onClick = { clicked = true }) + + composeRule.onNodeWithContentDescription("Rename shelf").assertDoesNotExist() + composeRule.onNodeWithContentDescription("Move shelf up").assertDoesNotExist() + composeRule.onNodeWithContentDescription("Move shelf down").assertDoesNotExist() + + composeRule.onNodeWithText("Top shelf").performClick() + assertTrue(clicked) + } + + @Test + fun edit_mode_shows_rename_and_reorder_for_a_normal_shelf() { + var renamed = false + var movedUp = false + var movedDown = false + render( + editMode = true, + isSystem = false, + onRename = { renamed = true }, + onMoveUp = { movedUp = true }, + onMoveDown = { movedDown = true }, + ) + + composeRule.onNodeWithContentDescription("Rename shelf").performClick() + composeRule.onNodeWithContentDescription("Move shelf up").performClick() + composeRule.onNodeWithContentDescription("Move shelf down").performClick() + + assertTrue(renamed) + assertTrue(movedUp) + assertTrue(movedDown) + } + + @Test + fun the_unsorted_shelf_shows_no_rename_or_reorder_even_in_edit_mode() { + render(editMode = true, isSystem = true) + + composeRule.onNodeWithContentDescription("Rename shelf").assertDoesNotExist() + composeRule.onNodeWithContentDescription("Move shelf up").assertDoesNotExist() + composeRule.onNodeWithContentDescription("Move shelf down").assertDoesNotExist() + } +} diff --git a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt new file mode 100644 index 0000000..250285e --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt @@ -0,0 +1,88 @@ +package dev.scuttle.inventory.ui.hierarchy + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.scuttle.inventory.R +import dev.scuttle.inventory.ui.theme.FrostCard + +/** + * One row of the shelves list view. + * + * Outside edit mode this is a plain [FrostCard]: tapping it opens the shelf, same + * as tapping its tab did before. In edit mode the row grows a leading [Checkbox] + * (selection for the batch delete/confirm flow), a rename pencil, and trailing + * up/down [IconButton]s for the manual drag order — all three gated off for the + * Unsorted shelf via [isSystem], since it can't be renamed, selected, or reordered. + */ +@Composable +fun EditableRow( + name: String, + editMode: Boolean, + isSystem: Boolean, + selected: Boolean, + canMoveUp: Boolean, + canMoveDown: Boolean, + onClick: () -> Unit, + onRename: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + modifier: Modifier = Modifier, +) { + val editable = editMode && !isSystem + FrostCard( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (editable) { + Checkbox(checked = selected, onCheckedChange = { onClick() }) + } + Text( + text = name, + style = MaterialTheme.typography.bodyLarge, + modifier = + Modifier + .weight(1f) + .padding(vertical = 14.dp), + ) + if (editable) { + IconButton(onClick = onRename) { + Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.shelf_rename_title)) + } + IconButton(onClick = onMoveUp, enabled = canMoveUp) { + Icon( + Icons.Default.KeyboardArrowUp, + contentDescription = stringResource(R.string.shelf_move_up_cd), + ) + } + IconButton(onClick = onMoveDown, enabled = canMoveDown) { + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(R.string.shelf_move_down_cd), + ) + } + } + } + } +} diff --git a/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt index 1508400..28028eb 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -15,6 +16,8 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -23,9 +26,10 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.ViewList +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -40,8 +44,10 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -69,12 +75,20 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import dev.scuttle.inventory.R +import dev.scuttle.inventory.data.dto.ShelfDto import dev.scuttle.inventory.ui.app.DrawerViewModel import dev.scuttle.inventory.ui.common.LiveStatusText +import dev.scuttle.inventory.ui.hierarchy.DeleteStrategyDialog +import dev.scuttle.inventory.ui.hierarchy.EditableRow +import dev.scuttle.inventory.ui.hierarchy.shelfStrategyOptions import dev.scuttle.inventory.ui.products.ProductsPane import dev.scuttle.inventory.ui.products.ProductsViewModel import dev.scuttle.inventory.ui.shelves.ShelvesViewModel import kotlinx.coroutines.launch +import androidx.compose.material.icons.filled.Tab as TabViewIcon + +/** Matches the server-side shelf name column limit (same cap as ShelvesViewModel.onNewNameChange). */ +private const val MAX_SHELF_NAME_LENGTH = 50 @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -95,10 +109,13 @@ fun LocationDetailScreen( val pagerState = rememberPagerState(pageCount = { state.shelves.size }) val currentPage = pagerState.currentPage.coerceAtMost((state.shelves.size - 1).coerceAtLeast(0)) val currentShelfId = state.shelves.getOrNull(currentPage)?.id + val nonSystemShelfCount = state.shelves.count { !it.is_system } var showAddShelfSheet by rememberSaveable { mutableStateOf(false) } var showAddProductSheet by rememberSaveable { mutableStateOf(false) } var productsRefreshKey by remember { mutableIntStateOf(0) } + var renamingShelf by remember { mutableStateOf(null) } + var renameText by remember { mutableStateOf("") } val keyboardController = LocalSoftwareKeyboardController.current // Track per-shelf warning state so we can roll up to location level @@ -108,7 +125,8 @@ fun LocationDetailScreen( shelvesViewModel.load(householdId, locationId) } - // Hosts one-shot action errors from the ProductsPane(s) below. + // Hosts one-shot action errors from the ProductsPane(s) below, plus the + // delete-undo snackbar (LaunchedEffect further down). val snackbarHostState = remember { SnackbarHostState() } val statusBarInsets = WindowInsets.statusBars @@ -120,16 +138,16 @@ fun LocationDetailScreen( TopAppBar( windowInsets = statusBarInsets, title = { - if (state.deleteMode && state.selectedShelves.isNotEmpty()) { - Text(stringResource(R.string.location_selected_count, state.selectedShelves.size)) + if (state.editMode && state.selected.isNotEmpty()) { + Text(stringResource(R.string.location_selected_count, state.selected.size)) } else { Text(stringResource(R.string.location_shelves_title)) } }, navigationIcon = { - if (state.deleteMode) { + if (state.editMode) { TextButton( - onClick = shelvesViewModel::exitDeleteMode, + onClick = shelvesViewModel::exitEditMode, ) { Text(stringResource(R.string.action_cancel)) } } else { IconButton(onClick = onBack) { @@ -141,27 +159,37 @@ fun LocationDetailScreen( } }, actions = { - if (state.deleteMode) { + if (state.editMode) { Button( - onClick = shelvesViewModel::deleteSelected, - enabled = state.selectedShelves.isNotEmpty() && !state.loading, + // requestDelete() only OPENS the strategy dialog — the actual + // delete happens in confirmDelete(), wired to the dialog below. + // This button must never call confirmDelete() directly: that + // would be exactly the no-confirmation bug this task replaces. + onClick = shelvesViewModel::requestDelete, + enabled = state.selected.isNotEmpty() && !state.loading, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.padding(end = 8.dp), ) { Text( - if (state.selectedShelves.isEmpty()) { + if (state.selected.isEmpty()) { stringResource(R.string.location_delete_button) } else { - stringResource(R.string.location_delete_count_button, state.selectedShelves.size) + stringResource(R.string.location_delete_count_button, state.selected.size) }, ) } } else { + IconButton(onClick = shelvesViewModel::toggleListView) { + Icon( + imageVector = if (state.listView) Icons.Default.TabViewIcon else Icons.Default.ViewList, + contentDescription = stringResource(R.string.location_view_toggle_cd), + ) + } if (state.shelves.isNotEmpty()) { - IconButton(onClick = shelvesViewModel::enterDeleteMode) { + IconButton(onClick = shelvesViewModel::enterEditMode) { Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.location_delete_shelves_cd), + Icons.Default.Edit, + contentDescription = stringResource(R.string.location_edit_shelves_cd), ) } } @@ -173,7 +201,7 @@ fun LocationDetailScreen( ) }, floatingActionButton = { - if (!state.deleteMode && currentShelfId != null) { + if (!state.editMode && currentShelfId != null) { Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(12.dp)) { SmallFloatingActionButton(onClick = onOpenScanner) { Icon( @@ -225,89 +253,99 @@ fun LocationDetailScreen( modifier = Modifier.padding(16.dp), ) } + } else if (state.listView) { + // Edit mode always renders here (enterEditMode() forces listView), + // since a ScrollableTabRow cannot host reorder buttons or an inline + // rename target. Outside edit mode this is just an alternative, + // full-name shelf selector — tapping a row drills back into the + // tabs+pager view centered on that shelf. + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed(state.shelves, key = { _, shelf -> shelf.id }) { index, shelf -> + EditableRow( + name = shelf.name, + editMode = state.editMode, + isSystem = shelf.is_system, + selected = shelf.id in state.selected, + canMoveUp = !shelf.is_system && index > 0, + canMoveDown = !shelf.is_system && index < nonSystemShelfCount - 1, + onClick = { + if (state.editMode) { + shelvesViewModel.toggleSelection(shelf.id) + } else { + scope.launch { pagerState.scrollToPage(index) } + shelvesViewModel.toggleListView() + } + }, + onRename = { + renamingShelf = shelf + renameText = shelf.name + }, + onMoveUp = { shelvesViewModel.moveUp(shelf.id) }, + onMoveDown = { shelvesViewModel.moveDown(shelf.id) }, + ) + } + } } else { ScrollableTabRow( selectedTabIndex = currentPage, ) { state.shelves.forEachIndexed { index, shelf -> - val isSelected = - if (state.deleteMode) { - shelf.id in state.selectedShelves - } else { - currentPage == index - } val tabHasWarning = shelfWarnings[shelf.id] == true // The warning is conveyed visually by red text + a dot; give the // text row a content description so it isn't color-only (WCAG // 1.4.1) and TalkBack announces ", has missing items" (W9). val warningCd = - if (tabHasWarning && !state.deleteMode) { + if (tabHasWarning) { stringResource(R.string.location_shelf_missing_cd, shelf.name) } else { null } Tab( - selected = isSelected, - onClick = { - if (state.deleteMode) { - shelvesViewModel.toggleShelfSelection(shelf.id) - } else { - scope.launch { pagerState.animateScrollToPage(index) } - } - }, + selected = currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, text = { - if (state.deleteMode && shelf.id in state.selectedShelves) { - Text(shelf.name) - } else { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = - if (warningCd != null) { - Modifier.clearAndSetSemantics { contentDescription = warningCd } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = + if (warningCd != null) { + Modifier.clearAndSetSemantics { contentDescription = warningCd } + } else { + Modifier + }, + ) { + Text( + shelf.name, + color = + if (tabHasWarning) { + MaterialTheme.colorScheme.error } else { - Modifier + Color.Unspecified }, - ) { - Text( - shelf.name, - color = - if (tabHasWarning && !state.deleteMode) { - MaterialTheme.colorScheme.error - } else { - Color.Unspecified - }, + ) + if (tabHasWarning) { + Box( + modifier = + Modifier + .size(6.dp) + .background(MaterialTheme.colorScheme.error, CircleShape), ) - if (tabHasWarning && !state.deleteMode) { - Box( - modifier = - Modifier - .size(6.dp) - .background(MaterialTheme.colorScheme.error, CircleShape), - ) - } } } }, - icon = - if (state.deleteMode && shelf.id in state.selectedShelves) { - { - Icon( - Icons.Default.CheckCircle, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - } - } else { - null - }, ) } } HorizontalPager( state = pagerState, - userScrollEnabled = !state.deleteMode, modifier = Modifier .fillMaxWidth() @@ -331,6 +369,66 @@ fun LocationDetailScreen( } // end PullToRefreshBox } + // The delete-strategy dialog for the current selection. Non-null pendingDelete + // is the ONLY thing that shows this dialog, and confirmDelete() (the only path + // that actually deletes) is reachable exclusively from its confirm button — the + // top bar's Delete button above only ever calls requestDelete(). + state.pendingDelete?.let { plan -> + DeleteStrategyDialog( + plan = plan, + options = shelfStrategyOptions(), + targets = state.moveTargets, + onDismiss = shelvesViewModel::cancelDelete, + onConfirm = { strategy, targetId -> shelvesViewModel.confirmDelete(strategy, targetId) }, + ) + } + + // Undo snackbar. A snackbar with an action, rather than SnackbarErrorEffect + // (which is for one-shot errors and has no action slot). + val undoLabel = stringResource(R.string.delete_undo) + val deletedMessage = stringResource(R.string.shelves_deleted) + LaunchedEffect(state.lastBatchId) { + if (state.lastBatchId == null) return@LaunchedEffect + val result = + snackbarHostState.showSnackbar( + message = deletedMessage, + actionLabel = undoLabel, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + shelvesViewModel.undoDelete() + } else { + shelvesViewModel.consumeLastBatch() + } + } + + renamingShelf?.let { shelf -> + AlertDialog( + onDismissRequest = { renamingShelf = null }, + title = { Text(stringResource(R.string.shelf_rename_title)) }, + text = { + OutlinedTextField( + value = renameText, + onValueChange = { renameText = it.take(MAX_SHELF_NAME_LENGTH) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrect = false, imeAction = ImeAction.Done), + ) + }, + confirmButton = { + TextButton( + onClick = { + shelvesViewModel.rename(shelf.id, renameText) + renamingShelf = null + }, + enabled = renameText.isNotBlank(), + ) { Text(stringResource(R.string.action_save)) } + }, + dismissButton = { + TextButton(onClick = { renamingShelf = null }) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } + if (showAddShelfSheet) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index ee20c68..670fff7 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -85,13 +85,18 @@ Planken %d geselecteerd - Planken selecteren om te verwijderen + Planken bewerken + Wisselen tussen tab- en lijstweergave Plank toevoegen Product toevoegen Nog geen planken. Tik op + in de bovenbalk om er een toe te voegen. %1$s, ontbrekende artikelen Verwijderen (%d) Verwijderen + Plank omhoog verplaatsen + Plank omlaag verplaatsen + Plank hernoemen + Planken verwijderd. Plank toevoegen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7dbd0bf..4648f1c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -85,13 +85,18 @@ Shelves %d selected - Select shelves to delete + Edit shelves + Switch between tab and list view Add shelf Add product No shelves yet. Tap + in the top bar to add one. %1$s, has missing items Delete (%d) Delete + Move shelf up + Move shelf down + Rename shelf + Shelves deleted. Add shelf From 65e17dafcabf0e3e135da7888eaa8c49a1aafb42 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Mon, 13 Jul 2026 21:01:12 +0200 Subject: [PATCH 12/40] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20loading-gated=20actions,=20name=20clamp,=20test=20g?= =?UTF-8?q?aps,=20flow=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditableRow/LocationDetailScreen: rename and move up/down each fire a request immediately, so hold them off while state.loading is true (selecting stays enabled — it never touches the network). Matches the existing pattern on the edit-mode Delete button. - ShelvesViewModel.rename(): clamp to MAX_SHELF_NAME_LENGTH itself instead of relying solely on the Compose text field's take() — the method is now safe to call from anywhere, not only the one wired-up dialog. - Added the missing positive rename_updates_the_shelf_name test and the move_down_on_the_last_shelf_does_nothing boundary counterpart to move_up_on_the_first_shelf_does_nothing. - DeleteShelfFlowTest (the regression test for the original no-confirmation bulk-delete bug) now drives the actual new flow: enter edit mode, select via the list row, Delete opens the strategy/confirm dialog, and only confirming inside it fires the DELETE. --- .../inventory/flow/DeleteShelfFlowTest.kt | 30 ++++++++++++---- .../inventory/ui/hierarchy/EditableRow.kt | 10 ++++-- .../ui/location/LocationDetailScreen.kt | 4 +++ .../inventory/ui/shelves/ShelvesViewModel.kt | 10 ++++-- .../scuttle/inventory/ShelvesViewModelTest.kt | 34 +++++++++++++++++++ 5 files changed, 76 insertions(+), 12 deletions(-) diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt index 1c8665b..aedc13f 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt @@ -2,6 +2,7 @@ package dev.scuttle.inventory.flow +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasTestTag @@ -19,10 +20,19 @@ import dev.scuttle.inventory.FlowTestBase import dev.scuttle.inventory.ui.dashboard.DASHBOARD_TITLE_TEST_TAG import org.junit.Test +/** + * The regression test for the bug that started Task 4: the shelves list used to + * bulk-delete checked shelves with NO confirmation dialog at all. This now drives + * the full edit-mode flow — enter edit mode, select, tap Delete, and the delete + * only actually happens once the strategy/confirm dialog's own Delete button is + * tapped. If a future change makes the top bar's Delete button call confirmDelete() + * directly instead of requestDelete(), this test breaks because the dialog step + * below would never appear before the shelf disappears. + */ @HiltAndroidTest class DeleteShelfFlowTest : FlowTestBase() { @Test - fun select_and_delete_shelf_removes_it_from_tabs() { + fun select_and_delete_shelf_requires_confirming_the_strategy_dialog() { mockServer.enqueue(fixture("auth_login.json")) mockServer.route("/households", fixture("households_one.json")) mockServer.route("/households/1/locations", fixture("locations_one.json")) @@ -49,20 +59,26 @@ class DeleteShelfFlowTest : FlowTestBase() { onNodeWithTag("home-location-Fridge").performClick() waitForIdle() - // Both tabs visible + // Both shelves visible as tabs (default view) waitUntilAtLeastOneExists(hasText("Top shelf"), timeoutMillis = 5_000) - // Tap "Select shelves to delete" icon → delete mode - onNodeWithContentDescription("Select shelves to delete").performClick() + // Tap the pencil ("Edit shelves") → edit mode, forced into the list view + onNodeWithContentDescription("Edit shelves").performClick() waitForIdle() - // Select "Top shelf" tab → becomes selected + // Select "Top shelf" row → becomes selected onNodeWithText("Top shelf").performClick() waitForIdle() - // DELETE /households/1/locations/10/shelves/100 → 204; shelf removed optimistically - mockServer.route("/households/1/locations/10/shelves/100", "", code = 204) + // Delete (1) only OPENS the strategy/confirm dialog — it must not delete yet. onNodeWithText("Delete (1)").performClick() + waitForIdle() + waitUntilAtLeastOneExists(hasText("Delete 1 item(s)?"), timeoutMillis = 3_000) + onNodeWithText("Top shelf").assertIsDisplayed() + + // Only now, confirming inside the dialog, does the DELETE fire. + mockServer.route("/households/1/locations/10/shelves/100", "", code = 204) + onNodeWithTag("delete-strategy-confirm").performClick() Thread.sleep(2_000) waitForIdle() diff --git a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt index 250285e..19f3906 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt @@ -42,6 +42,10 @@ fun EditableRow( onMoveUp: () -> Unit, onMoveDown: () -> Unit, modifier: Modifier = Modifier, + // Selecting (the checkbox) never hits the network, so it stays enabled while a + // mutation is in flight — only rename/reorder, which each fire their own + // request immediately, need to be held off to avoid racing a second one. + actionsEnabled: Boolean = true, ) { val editable = editMode && !isSystem FrostCard( @@ -67,16 +71,16 @@ fun EditableRow( .padding(vertical = 14.dp), ) if (editable) { - IconButton(onClick = onRename) { + IconButton(onClick = onRename, enabled = actionsEnabled) { Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.shelf_rename_title)) } - IconButton(onClick = onMoveUp, enabled = canMoveUp) { + IconButton(onClick = onMoveUp, enabled = canMoveUp && actionsEnabled) { Icon( Icons.Default.KeyboardArrowUp, contentDescription = stringResource(R.string.shelf_move_up_cd), ) } - IconButton(onClick = onMoveDown, enabled = canMoveDown) { + IconButton(onClick = onMoveDown, enabled = canMoveDown && actionsEnabled) { Icon( Icons.Default.KeyboardArrowDown, contentDescription = stringResource(R.string.shelf_move_down_cd), diff --git a/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt index 28028eb..2f8406a 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt @@ -275,6 +275,10 @@ fun LocationDetailScreen( selected = shelf.id in state.selected, canMoveUp = !shelf.is_system && index > 0, canMoveDown = !shelf.is_system && index < nonSystemShelfCount - 1, + // Selecting doesn't touch the network, but rename/reorder each + // fire their own request immediately — held off while another + // mutation is in flight so two don't race each other. + actionsEnabled = !state.loading, onClick = { if (state.editMode) { shelvesViewModel.toggleSelection(shelf.id) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt index 290ab51..dcc3a9c 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt @@ -23,6 +23,9 @@ import kotlinx.coroutines.launch import java.util.UUID import javax.inject.Inject +/** Server-side shelf name column limit — clamped here (rename) and in onNewNameChange (create). */ +private const val MAX_SHELF_NAME_LENGTH = 50 + data class ShelvesUiState( val loading: Boolean = false, // Only a user-initiated refresh() flips this; create/delete use `loading` alone, @@ -91,7 +94,8 @@ class ShelvesViewModel } } - fun onNewNameChange(value: String) = _state.update { it.copy(newName = value.take(50), error = null) } + fun onNewNameChange(value: String) = + _state.update { it.copy(newName = value.take(MAX_SHELF_NAME_LENGTH), error = null) } fun refresh() { val h = householdId ?: return @@ -160,7 +164,9 @@ class ShelvesViewModel val l = locationId if (h == null || l == null) return val shelf = _state.value.shelves.firstOrNull { it.id == shelfId } - val trimmed = name.trim() + // Clamped here too (not just in the Compose text field), so this method + // is safe to call from anywhere, not only the one wired-up dialog. + val trimmed = name.trim().take(MAX_SHELF_NAME_LENGTH) // The Unsorted shelf's name is server-owned; the client only localises // its displayed label, so it can never be renamed from here. if (shelf == null || shelf.is_system || trimmed.isEmpty()) return diff --git a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt index c7620c7..c7cc964 100644 --- a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt @@ -304,6 +304,22 @@ class ShelvesViewModelTest { ) } + @Test + fun move_down_on_the_last_shelf_does_nothing() = + runTest { + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + + vm.moveDown(1L) + + assertEquals( + listOf("Top"), + vm.state.value.shelves + .map { it.name }, + ) + } + @Test fun reordering_sends_the_complete_id_list_excluding_the_system_shelf() = runTest { @@ -352,6 +368,24 @@ class ShelvesViewModelTest { ) } + @Test + fun rename_updates_the_shelf_name() = + runTest { + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + + vm.rename(1L, "Freezer top") + + assertEquals(1L, repo.lastRenamedId) + assertEquals( + "Freezer top", + vm.state.value.shelves + .first() + .name, + ) + } + @Test fun the_unsorted_shelf_cannot_be_renamed() = runTest { From b2a51e894451ae8e051bb43a4f281477f4a1dec9 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 00:22:12 +0200 Subject: [PATCH 13/40] =?UTF-8?q?fix:=20address=20Task=204=20code=20review?= =?UTF-8?q?=20=E2=80=94=20dup-shelf=20crash,=20stale=20delete=20plan,=20pa?= =?UTF-8?q?rtial-batch=20undo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: reorder's server response already includes the Unsorted (is_system) shelf (PATCH .../shelves/reorder ends with `return $this->index(...)`), and ShelvesViewModel.move() was appending its own local copy on top of that, producing a duplicate shelf id. Every location with an Unsorted shelf crashed on the first reorder (LazyColumn's itemsIndexed keys by shelf.id). Filter the server's own system-shelf entries out before re-adding ours. The test fake's reorder() was lying about this (returning only the reordered subset, with a comment claiming the opposite of what the real endpoint does) — fixed the fake to return the full list, faithfully, so the suite can actually catch this class of bug. Also pinned the optimistic reorder frame with a gated fake, since the synchronous fake previously let a broken optimistic update slip through. Important: requestDelete() built its delete-strategy plan from cached product_count, which product mutations elsewhere on the screen (add-product sheet, barcode scan, product delete) never update — a stale zero skipped the strategy prompt and produced a 422 deleting a non-empty shelf. requestDelete() now refreshes the shelf list from the server before building the plan. Minor: confirmDelete() now restores the pre-edit view like exitEditMode() does, instead of leaving listView forced on. A batch delete that fails partway now stops, reconciles the list with the server, and still surfaces Undo for whatever landed instead of silently losing it; confirmDelete() filters the selection against the live shelf list the same way requestDelete() does. Removed the two @Suppress annotations (ShelvesViewModel TooManyFunctions, SessionCleaner LongParameterList) in favor of the project's detekt-baseline mechanism, matching how AuthViewModel's TooManyFunctions is already handled. Added discriminating tests for every fix; each was verified red against the pre-fix code and green after (see task-4-report.md for the per-finding evidence). --- app/detekt-baseline.xml | 4 +- .../inventory/data/auth/SessionCleaner.kt | 10 +- .../inventory/ui/shelves/ShelvesViewModel.kt | 133 +++++++++---- .../scuttle/inventory/ShelvesViewModelTest.kt | 186 +++++++++++++++++- 4 files changed, 286 insertions(+), 47 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 4008028..7380114 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -77,7 +77,7 @@ LongParameterList:DashboardScreen.kt$( modifier: Modifier = Modifier, onOpenDrawer: () -> Unit = {}, onOpenLocation: (householdId: Long, locationId: Long) -> Unit = { _, _ -> }, onOpenHouseholds: () -> Unit = {}, onOpenMissingItems: () -> Unit = {}, viewModel: DashboardViewModel = hiltViewModel(), ) LongParameterList:LocationDetailScreen.kt$( householdId: Long, locationId: Long, drawerViewModel: DrawerViewModel, modifier: Modifier = Modifier, onBack: () -> Unit = {}, onOpenDrawer: () -> Unit = {}, onOpenProduct: (householdId: Long, shelfId: Long, productId: Long) -> Unit = { _, _, _ -> }, shelvesViewModel: ShelvesViewModel = hiltViewModel(), ) LongParameterList:ProductsPane.kt$( householdId: Long, shelfId: Long, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, refreshKey: Int = 0, onOpenProduct: (ProductDto) -> Unit = {}, onWarningChange: (Boolean) -> Unit = {}, viewModel: ProductsViewModel = hiltViewModel(key = "products-$shelfId"), ) - LongParameterList:SessionCleaner.kt$SessionCleaner$( private val householdRepository: HouseholdRepository, private val locationRepository: LocationRepository, private val shelfRepository: ShelfRepository, private val productRepository: ProductRepository, private val hierarchyStore: HierarchyStore, private val favoritesStore: FavoritesStore, private val defaultHouseholdStore: DefaultHouseholdStore, ) + LongParameterList:SessionCleaner.kt$SessionCleaner$( private val householdRepository: HouseholdRepository, private val locationRepository: LocationRepository, private val shelfRepository: ShelfRepository, private val productRepository: ProductRepository, private val hierarchyStore: HierarchyStore, private val favoritesStore: FavoritesStore, private val defaultHouseholdStore: DefaultHouseholdStore, private val shelfViewStore: ShelfViewStore, ) LongParameterList:SettingsScreen.kt$( modifier: Modifier = Modifier, onOpenDrawer: () -> Unit = {}, onSignOut: () -> Unit = {}, onOpenHouseholds: () -> Unit = {}, themeViewModel: ThemeViewModel = hiltViewModel(), languageViewModel: LanguageViewModel = hiltViewModel(), joinViewModel: JoinHouseholdViewModel = hiltViewModel(), ) LongParameterList:StorageOverviewScreen.kt$( householdId: Long, modifier: Modifier = Modifier, onBack: () -> Unit = {}, onOpenDrawer: () -> Unit = {}, onOpenLocation: (Long) -> Unit = {}, onOpenSearch: () -> Unit = {}, viewModel: StorageOverviewViewModel = hiltViewModel(), ) MagicNumber:AuthInterceptor.kt$AuthInterceptor$401 @@ -205,12 +205,14 @@ TooGenericExceptionThrown:ProductDetailViewModelTest.kt$ProductDetailViewModelTest.FakeProductRepository$throw RuntimeException("save failed") TooGenericExceptionThrown:ProductsViewModelTest.kt$ProductsViewModelTest.FakeProductRepository$throw RuntimeException("offline") TooGenericExceptionThrown:SearchViewModelTest.kt$SearchViewModelTest.FakeSearchRepository$throw RuntimeException("offline") + TooGenericExceptionThrown:ShelvesViewModelTest.kt$ShelvesViewModelTest.FakeShelfRepository$throw RuntimeException("delete failed") TooGenericExceptionThrown:ShelvesViewModelTest.kt$ShelvesViewModelTest.FakeShelfRepository$throw RuntimeException("offline") TooGenericExceptionThrown:StorageOverviewViewModelTest.kt$StorageOverviewViewModelTest.FakeLocationRepository$throw RuntimeException("offline") TooManyFunctions:AuthViewModel.kt$AuthViewModel : ViewModel TooManyFunctions:NetworkModule.kt$NetworkModule TooManyFunctions:ProductRepositoryImpl.kt$ProductRepositoryImpl : ProductRepository TooManyFunctions:ProductsViewModel.kt$ProductsViewModel : ViewModel + TooManyFunctions:ShelvesViewModel.kt$ShelvesViewModel : ViewModel UnusedParameter:AuthViewModel.kt$AuthViewModel$mode: AuthMode UnusedParameter:LocationDetailScreen.kt$householdId: Long diff --git a/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt b/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt index 30886a4..b65c931 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt @@ -20,12 +20,14 @@ import javax.inject.Singleton * calls `loadFromCache()`, which repopulates synchronously from the stale caches * before the network returns. In-memory/prefs only, so it respects the * always-online, no-local-DB rule. + * + * One constructor parameter per thing to clear is this class's entire job — it + * is a pure fan-out, not a design smell to split up; splitting it would need an + * artificial "clearable" abstraction (and Hilt multibinding) for no real benefit. + * (LongParameterList is baselined in detekt-baseline.xml, same mechanism as the + * other long-parameter-list Composables in this codebase.) */ @Singleton -// One constructor parameter per thing to clear is this class's entire job — it is -// a pure fan-out, not a design smell to split up; splitting it would need an -// artificial "clearable" abstraction (and Hilt multibinding) for no real benefit. -@Suppress("LongParameterList") class SessionCleaner @Inject constructor( diff --git a/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt index dcc3a9c..3450b11 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt @@ -50,12 +50,12 @@ data class ShelvesUiState( val lastBatchId: String? = null, ) -@HiltViewModel // The method count is the required surface for this screen (edit mode, reorder, // rename, strategy-gated delete, undo, view toggle) — Task 5 mirrors this same // shape for locations, so splitting it up would fight the spec rather than the // class's actual cohesion (every method operates on the one ShelvesUiState). -@Suppress("TooManyFunctions") +// (TooManyFunctions is baselined in detekt-baseline.xml, same as AuthViewModel.) +@HiltViewModel class ShelvesViewModel @Inject constructor( @@ -210,40 +210,68 @@ class ShelvesViewModel // list produces duplicate positions server-side, and the server // does not want the system shelf in the payload at all. val server = repository.reorder(h, l, reordered.map { it.id }) - _state.update { it.copy(shelves = orderShelves(server + systemShelves)) } + // The server's response is the FULL shelf list for this location, + // is_system included (PATCH .../shelves/reorder ends with `return + // $this->index(...)`). Appending our own local systemShelves on + // top of that would duplicate the system shelf's id, and the list + // view's LazyColumn keys itemsIndexed by shelf.id — a duplicate + // key throws IllegalArgumentException on every reorder. Strip any + // system shelf(s) the server sent back before adding ours. + _state.update { it.copy(shelves = orderShelves(server.filterNot { s -> s.is_system } + systemShelves)) } hierarchyStore.refresh() } } - /** Open the strategy dialog for the current selection. */ + /** + * Open the strategy dialog for the current selection. + * + * Refreshes the shelf list from the server FIRST, then builds the plan off + * that fresh copy. ShelfDto.product_count's own doc comment warns why this + * is required: add-product / barcode-scan / product-delete all mutate a + * shelf's product count without this screen ever hearing about it, so the + * cached `state.shelves[].product_count` this dialog used to read from can + * be stale. Building `needsStrategy` off a stale zero would silently drop + * the strategy prompt and send a strategy-less delete for a shelf that + * still holds products — a guaranteed 422. + */ fun requestDelete() { - val state = _state.value - val selected = state.shelves.filter { it.id in state.selected } - if (selected.isEmpty()) return + val h = householdId + val l = locationId + if (h == null || l == null) return + val selectedIds = _state.value.selected + if (selectedIds.isEmpty()) return - // For a SHELF, what the server counts as "has contents" IS its product - // count — so contentCount and productCount coincide here. They do NOT - // coincide for a location (Task 5): there, contentCount is shelf_count. - val products = selected.sumOf { it.product_count } + launchLoading { + val shelves = orderShelves(repository.list(h, l)) + _state.update { it.copy(shelves = shelves) } - _state.update { - it.copy( - pendingDelete = - DeletePlan( - itemCount = selected.size, - productCount = products, - contentCount = products, - ), - // The shelves the products could move TO: live, non-system, and - // not themselves being deleted. This list is the ONLY signal for - // whether "move" is offered — DeletePlan carries no canMove flag - // (see DeletePlan.kt). Empty list => the dialog hides the move - // option, so a move is never offered with nowhere to go. - moveTargets = - state.shelves - .filter { s -> s.id !in state.selected && !s.is_system } - .map { s -> MoveTarget(id = s.id, name = s.name) }, - ) + val selected = shelves.filter { it.id in selectedIds } + if (selected.isEmpty()) return@launchLoading + + // For a SHELF, what the server counts as "has contents" IS its product + // count — so contentCount and productCount coincide here. They do NOT + // coincide for a location (Task 5): there, contentCount is shelf_count. + val products = selected.sumOf { it.product_count } + + _state.update { + it.copy( + pendingDelete = + DeletePlan( + itemCount = selected.size, + productCount = products, + contentCount = products, + ), + // The shelves the products could move TO: live, non-system, and + // not themselves being deleted. This list is the ONLY signal for + // whether "move" is offered — DeletePlan carries no canMove flag + // (see DeletePlan.kt). Empty list => the dialog hides the move + // option, so a move is never offered with nowhere to go. + moveTargets = + shelves + .filter { s -> s.id !in selectedIds && !s.is_system } + .map { s -> MoveTarget(id = s.id, name = s.name) }, + ) + } } } @@ -254,7 +282,13 @@ class ShelvesViewModel val h = householdId val l = locationId if (h == null || l == null) return - val ids = _state.value.selected.toList() + val state = _state.value + // Same filter requestDelete() applies (and, by the time the dialog is + // open, requestDelete() has already refreshed state.shelves): only ids + // that still exist in the live list, so a shelf that vanished from + // under the user — another device, a refresh while the dialog was open + // — never gets a delete request for an id that no longer exists. + val ids = state.shelves.filter { it.id in state.selected }.map { it.id } if (ids.isEmpty()) return // ONE batch id for the whole gesture. Deleting three shelves is three @@ -263,20 +297,49 @@ class ShelvesViewModel val batchId = UUID.randomUUID().toString() launchLoading { - ids.forEach { id -> - repository.deleteWithStrategy(h, l, id, ShelfDeletion(batchId, strategy, targetId)) + val succeeded = mutableListOf() + var failure: Throwable? = null + for (id in ids) { + val result = + runCatching { + repository.deleteWithStrategy(h, l, id, ShelfDeletion(batchId, strategy, targetId)) + } + result.exceptionOrNull()?.let { if (it is CancellationException) throw it } + if (result.isSuccess) { + succeeded += id + } else { + // Stop at the first failure: whatever already landed + // server-side must still be reflected and made Undo-able, + // but nothing past the failure was attempted, so there is + // nothing more to reconcile for those ids. + failure = result.exceptionOrNull() + break + } + } + + if (succeeded.isNotEmpty()) { + // A partial batch already changed the server's truth (and a + // MOVE/UNSORT strategy can shift another shelf's product_count + // too), so reconcile with a real refresh rather than a local + // filter — and surface Undo for whatever actually landed. + val fresh = orderShelves(repository.list(h, l)) + _state.update { it.copy(shelves = fresh, lastBatchId = batchId) } + hierarchyStore.refresh() } + + // Cancel and Delete must leave the screen in the same shape: exit + // edit mode and restore the pre-edit view either way. _state.update { it.copy( editMode = false, + listView = viewBeforeEdit, selected = emptySet(), pendingDelete = null, moveTargets = emptyList(), - lastBatchId = batchId, - shelves = it.shelves.filter { s -> s.id !in ids }, ) } - hierarchyStore.refresh() + + failure?.let { throw it } } } diff --git a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt index c7cc964..7b66696 100644 --- a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt @@ -9,6 +9,7 @@ import dev.scuttle.inventory.data.household.HouseholdRepository import dev.scuttle.inventory.data.settings.ShelfViewStore import dev.scuttle.inventory.data.shelf.ShelfRepository import dev.scuttle.inventory.ui.shelves.ShelvesViewModel +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -32,9 +33,14 @@ class ShelvesViewModelTest { val batchIdsUsed = mutableListOf() val strategiesUsed = mutableListOf() val targetIdsUsed = mutableListOf() + val deletedShelfIds = mutableListOf() var lastRenamedId: Long? = null var lastReorderIds: List? = null + // When set, deleteWithStrategy throws for this one id instead of deleting + // it — simulates a mid-batch server failure (Minor 4: partial batches). + var failDeleteId: Long? = null + override fun getCached( householdId: Long, locationId: Long, @@ -80,20 +86,28 @@ class ShelvesViewModelTest { return updated } + var reorderGate: CompletableDeferred? = null + override suspend fun reorder( householdId: Long, locationId: Long, ids: List, ): List { + reorderGate?.await() lastReorderIds = ids - // Only positions of the given ids change — anything left out (the system - // shelf) stays exactly where it was, same as the real server endpoint. - val reordered = ids.mapIndexedNotNull { i, id -> items.find { it.id == id }?.copy(position = i) } - reordered.forEach { updated -> - val index = items.indexOfFirst { it.id == updated.id } - items[index] = updated + // Only positions of the given ids change... + ids.forEachIndexed { i, id -> + val index = items.indexOfFirst { it.id == id } + if (index >= 0) items[index] = items[index].copy(position = i) } - return reordered + // ...but the FULL list for the location is returned, system shelf + // included: PATCH .../shelves/reorder ends with `return + // $this->index(...)` server-side. A fake that returned only the + // reordered subset would hide the exact bug this fake exists to + // catch (the VM appending its own local system-shelf copy on top + // of the one the server already sent back → a duplicate id that + // crashes the LazyColumn's itemsIndexed(key = shelf.id)). + return items.toList() } override suspend fun deleteWithStrategy( @@ -102,9 +116,11 @@ class ShelvesViewModelTest { shelfId: Long, deletion: ShelfDeletion, ) { + if (shelfId == failDeleteId) throw RuntimeException("delete failed") batchIdsUsed += deletion.batchId strategiesUsed += deletion.strategy targetIdsUsed += deletion.targetShelfId + deletedShelfIds += shelfId items.removeIf { it.id == shelfId } } } @@ -346,6 +362,46 @@ class ShelvesViewModelTest { ) } + @Test + fun the_optimistic_reorder_frame_keeps_the_system_shelf_before_the_server_confirms() = + runTest { + // Under the test dispatcher the fake's response normally lands + // synchronously and overwrites the optimistic frame before any + // assertion can observe it — so a VM that mutated the optimistic + // frame to `shelves = reordered` (silently dropping the system + // shelf) would still pass every other test in this file. Gating + // the fake's reorder() call parks the coroutine mid-flight so the + // optimistic frame itself can be inspected. + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Middle", 1, 1L)) + items.add(ShelfDto(3, "Bottom", 2, 1L)) + items.add(ShelfDto(4, "Unsorted", 3, 1L, is_system = true)) + } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + repo.reorderGate = CompletableDeferred() + + vm.moveUp(3L) + + // The server call is parked on the gate — this IS the optimistic frame. + assertEquals( + listOf("Top", "Bottom", "Middle", "Unsorted"), + vm.state.value.shelves + .map { it.name }, + ) + + repo.reorderGate?.complete(Unit) + + // And it still holds once the (faithful, full-list) server response lands. + assertEquals( + listOf("Top", "Bottom", "Middle", "Unsorted"), + vm.state.value.shelves + .map { it.name }, + ) + } + @Test fun the_unsorted_shelf_cannot_be_selected_for_deletion() = runTest { @@ -485,6 +541,38 @@ class ShelvesViewModelTest { ) } + @Test + fun requesting_delete_refreshes_the_shelf_list_so_a_stale_product_count_cannot_skip_the_strategy_prompt() = + runTest { + // Simulates a product added via the FAB / barcode scan / product + // delete — none of those mutations update ShelvesViewModel's own + // cached shelves list (see ShelfDto.product_count's doc comment). + // requestDelete() must re-fetch before building the plan, or a + // non-empty shelf gets deleted with no strategy and the server 422s. + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Snacks", 0, 1L, product_count = 0)) } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + + // The server-side count changed through a path this VM never + // observes; its own cached copy (product_count = 0) hasn't. + repo.items[0] = repo.items[0].copy(product_count = 1) + + vm.requestDelete() + + assertNotNull(vm.state.value.pendingDelete) + assertEquals( + 1, + vm.state.value.pendingDelete + ?.contentCount, + ) + assertTrue( + vm.state.value.pendingDelete + ?.needsStrategy == true, + ) + } + @Test fun requesting_delete_offers_only_live_non_system_non_selected_shelves_as_move_targets() = runTest { @@ -575,6 +663,90 @@ class ShelvesViewModelTest { assertEquals(listOf(2L), repo.targetIdsUsed) } + @Test + fun confirming_a_delete_restores_the_view_the_user_had_before_entering_edit_mode() = + runTest { + // exitEditMode() (Cancel) and confirmDelete() (Delete) must leave the + // screen in the same shape — both restore the pre-edit view, not just + // exit editMode. + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + assertFalse(vm.state.value.listView) + + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + vm.confirmDelete(null, targetId = null) + + assertFalse(vm.state.value.editMode) + assertFalse(vm.state.value.listView) + } + + @Test + fun a_batch_delete_that_partially_fails_still_surfaces_undo_for_what_landed() = + runTest { + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Middle", 1, 1L)) + items.add(ShelfDto(3, "Bottom", 2, 1L)) + failDeleteId = 2L + } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.toggleSelection(2L) + vm.toggleSelection(3L) + vm.requestDelete() + + vm.confirmDelete(ShelfDeleteStrategy.DELETE_PRODUCTS, targetId = null) + + // Shelf 1's delete landed before shelf 2's failed — that must still be + // Undo-able, and the list must be reconciled with what the server + // actually holds now (1 gone; 2 and 3 remain — we stop at the first + // failure rather than attempting the rest blind). + assertNotNull(vm.state.value.lastBatchId) + assertEquals(listOf(1L), repo.deletedShelfIds) + assertEquals( + setOf(2L, 3L), + vm.state.value.shelves + .map { it.id } + .toSet(), + ) + assertNotNull(vm.state.value.error) + } + + @Test + fun confirming_delete_ignores_a_selected_id_no_longer_in_the_live_shelf_list() = + runTest { + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Middle", 1, 1L)) + } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.toggleSelection(2L) + vm.requestDelete() + + // Shelf 2 disappears server-side (another device) while the dialog is + // open; a refresh (e.g. a live update) updates state.shelves but never + // touches state.selected. + repo.items.removeIf { it.id == 2L } + vm.refresh() + + vm.confirmDelete(ShelfDeleteStrategy.DELETE_PRODUCTS, targetId = null) + + // Only shelf 1 (still live) gets a delete request — requestDelete() + // filters its plan the same way, so confirmDelete must use the same + // filtered set, not the raw (possibly stale) selection. + assertEquals(listOf(1L), repo.deletedShelfIds) + } + @Test fun undo_delete_restores_the_batch_and_clears_it() = runTest { From b19a9e972b8daff30876b64ce6da35ecbd6a9abe Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 00:37:28 +0200 Subject: [PATCH 14/40] feat: edit mode on the locations list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename, retype, reorder and delete-with-strategy for storage locations, behind the same pencil pattern as the shelves list (Task 4). Removes swipe-to-delete on locations: a swipe should not be able to destroy a fridge full of food. Deletion now requires entering edit mode, selecting, and answering the strategy dialog (LocationDeleteStrategy — move or delete contents, deliberately no unsort at this level). DeletePlan.contentCount is built from shelf_count, not product_count: the server requires a strategy whenever a location HAS SHELVES, even completely empty ones. moveTargets is the household's other live locations, computed fresh in requestDelete() after a refresh (avoids a stale shelf_count skipping the strategy prompt). EditableRow gains optional rename/move-up/move-down content-description overrides (defaulting to the existing shelf strings) so the shared row component reads correctly for locations too, not "Rename shelf" on every container type. --- app/detekt-baseline.xml | 2 + .../inventory/ui/hierarchy/EditableRow.kt | 13 +- .../ui/storage/StorageOverviewScreen.kt | 315 +++++-- .../ui/storage/StorageOverviewViewModel.kt | 258 +++++- app/src/main/res/values-nl/strings.xml | 5 + app/src/main/res/values/strings.xml | 5 + .../inventory/StorageOverviewViewModelTest.kt | 834 ++++++++++++++---- 7 files changed, 1158 insertions(+), 274 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 7380114..9f54a4c 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -207,12 +207,14 @@ TooGenericExceptionThrown:SearchViewModelTest.kt$SearchViewModelTest.FakeSearchRepository$throw RuntimeException("offline") TooGenericExceptionThrown:ShelvesViewModelTest.kt$ShelvesViewModelTest.FakeShelfRepository$throw RuntimeException("delete failed") TooGenericExceptionThrown:ShelvesViewModelTest.kt$ShelvesViewModelTest.FakeShelfRepository$throw RuntimeException("offline") + TooGenericExceptionThrown:StorageOverviewViewModelTest.kt$StorageOverviewViewModelTest.FakeLocationRepository$throw RuntimeException("delete failed") TooGenericExceptionThrown:StorageOverviewViewModelTest.kt$StorageOverviewViewModelTest.FakeLocationRepository$throw RuntimeException("offline") TooManyFunctions:AuthViewModel.kt$AuthViewModel : ViewModel TooManyFunctions:NetworkModule.kt$NetworkModule TooManyFunctions:ProductRepositoryImpl.kt$ProductRepositoryImpl : ProductRepository TooManyFunctions:ProductsViewModel.kt$ProductsViewModel : ViewModel TooManyFunctions:ShelvesViewModel.kt$ShelvesViewModel : ViewModel + TooManyFunctions:StorageOverviewViewModel.kt$StorageOverviewViewModel : ViewModel UnusedParameter:AuthViewModel.kt$AuthViewModel$mode: AuthMode UnusedParameter:LocationDetailScreen.kt$householdId: Long diff --git a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt index 19f3906..6383e17 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt @@ -1,5 +1,6 @@ package dev.scuttle.inventory.ui.hierarchy +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -46,6 +47,12 @@ fun EditableRow( // mutation is in flight — only rename/reorder, which each fire their own // request immediately, need to be held off to avoid racing a second one. actionsEnabled: Boolean = true, + // Defaults preserve the shelves-screen wording; a caller editing a different + // kind of row (e.g. locations) passes its own copy so a screen reader never + // announces "Rename shelf" over a location row. + @StringRes renameLabelRes: Int = R.string.shelf_rename_title, + @StringRes moveUpLabelRes: Int = R.string.shelf_move_up_cd, + @StringRes moveDownLabelRes: Int = R.string.shelf_move_down_cd, ) { val editable = editMode && !isSystem FrostCard( @@ -72,18 +79,18 @@ fun EditableRow( ) if (editable) { IconButton(onClick = onRename, enabled = actionsEnabled) { - Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.shelf_rename_title)) + Icon(Icons.Default.Edit, contentDescription = stringResource(renameLabelRes)) } IconButton(onClick = onMoveUp, enabled = canMoveUp && actionsEnabled) { Icon( Icons.Default.KeyboardArrowUp, - contentDescription = stringResource(R.string.shelf_move_up_cd), + contentDescription = stringResource(moveUpLabelRes), ) } IconButton(onClick = onMoveDown, enabled = canMoveDown && actionsEnabled) { Icon( Icons.Default.KeyboardArrowDown, - contentDescription = stringResource(R.string.shelf_move_down_cd), + contentDescription = stringResource(moveDownLabelRes), ) } } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt index 3ceb204..2cb803a 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt @@ -1,11 +1,10 @@ package dev.scuttle.inventory.ui.storage -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -15,6 +14,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -22,9 +23,8 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -37,24 +37,23 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -68,8 +67,14 @@ import dev.scuttle.inventory.R import dev.scuttle.inventory.data.dto.LocationDto import dev.scuttle.inventory.ui.common.ErrorRetry import dev.scuttle.inventory.ui.common.storageTypeLabel +import dev.scuttle.inventory.ui.hierarchy.DeleteStrategyDialog +import dev.scuttle.inventory.ui.hierarchy.EditableRow +import dev.scuttle.inventory.ui.hierarchy.locationStrategyOptions import dev.scuttle.inventory.ui.theme.FrostCard +/** Matches the server-side location name column limit (same cap as StorageOverviewViewModel.onNewNameChange). */ +private const val MAX_LOCATION_NAME_LENGTH = 50 + @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun StorageOverviewScreen( @@ -82,9 +87,12 @@ fun StorageOverviewScreen( ) { val state by viewModel.state.collectAsState() var showAddSheet by rememberSaveable { mutableStateOf(false) } - var pendingDeleteLocation by remember { mutableStateOf(null) } + var renamingLocation by remember { mutableStateOf(null) } + var renameName by remember { mutableStateOf("") } + var renameType by remember { mutableStateOf("freezer") } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val keyboardController = LocalSoftwareKeyboardController.current + val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(householdId) { viewModel.load(householdId) } @@ -92,31 +100,73 @@ fun StorageOverviewScreen( Scaffold( contentWindowInsets = WindowInsets(0), modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( windowInsets = statusBarInsets, - title = { Text(stringResource(R.string.all_storage_title)) }, + title = { + if (state.editMode && state.selected.isNotEmpty()) { + Text(stringResource(R.string.location_selected_count, state.selected.size)) + } else { + Text(stringResource(R.string.all_storage_title)) + } + }, navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.action_back), - ) + if (state.editMode) { + TextButton(onClick = viewModel::exitEditMode) { Text(stringResource(R.string.action_cancel)) } + } else { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } } }, actions = { - IconButton(onClick = onOpenSearch) { - Icon( - Icons.Default.Search, - contentDescription = stringResource(R.string.storage_overview_search_cd), - ) + if (state.editMode) { + Button( + // requestDelete() only OPENS the strategy dialog — the actual + // delete happens in confirmDelete(), wired to the dialog below. + // This button must never call confirmDelete() directly: that + // would be exactly the no-confirmation bug this task replaces. + onClick = viewModel::requestDelete, + enabled = state.selected.isNotEmpty() && !state.loading, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.padding(end = 8.dp), + ) { + Text( + if (state.selected.isEmpty()) { + stringResource(R.string.location_delete_button) + } else { + stringResource(R.string.location_delete_count_button, state.selected.size) + }, + ) + } + } else { + IconButton(onClick = onOpenSearch) { + Icon( + Icons.Default.Search, + contentDescription = stringResource(R.string.storage_overview_search_cd), + ) + } + if (state.locations.isNotEmpty()) { + IconButton(onClick = viewModel::enterEditMode) { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(R.string.storage_overview_edit_cd), + ) + } + } } }, ) }, floatingActionButton = { - FloatingActionButton(modifier = Modifier.navigationBarsPadding(), onClick = { showAddSheet = true }) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.all_storage_add_location_cd)) + if (!state.editMode) { + FloatingActionButton(modifier = Modifier.navigationBarsPadding(), onClick = { showAddSheet = true }) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.all_storage_add_location_cd)) + } } }, ) { padding -> @@ -128,23 +178,13 @@ fun StorageOverviewScreen( .fillMaxSize() .padding(padding), ) { - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .navigationBarsPadding() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Spacer(Modifier.height(4.dp)) - + Column(modifier = Modifier.fillMaxSize()) { if (state.loading) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } state.error?.let { - ErrorRetry(message = it, onRetry = viewModel::refresh) + ErrorRetry(message = it, onRetry = viewModel::refresh, modifier = Modifier.padding(16.dp)) } // Don't show the empty text on a failed load — the ErrorRetry above @@ -153,52 +193,66 @@ fun StorageOverviewScreen( if (state.locations.isEmpty() && !state.loading && state.error == null) { Text( text = stringResource(R.string.storage_overview_empty), - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(16.dp), ) } - state.locations.forEach { location -> - key(location.id) { - // Hoist formatted string for use inside non-composable semantics block - val openDesc = stringResource(R.string.storage_overview_open_cd, location.name) - - val swipeState = - rememberSwipeToDismissBoxState( - confirmValueChange = { value -> - if (value == SwipeToDismissBoxValue.EndToStart) { - pendingDeleteLocation = location - } - false // always snap back; deletion confirmed via dialog + if (state.editMode) { + // Delete now lives behind edit mode: a swipe should not be able + // to destroy a fridge full of food. Keyed by location.id, same + // as LocationDetailScreen's shelf list — the server's reorder + // response replaces this list wholesale (StorageOverviewViewModel.move), + // never merged, so no duplicate key can reach this LazyColumn. + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed(state.locations, key = { _, location -> location.id }) { index, location -> + EditableRow( + name = location.name, + editMode = true, + isSystem = false, + selected = location.id in state.selected, + canMoveUp = index > 0, + canMoveDown = index < state.locations.size - 1, + // Selecting doesn't touch the network, but rename/reorder each + // fire their own request immediately — held off while another + // mutation is in flight so two don't race each other. + actionsEnabled = !state.loading, + onClick = { viewModel.toggleSelection(location.id) }, + onRename = { + renamingLocation = location + renameName = location.name + renameType = location.type }, + onMoveUp = { viewModel.moveUp(location.id) }, + onMoveDown = { viewModel.moveDown(location.id) }, + renameLabelRes = R.string.storage_edit_title, + moveUpLabelRes = R.string.storage_move_up_cd, + moveDownLabelRes = R.string.storage_move_down_cd, ) - SwipeToDismissBox( - state = swipeState, - enableDismissFromStartToEnd = false, - backgroundContent = { - // Only draw the delete affordance mid-swipe: the frosted (translucent) - // cards otherwise let the red container + bin icon bleed through at - // rest, overlapping the row's own trailing controls. - if (swipeState.dismissDirection == SwipeToDismissBoxValue.EndToStart) { - Box( - modifier = - Modifier - .fillMaxSize() - .background( - color = MaterialTheme.colorScheme.errorContainer, - shape = MaterialTheme.shapes.medium, - ), - contentAlignment = Alignment.CenterEnd, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.action_delete), - tint = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.padding(horizontal = 20.dp), - ) - } - } - }, - ) { + } + } + } else { + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Spacer(Modifier.height(4.dp)) + + state.locations.forEach { location -> + // Hoist formatted string for use inside non-composable semantics block + val openDesc = stringResource(R.string.storage_overview_open_cd, location.name) FrostCard( onClick = { onOpenLocation(location.id) }, modifier = @@ -220,38 +274,107 @@ fun StorageOverviewScreen( } } } + + Spacer(Modifier.height(80.dp)) } } - - Spacer(Modifier.height(80.dp)) } } // end PullToRefreshBox } - pendingDeleteLocation?.let { location -> - AlertDialog( - onDismissRequest = { pendingDeleteLocation = null }, - title = { Text(stringResource(R.string.delete_dialog_location_title, location.name)) }, - text = { Text(stringResource(R.string.delete_dialog_location_text)) }, - confirmButton = { - Button( - onClick = { - viewModel.deleteLocation(location.id) - pendingDeleteLocation = null - }, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), - ) { Text(stringResource(R.string.action_delete)) } - }, - dismissButton = { - TextButton(onClick = { pendingDeleteLocation = null }) { Text(stringResource(R.string.action_cancel)) } - }, + // The delete-strategy dialog for the current selection. Non-null pendingDelete + // is the ONLY thing that shows this dialog, and confirmDelete() (the only path + // that actually deletes) is reachable exclusively from its confirm button — the + // top bar's Delete button above only ever calls requestDelete(). + state.pendingDelete?.let { plan -> + DeleteStrategyDialog( + plan = plan, + options = locationStrategyOptions(), + targets = state.moveTargets, + onDismiss = viewModel::cancelDelete, + onConfirm = { strategy, targetId -> viewModel.confirmDelete(strategy, targetId) }, ) } + // Undo snackbar. A snackbar with an action, rather than a one-shot error + // effect (which has no action slot). + val undoLabel = stringResource(R.string.delete_undo) + val deletedMessage = stringResource(R.string.locations_deleted) + LaunchedEffect(state.lastBatchId) { + if (state.lastBatchId == null) return@LaunchedEffect + val result = + snackbarHostState.showSnackbar( + message = deletedMessage, + actionLabel = undoLabel, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + viewModel.undoDelete() + } else { + viewModel.consumeLastBatch() + } + } + + renamingLocation?.let { + ModalBottomSheet( + onDismissRequest = { renamingLocation = null }, + sheetState = sheetState, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(text = stringResource(R.string.storage_edit_title), style = MaterialTheme.typography.titleLarge) + + Text(text = stringResource(R.string.add_storage_type_label)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + STORAGE_TYPES.forEach { type -> + FilterChip( + selected = renameType == type, + onClick = { renameType = type }, + label = { Text(storageTypeLabel(type)) }, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = renameName, + onValueChange = { renameName = it.take(MAX_LOCATION_NAME_LENGTH) }, + label = { Text(stringResource(R.string.add_storage_name_field)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrect = false, imeAction = ImeAction.Done), + modifier = Modifier.weight(1f), + ) + Button( + onClick = { + keyboardController?.hide() + val location = renamingLocation + if (location != null) { + viewModel.rename(location.id, renameName, renameType) + } + renamingLocation = null + }, + enabled = renameName.isNotBlank(), + ) { + Text(stringResource(R.string.action_save)) + } + } + } + } + } + if (showAddSheet) { ModalBottomSheet( onDismissRequest = { showAddSheet = false }, - sheetState = sheetState, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { Column( modifier = diff --git a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt index c5c7070..0386746 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt @@ -6,17 +6,27 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dev.scuttle.inventory.data.HierarchyStore import dev.scuttle.inventory.data.dto.LocationDto import dev.scuttle.inventory.data.error.toUserMessage +import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.LocationDeletion +import dev.scuttle.inventory.data.hierarchy.RestoreRepository import dev.scuttle.inventory.data.location.LocationRepository +import dev.scuttle.inventory.ui.common.orderByPosition +import dev.scuttle.inventory.ui.hierarchy.DeletePlan +import dev.scuttle.inventory.ui.hierarchy.MoveTarget import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.UUID import javax.inject.Inject val STORAGE_TYPES = listOf("freezer", "fridge", "pantry", "other") +/** Server-side location name column limit — clamped here (rename) and in onNewNameChange (create). */ +private const val MAX_LOCATION_NAME_LENGTH = 50 + data class StorageOverviewUiState( val loading: Boolean = false, // Only a user-initiated refresh() flips this — create/delete use `loading` alone, @@ -26,14 +36,31 @@ data class StorageOverviewUiState( val newName: String = "", val newType: String = "freezer", val error: String? = null, + val editMode: Boolean = false, + val selected: Set = emptySet(), + /** Non-null while the delete-strategy dialog is open. */ + val pendingDelete: DeletePlan? = null, + /** + * The household's other live locations — the ONLY signal for whether the + * dialog offers "move" (DeletePlan itself carries no canMove flag; see + * DeletePlan.kt). Empty when the current selection is the household's only + * location(s), so the dialog then correctly offers only delete-or-cancel. + */ + val moveTargets: List = emptyList(), + /** The batch just deleted, for the Undo snackbar. Cleared once consumed. */ + val lastBatchId: String? = null, ) +// The method count is the required surface for this screen (edit mode, reorder, +// rename, strategy-gated delete, undo) — mirrors ShelvesViewModel's own shape, +// which needed the same baseline entry for the same reason (TooManyFunctions). @HiltViewModel class StorageOverviewViewModel @Inject constructor( private val repository: LocationRepository, private val hierarchyStore: HierarchyStore, + private val restoreRepository: RestoreRepository, ) : ViewModel() { private var householdId: Long? = null @@ -49,7 +76,7 @@ class StorageOverviewViewModel } val cached = repository.getCached(householdId) if (cached != null) { - _state.update { it.copy(locations = cached) } + _state.update { it.copy(locations = orderLocations(cached)) } refreshSilent() } else { _state.update { it.copy(locations = emptyList()) } @@ -57,7 +84,8 @@ class StorageOverviewViewModel } } - fun onNewNameChange(value: String) = _state.update { it.copy(newName = value.take(50), error = null) } + fun onNewNameChange(value: String) = + _state.update { it.copy(newName = value.take(MAX_LOCATION_NAME_LENGTH), error = null) } fun onTypeSelect(type: String) = _state.update { it.copy(newType = type) } @@ -65,7 +93,7 @@ class StorageOverviewViewModel val id = householdId ?: return launchLoading(refreshing = true) { val locations = repository.list(id) - _state.update { it.copy(locations = locations) } + _state.update { it.copy(locations = orderLocations(locations)) } } } @@ -75,25 +103,239 @@ class StorageOverviewViewModel if (name.isEmpty()) return launchLoading { val created = repository.create(id, name, _state.value.newType) - _state.update { it.copy(newName = "", locations = it.locations + created) } + _state.update { it.copy(newName = "", locations = orderLocations(it.locations + created)) } + hierarchyStore.refresh() + } + } + + fun enterEditMode() = _state.update { it.copy(editMode = true, selected = emptySet()) } + + fun exitEditMode() = + _state.update { + it.copy( + editMode = false, + selected = emptySet(), + pendingDelete = null, + moveTargets = emptyList(), + ) + } + + fun toggleSelection(locationId: Long) = + _state.update { state -> + val exists = state.locations.any { it.id == locationId } + if (!exists) { + state + } else { + val updated = + if (locationId in state.selected) state.selected - locationId else state.selected + locationId + state.copy(selected = updated) + } + } + + fun rename( + locationId: Long, + name: String, + type: String, + ) { + val id = householdId ?: return + // Clamped here too (not just in the Compose text field), so this method + // is safe to call from anywhere, not only the one wired-up sheet. + val trimmed = name.trim().take(MAX_LOCATION_NAME_LENGTH) + if (trimmed.isEmpty()) return + launchLoading { + val updated = repository.rename(id, locationId, trimmed, type) + _state.update { s -> + s.copy(locations = orderLocations(s.locations.map { if (it.id == locationId) updated else it })) + } hierarchyStore.refresh() } } - fun deleteLocation(locationId: Long) { + fun moveUp(locationId: Long) = move(locationId, -1) + + fun moveDown(locationId: Long) = move(locationId, +1) + + private fun move( + locationId: Long, + delta: Int, + ) { + val id = householdId ?: return + val current = _state.value.locations + val index = current.indexOfFirst { it.id == locationId } + val target = index + delta + if (index < 0 || target !in current.indices) return + + val reordered = current.toMutableList().apply { add(target, removeAt(index)) } + + // Optimistic: the row visibly moves on tap. The server call rewrites + // every position in one transaction, so a failure snaps the whole list + // back rather than leaving it half-sorted. + _state.update { it.copy(locations = reordered) } + + launchLoading { + // The COMPLETE ordered id list — a partial list produces duplicate + // positions server-side (LocationController::reorder rejects any + // list that isn't exactly every live location in this household). + val server = repository.reorder(id, reordered.map { it.id }) + // The server's response IS the full, current location list for + // this household (PATCH .../locations/reorder ends with `return + // $this->index(...)`, same shape as the shelves endpoint). Replace + // local state with it directly — do NOT merge/append anything + // else on top of it, or a location ends up listed twice and the + // list's LazyColumn (keyed by location.id) crashes on the + // duplicate key. (This is the exact bug Task 4 shipped for + // shelves, at the level above: there it was re-merging a local + // systemShelves copy onto a response that already contained it; + // here there is no system entry, so the fix is simply "replace, + // never merge.") + _state.update { it.copy(locations = orderLocations(server)) } + hierarchyStore.refresh() + } + } + + /** + * Open the strategy dialog for the current selection. + * + * Refreshes the location list from the server FIRST, then builds the + * plan off that fresh copy — the same staleness concern as + * ShelvesViewModel.requestDelete: a shelf added to a previously + * shelf-less location via LocationDetailScreen's own add-shelf sheet + * never updates this screen's cached `shelf_count` on its own. + */ + fun requestDelete() { + val id = householdId ?: return + val selectedIds = _state.value.selected + if (selectedIds.isEmpty()) return + + launchLoading { + val locations = orderLocations(repository.list(id)) + _state.update { it.copy(locations = locations) } + + val selected = locations.filter { it.id in selectedIds } + if (selected.isEmpty()) return@launchLoading + + val products = selected.sumOf { it.product_count } + // The trap: what the SERVER counts as "has contents" for a + // LOCATION is its SHELF count, not its product count — a + // location with 3 completely empty shelves still needs a + // strategy. contentCount must come from shelf_count, or this + // delete goes out with no strategy and 422s. productCount still + // feeds the summary line ("N products stored inside"). + val shelves = selected.sumOf { it.shelf_count } + + _state.update { + it.copy( + pendingDelete = + DeletePlan( + itemCount = selected.size, + productCount = products, + contentCount = shelves, + ), + // The household's other live locations: not themselves + // being deleted. This list is the ONLY signal for + // whether "move" is offered — DeletePlan carries no + // canMove flag (see DeletePlan.kt). Empty means the + // dialog hides the move option, so a move is never + // offered with nowhere to go. + moveTargets = + locations + .filter { l -> l.id !in selectedIds } + .map { l -> MoveTarget(id = l.id, name = l.name) }, + ) + } + } + } + + fun confirmDelete( + strategy: LocationDeleteStrategy?, + targetId: Long?, + ) { + val id = householdId ?: return + val state = _state.value + // Same filter requestDelete() applies (and, by the time the dialog + // is open, requestDelete() has already refreshed state.locations): + // only ids that still exist in the live list, so a location that + // vanished from under the user never gets a delete request for an + // id that no longer exists. + val ids = state.locations.filter { it.id in state.selected }.map { it.id } + if (ids.isEmpty()) return + + // ONE batch id for the whole gesture. Deleting three locations is + // three requests; if each minted its own id they would land in + // three batches and Undo would restore only one of them. + val batchId = UUID.randomUUID().toString() + + launchLoading { + val succeeded = mutableListOf() + var failure: Throwable? = null + for (locationId in ids) { + val result = + runCatching { + repository.deleteWithStrategy( + id, + locationId, + LocationDeletion(batchId, strategy, targetId), + ) + } + result.exceptionOrNull()?.let { if (it is CancellationException) throw it } + if (result.isSuccess) { + succeeded += locationId + } else { + // Stop at the first failure: whatever already landed + // server-side must still be reflected and made + // Undo-able, but nothing past the failure was + // attempted, so there is nothing more to reconcile. + failure = result.exceptionOrNull() + break + } + } + + if (succeeded.isNotEmpty()) { + // A partial batch already changed the server's truth, so + // reconcile with a real refresh rather than a local filter + // — and surface Undo for whatever actually landed. + val fresh = orderLocations(repository.list(id)) + _state.update { it.copy(locations = fresh, lastBatchId = batchId) } + hierarchyStore.refresh() + } + + _state.update { + it.copy( + editMode = false, + selected = emptySet(), + pendingDelete = null, + moveTargets = emptyList(), + ) + } + + failure?.let { throw it } + } + } + + fun cancelDelete() = _state.update { it.copy(pendingDelete = null, moveTargets = emptyList()) } + + fun undoDelete() { val id = householdId ?: return + val batchId = _state.value.lastBatchId ?: return launchLoading { - repository.delete(id, locationId) - _state.update { it.copy(locations = it.locations.filter { l -> l.id != locationId }) } + restoreRepository.restore(id, batchId) + _state.update { it.copy(lastBatchId = null) } + refresh() hierarchyStore.refresh() } } + fun consumeLastBatch() = _state.update { it.copy(lastBatchId = null) } + + /** The one hierarchy ordering rule (Task 2): manual position, name tie-break. */ + private fun orderLocations(locations: List): List = + orderByPosition(locations, { it.position }, { it.name }) + private fun refreshSilent() { val id = householdId ?: return viewModelScope.launch { runCatching { repository.list(id) } - .onSuccess { locations -> _state.update { it.copy(locations = locations) } } + .onSuccess { locations -> _state.update { it.copy(locations = orderLocations(locations)) } } } } diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 670fff7..81e93da 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -182,6 +182,11 @@ Nog geen opslaglocaties. Tik op + om een koelkast, vriezer of voorraadkast toe te voegen. Producten zoeken %s openen + Opslag bewerken + Opslaglocatie omhoog verplaatsen + Opslaglocatie omlaag verplaatsen + Opslaglocatie bewerken + Opslaglocaties verwijderd. Opslag toevoegen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4648f1c..2e8a77a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -182,6 +182,11 @@ No storage locations yet. Tap + to add a fridge, freezer or pantry. Search products Open %s + Edit storage + Move storage location up + Move storage location down + Edit storage location + Storage locations deleted. Add storage diff --git a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt index c96af4b..fbe3096 100644 --- a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt @@ -1,20 +1,20 @@ package dev.scuttle.inventory -import android.net.Uri import dev.scuttle.inventory.data.HierarchyStore import dev.scuttle.inventory.data.dto.HouseholdDto import dev.scuttle.inventory.data.dto.LocationDto -import dev.scuttle.inventory.data.dto.ProductDto -import dev.scuttle.inventory.data.dto.ShelfDto +import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.LocationDeletion +import dev.scuttle.inventory.data.hierarchy.RestoreRepository import dev.scuttle.inventory.data.household.HouseholdRepository import dev.scuttle.inventory.data.location.LocationRepository -import dev.scuttle.inventory.data.product.ProductEdit -import dev.scuttle.inventory.data.product.ProductRepository -import dev.scuttle.inventory.data.shelf.ShelfRepository import dev.scuttle.inventory.ui.storage.StorageOverviewViewModel +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -27,6 +27,22 @@ class StorageOverviewViewModelTest { val items = mutableListOf() var failList = false + // Recorded for assertions below — the real LocationRepository interface bundles + // batchId/strategy/targetLocationId into one LocationDeletion (data/hierarchy/DeleteStrategy.kt), + // so each call's deletion is captured whole and unpacked into these lists. + val batchIdsUsed = mutableListOf() + val strategiesUsed = mutableListOf() + val targetIdsUsed = mutableListOf() + val deletedLocationIds = mutableListOf() + var lastRenamedId: Long? = null + var lastReorderIds: List? = null + + // When set, deleteWithStrategy throws for this one id instead of deleting + // it — simulates a mid-batch server failure. + var failDeleteId: Long? = null + + var reorderGate: CompletableDeferred? = null + override fun getCached(householdId: Long): List? = null override suspend fun list(householdId: Long): List { @@ -50,155 +66,101 @@ class StorageOverviewViewModelTest { ) { items.removeIf { it.id == locationId } } + + override suspend fun rename( + householdId: Long, + locationId: Long, + name: String, + type: String, + ): LocationDto { + lastRenamedId = locationId + val index = items.indexOfFirst { it.id == locationId } + val updated = items[index].copy(name = name, type = type) + items[index] = updated + return updated + } + + override suspend fun reorder( + householdId: Long, + ids: List, + ): List { + reorderGate?.await() + lastReorderIds = ids + // Only positions of the given ids change... + ids.forEachIndexed { i, id -> + val index = items.indexOfFirst { it.id == id } + if (index >= 0) items[index] = items[index].copy(position = i) + } + // ...but the FULL location list for the household is returned: + // PATCH .../locations/reorder ends with `return $this->index(...)` + // server-side, same shape as the shelves endpoint. A fake that + // returned only the reordered subset would hide the exact bug this + // fake exists to catch (the VM merging its own local pre-reorder + // copy on top of the one the server already sent back — a + // duplicate id that crashes a LazyColumn keyed by location.id). + return items.toList() + } + + override suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + deletion: LocationDeletion, + ) { + if (locationId == failDeleteId) throw RuntimeException("delete failed") + batchIdsUsed += deletion.batchId + strategiesUsed += deletion.strategy + targetIdsUsed += deletion.targetLocationId + deletedLocationIds += locationId + items.removeIf { it.id == locationId } + } + } + + private class FakeRestoreRepository : RestoreRepository { + var lastHouseholdId: Long? = null + var lastBatchId: String? = null + var restoreCalls = 0 + + override suspend fun restore( + householdId: Long, + batchId: String, + ): Int { + lastHouseholdId = householdId + lastBatchId = batchId + restoreCalls++ + return 1 + } } - private fun emptyStore() = - HierarchyStore( - object : HouseholdRepository { - override fun getCached() = emptyList() - - override suspend fun list() = emptyList() - - override suspend fun create(name: String) = HouseholdDto(1, name, "") - - override suspend fun join(code: String) = HouseholdDto(1, "", code) - - override suspend fun leave(householdId: Long) {} - }, - object : LocationRepository { - override fun getCached(householdId: Long) = emptyList() - - override suspend fun list(householdId: Long) = emptyList() - - override suspend fun create( - householdId: Long, - name: String, - type: String, - ) = LocationDto(1, name, type) - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} - }, - object : ShelfRepository { - override fun getCached( - householdId: Long, - locationId: Long, - ) = emptyList() - - override suspend fun list( - householdId: Long, - locationId: Long, - ) = emptyList() - - override suspend fun create( - householdId: Long, - locationId: Long, - name: String, - ) = ShelfDto(1, name, 0, locationId) - - override suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) {} - }, - object : ProductRepository { - override fun getCached( - householdId: Long, - shelfId: Long, - ) = emptyList() - - override suspend fun list( - householdId: Long, - shelfId: Long, - ) = emptyList() - - override suspend fun create( - householdId: Long, - shelfId: Long, - name: String, - quantity: Int, - code: String?, - ) = ProductDto( - 1, - name, - quantity, - shelfId, - ) - - override suspend fun update( - householdId: Long, - shelfId: Long, - productId: Long, - edit: ProductEdit, - ) = ProductDto(productId, edit.name, 0, shelfId) - - override suspend fun add( - householdId: Long, - shelfId: Long, - productId: Long, - amount: Int, - ) = ProductDto(productId, "", 0, shelfId) - - override suspend fun remove( - householdId: Long, - shelfId: Long, - productId: Long, - amount: Int, - ) = ProductDto( - productId, - "", - 0, - shelfId, - ) - - override suspend fun move( - householdId: Long, - shelfId: Long, - productId: Long, - targetShelfId: Long, - ) = ProductDto( - productId, - "", - 0, - targetShelfId, - ) - - override suspend fun delete( - householdId: Long, - shelfId: Long, - productId: Long, - ) {} - - override suspend fun uploadImage( - householdId: Long, - shelfId: Long, - productId: Long, - imageUri: Uri, - mimeType: String, - ) = ProductDto( - productId, - "", - 0, - shelfId, - ) - }, - ) + private class FakeHouseholdRepository : HouseholdRepository { + override fun getCached(): List? = null + + override suspend fun list(): List = emptyList() + + override suspend fun create(name: String) = throw NotImplementedError() + + override suspend fun join(code: String) = throw NotImplementedError() + + override suspend fun leave(householdId: Long) = Unit + } + + private fun viewModel( + repo: LocationRepository = FakeLocationRepository(), + restoreRepository: RestoreRepository = FakeRestoreRepository(), + hierarchyStore: HierarchyStore = TestHierarchy.store(FakeHouseholdRepository()), + ): StorageOverviewViewModel = StorageOverviewViewModel(repo, hierarchyStore, restoreRepository) @Test fun load_populates_locations() = runTest { val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Chest", "freezer")) } - val viewModel = StorageOverviewViewModel(repo, emptyStore()) + val vm = viewModel(repo) - viewModel.load(householdId = 1) + vm.load(householdId = 1) - assertEquals(1, viewModel.state.value.locations.size) + assertEquals(1, vm.state.value.locations.size) assertEquals( "Chest", - viewModel.state.value.locations + vm.state.value.locations .first() .name, ) @@ -207,31 +169,31 @@ class StorageOverviewViewModelTest { @Test fun create_adds_a_location_with_the_selected_type() = runTest { - val viewModel = StorageOverviewViewModel(FakeLocationRepository(), emptyStore()) - viewModel.load(householdId = 1) - viewModel.onNewNameChange("Pantry") - viewModel.onTypeSelect("pantry") + val vm = viewModel() + vm.load(householdId = 1) + vm.onNewNameChange("Pantry") + vm.onTypeSelect("pantry") - viewModel.create() + vm.create() - val locations = viewModel.state.value.locations + val locations = vm.state.value.locations assertTrue(locations.any { it.name == "Pantry" && it.type == "pantry" }) - assertEquals("", viewModel.state.value.newName) + assertEquals("", vm.state.value.newName) } @Test fun list_failure_surfaces_an_error() = runTest { val repo = FakeLocationRepository().apply { failList = true } - val viewModel = StorageOverviewViewModel(repo, emptyStore()) + val vm = viewModel(repo) - viewModel.load(householdId = 1) + vm.load(householdId = 1) - assertEquals("offline", viewModel.state.value.error) + assertEquals("offline", vm.state.value.error) } /** - * T9: the pull-to-refresh spinner (`refreshing`) must fire only on a user + * The pull-to-refresh spinner (`refreshing`) must fire only on a user * refresh, not on mutations. A gated repo lets us inspect the in-flight state: * create() should show `loading` without `refreshing`, refresh() should show both. */ @@ -239,33 +201,33 @@ class StorageOverviewViewModelTest { fun refresh_flags_the_pull_spinner_but_create_does_not() = runTest { val gate = GatedLocationRepository() - val viewModel = StorageOverviewViewModel(gate, emptyStore()) + val vm = viewModel(gate) gate.release() // let the initial cache-miss load settle - viewModel.load(householdId = 1) + vm.load(householdId = 1) gate.reset() // A mutation is in flight: generic loading is on, the pull spinner is not. - viewModel.onNewNameChange("Pantry") - viewModel.create() - assertTrue("mutation should set loading", viewModel.state.value.loading) - assertFalse("mutation must NOT set the pull spinner", viewModel.state.value.refreshing) + vm.onNewNameChange("Pantry") + vm.create() + assertTrue("mutation should set loading", vm.state.value.loading) + assertFalse("mutation must NOT set the pull spinner", vm.state.value.refreshing) gate.release() - assertFalse(viewModel.state.value.refreshing) + assertFalse(vm.state.value.refreshing) // A user refresh is in flight: the pull spinner is on. gate.reset() - viewModel.refresh() - assertTrue("refresh should set the pull spinner", viewModel.state.value.refreshing) + vm.refresh() + assertTrue("refresh should set the pull spinner", vm.state.value.refreshing) gate.release() - assertFalse(viewModel.state.value.refreshing) + assertFalse(vm.state.value.refreshing) } /** A LocationRepository whose suspend calls block on a gate until released. */ private class GatedLocationRepository : LocationRepository { - private var gate = kotlinx.coroutines.CompletableDeferred() + private var gate = CompletableDeferred() fun reset() { - gate = kotlinx.coroutines.CompletableDeferred() + gate = CompletableDeferred() } fun release() { @@ -293,4 +255,542 @@ class StorageOverviewViewModelTest { locationId: Long, ) = gate.await() } + + @Test + fun locations_are_displayed_in_position_order_not_repository_order() = + runTest { + // Discriminates against a VM that just passes the repository's list + // through untouched instead of applying orderByPosition (Task 2). + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Bottom", "pantry", 2)) + items.add(LocationDto(2, "Top", "freezer", 0)) + items.add(LocationDto(3, "Middle", "fridge", 1)) + } + val vm = viewModel(repo) + + vm.load(householdId = 1) + + assertEquals( + listOf("Top", "Middle", "Bottom"), + vm.state.value.locations + .map { it.name }, + ) + } + + @Test + fun move_up_swaps_a_location_with_the_one_above_it() = + runTest { + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Middle", "fridge", 1)) + items.add(LocationDto(3, "Bottom", "pantry", 2)) + } + val vm = viewModel(repo) + vm.load(householdId = 1) + + vm.moveUp(3L) + + assertEquals( + listOf("Top", "Bottom", "Middle"), + vm.state.value.locations + .map { it.name }, + ) + } + + @Test + fun move_down_swaps_a_location_with_the_one_below_it() = + runTest { + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Middle", "fridge", 1)) + items.add(LocationDto(3, "Bottom", "pantry", 2)) + } + val vm = viewModel(repo) + vm.load(householdId = 1) + + vm.moveDown(1L) + + assertEquals( + listOf("Middle", "Top", "Bottom"), + vm.state.value.locations + .map { it.name }, + ) + } + + @Test + fun move_up_on_the_first_location_does_nothing() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val vm = viewModel(repo) + vm.load(householdId = 1) + + vm.moveUp(1L) + + assertEquals( + listOf("Top"), + vm.state.value.locations + .map { it.name }, + ) + } + + @Test + fun move_down_on_the_last_location_does_nothing() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val vm = viewModel(repo) + vm.load(householdId = 1) + + vm.moveDown(1L) + + assertEquals( + listOf("Top"), + vm.state.value.locations + .map { it.name }, + ) + } + + @Test + fun reordering_sends_the_complete_ordered_id_list() = + runTest { + // A partial list produces duplicate positions server-side — the + // server rejects a reorder unless every live location is present. + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Middle", "fridge", 1)) + items.add(LocationDto(3, "Bottom", "pantry", 2)) + } + val vm = viewModel(repo) + vm.load(householdId = 1) + + vm.moveUp(3L) + + assertEquals(listOf(1L, 3L, 2L), repo.lastReorderIds) + } + + @Test + fun reorder_replaces_local_state_with_the_servers_full_response_not_merged() = + runTest { + // THE Task-4 bug, at the locations level: the server's reorder + // response is already the COMPLETE, current location list (see + // FakeLocationRepository.reorder's own comment). A VM that merges + // that response with its own local pre-reorder copy — instead of + // just replacing state with it — duplicates every location, and a + // LazyColumn keyed by location.id crashes on the very first reorder. + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Middle", "fridge", 1)) + items.add(LocationDto(3, "Bottom", "pantry", 2)) + } + val vm = viewModel(repo) + vm.load(householdId = 1) + + vm.moveUp(3L) + + val ids = + vm.state.value.locations + .map { it.id } + assertEquals(3, ids.size) + assertEquals(ids.size, ids.toSet().size) + assertEquals( + listOf("Top", "Bottom", "Middle"), + vm.state.value.locations + .map { it.name }, + ) + } + + @Test + fun the_optimistic_reorder_frame_is_visible_before_the_server_confirms() = + runTest { + // Under the test dispatcher the fake's response normally lands + // synchronously and overwrites the optimistic frame before any + // assertion can observe it — gating the fake's reorder() call + // parks the coroutine mid-flight so the optimistic frame itself + // can be inspected. + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Middle", "fridge", 1)) + items.add(LocationDto(3, "Bottom", "pantry", 2)) + } + val vm = viewModel(repo) + vm.load(householdId = 1) + repo.reorderGate = CompletableDeferred() + + vm.moveUp(3L) + + // The server call is parked on the gate — this IS the optimistic frame. + assertEquals( + listOf("Top", "Bottom", "Middle"), + vm.state.value.locations + .map { it.name }, + ) + + repo.reorderGate?.complete(Unit) + + assertEquals( + listOf("Top", "Bottom", "Middle"), + vm.state.value.locations + .map { it.name }, + ) + } + + @Test + fun request_delete_does_not_delete_anything_until_confirmed() = + runTest { + // The confirm dialog can never be bypassed. Selecting + requesting + // must NOT mutate the repository or the list — only an explicit + // confirmDelete() may do that. + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Fridge", "fridge", 0, 0, 3)) } + val vm = viewModel(repo) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + + vm.requestDelete() + + assertEquals(1, vm.state.value.locations.size) + assertTrue(repo.batchIdsUsed.isEmpty()) + assertNotNull(vm.state.value.pendingDelete) + assertEquals( + 3, + vm.state.value.pendingDelete + ?.productCount, + ) + } + + @Test + fun deleting_a_location_whose_shelves_are_EMPTY_still_needs_a_strategy() = + runTest { + // The trap: the server asks about a location's SHELVES, not its + // products. A location with 3 empty shelves has product_count 0 + // but shelf_count 3 — contentCount must be built from shelf_count, + // or this delete goes out with no strategy and 422s. + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Fridge", "fridge", shelf_count = 3, product_count = 0)) + items.add(LocationDto(2, "Pantry", "pantry", shelf_count = 0, product_count = 0)) + } + val vm = viewModel(repo) + vm.load(householdId = 1) + + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + + assertNotNull(vm.state.value.pendingDelete) + assertEquals( + 3, + vm.state.value.pendingDelete + ?.contentCount, + ) + assertEquals( + 0, + vm.state.value.pendingDelete + ?.productCount, + ) + assertTrue( + vm.state.value.pendingDelete + ?.needsStrategy == true, + ) + } + + @Test + fun requesting_delete_refreshes_the_location_list_so_a_stale_shelf_count_cannot_skip_the_strategy_prompt() = + runTest { + // Simulates a shelf added via LocationDetailScreen's own add-shelf + // sheet — a mutation this screen's cached shelf_count never hears + // about (see LocationDto.shelf_count's own doc comment). + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Fridge", "fridge", shelf_count = 0, product_count = 0)) + } + val vm = viewModel(repo) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + + // The server-side shelf count changed through a path this VM never + // observes; its own cached copy (shelf_count = 0) hasn't. + repo.items[0] = repo.items[0].copy(shelf_count = 1) + + vm.requestDelete() + + assertNotNull(vm.state.value.pendingDelete) + assertEquals( + 1, + vm.state.value.pendingDelete + ?.contentCount, + ) + assertTrue( + vm.state.value.pendingDelete + ?.needsStrategy == true, + ) + } + + @Test + fun deleting_the_only_location_offers_no_move_target() = + runTest { + // There is nowhere to move the contents to, so the dialog must not + // dangle a "move" option that cannot work. The empty target list IS + // the mechanism (DeleteStrategyDialog hides a target-requiring option + // when handed no targets) — there is no canMove flag to assert on. + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Fridge", "fridge", 0)) } + val vm = viewModel(repo) + vm.load(householdId = 1) + + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + + assertTrue( + vm.state.value.moveTargets + .isEmpty(), + ) + } + + @Test + fun requesting_delete_offers_only_live_non_selected_locations_as_move_targets() = + runTest { + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Fridge", "fridge", 0)) + items.add(LocationDto(2, "Freezer", "freezer", 1)) + items.add(LocationDto(3, "Pantry", "pantry", 2)) + } + val vm = viewModel(repo) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + + vm.requestDelete() + + // Not "Fridge" (being deleted) — "Freezer" and "Pantry" only. + assertEquals( + listOf(2L, 3L), + vm.state.value.moveTargets + .map { it.id }, + ) + } + + @Test + fun requesting_delete_with_no_selection_does_nothing() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Fridge", "fridge", 0)) } + val vm = viewModel(repo) + vm.load(householdId = 1) + vm.enterEditMode() + + vm.requestDelete() + + assertNull(vm.state.value.pendingDelete) + } + + @Test + fun a_rename_updates_the_row_in_place() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Freezr", "freezer", 0)) } + val vm = viewModel(repo) + vm.load(householdId = 1) + + vm.rename(1L, "Freezer", "freezer") + + assertEquals( + "Freezer", + vm.state.value.locations + .first() + .name, + ) + assertEquals(1L, repo.lastRenamedId) + } + + @Test + fun confirming_a_delete_sends_the_chosen_strategy_and_one_shared_batch_id() = + runTest { + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Bottom", "fridge", 1)) + items.add(LocationDto(3, "Middle", "pantry", 2)) + } + val vm = viewModel(repo) + vm.load(householdId = 1) + + vm.enterEditMode() + vm.toggleSelection(1L) + vm.toggleSelection(2L) + vm.toggleSelection(3L) + vm.requestDelete() + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + + assertEquals(3, repo.batchIdsUsed.size) + assertTrue(repo.strategiesUsed.all { it == LocationDeleteStrategy.DELETE_CONTENTS }) + // Three locations deleted together = three requests carrying ONE + // batch id, so one Undo restores all three. A per-request id would + // split one gesture into three batches and Undo would restore only one. + assertEquals(1, repo.batchIdsUsed.toSet().size) + assertEquals(repo.batchIdsUsed.first(), vm.state.value.lastBatchId) + assertTrue( + vm.state.value.locations + .isEmpty(), + ) + assertFalse(vm.state.value.editMode) + assertNull(vm.state.value.pendingDelete) + } + + @Test + fun confirming_a_move_delete_sends_the_target_location_id() = + runTest { + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Middle", "fridge", 1)) + } + val vm = viewModel(repo) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + + vm.confirmDelete(LocationDeleteStrategy.MOVE_CONTENTS, targetId = 2L) + + assertEquals(listOf(2L), repo.targetIdsUsed) + } + + @Test + fun a_batch_delete_that_partially_fails_still_surfaces_undo_for_what_landed() = + runTest { + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Middle", "fridge", 1)) + items.add(LocationDto(3, "Bottom", "pantry", 2)) + failDeleteId = 2L + } + val vm = viewModel(repo) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.toggleSelection(2L) + vm.toggleSelection(3L) + vm.requestDelete() + + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + + assertNotNull(vm.state.value.lastBatchId) + assertEquals(listOf(1L), repo.deletedLocationIds) + assertEquals( + setOf(2L, 3L), + vm.state.value.locations + .map { it.id } + .toSet(), + ) + assertNotNull(vm.state.value.error) + } + + @Test + fun confirming_delete_ignores_a_selected_id_no_longer_in_the_live_location_list() = + runTest { + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Middle", "fridge", 1)) + } + val vm = viewModel(repo) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.toggleSelection(2L) + vm.requestDelete() + + // Location 2 disappears server-side (another device) while the + // dialog is open; a refresh updates state.locations but never + // touches state.selected. + repo.items.removeIf { it.id == 2L } + vm.refresh() + + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + + assertEquals(listOf(1L), repo.deletedLocationIds) + } + + @Test + fun undo_delete_restores_the_batch_and_clears_it() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val restore = FakeRestoreRepository() + val vm = viewModel(repo, restoreRepository = restore) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + val batchId = vm.state.value.lastBatchId + assertNotNull(batchId) + + vm.undoDelete() + + assertEquals(1L, restore.lastHouseholdId) + assertEquals(batchId, restore.lastBatchId) + assertNull(vm.state.value.lastBatchId) + } + + @Test + fun consume_last_batch_clears_it_without_restoring() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val restore = FakeRestoreRepository() + val vm = viewModel(repo, restoreRepository = restore) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + + vm.consumeLastBatch() + + assertNull(vm.state.value.lastBatchId) + assertEquals(0, restore.restoreCalls) + } + + @Test + fun cancel_delete_closes_the_dialog_without_deleting_anything() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val vm = viewModel(repo) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + + vm.cancelDelete() + + assertNull(vm.state.value.pendingDelete) + assertEquals(1, vm.state.value.locations.size) + assertTrue(repo.batchIdsUsed.isEmpty()) + } + + @Test + fun exiting_edit_mode_clears_selection_and_any_pending_delete() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val vm = viewModel(repo) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + + vm.exitEditMode() + + assertFalse(vm.state.value.editMode) + assertTrue( + vm.state.value.selected + .isEmpty(), + ) + assertNull(vm.state.value.pendingDelete) + } } From d04304ae7de8adc3271e13c48c7e45cd9c47c009 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 00:46:40 +0200 Subject: [PATCH 15/40] refactor: self-review cleanup on the locations edit-mode diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the renamingLocation lambda parameter directly instead of re-reading the mutable var inside the Save button's onClick (matches LocationDetailScreen's renamingShelf idiom); update EditableRow's KDoc now that it's genuinely shared between the shelves and locations screens rather than shelf-specific. No behavior change — confirmed via the full test/ktlint/detekt gate rerun and an independent review pass. --- .../scuttle/inventory/ui/hierarchy/EditableRow.kt | 15 +++++++++------ .../inventory/ui/storage/StorageOverviewScreen.kt | 7 ++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt index 6383e17..a84bfcf 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt @@ -22,13 +22,16 @@ import dev.scuttle.inventory.R import dev.scuttle.inventory.ui.theme.FrostCard /** - * One row of the shelves list view. + * One row of an editable hierarchy list — shared by the shelves list + * (LocationDetailScreen) and the locations list (StorageOverviewScreen). * - * Outside edit mode this is a plain [FrostCard]: tapping it opens the shelf, same - * as tapping its tab did before. In edit mode the row grows a leading [Checkbox] - * (selection for the batch delete/confirm flow), a rename pencil, and trailing - * up/down [IconButton]s for the manual drag order — all three gated off for the - * Unsorted shelf via [isSystem], since it can't be renamed, selected, or reordered. + * Outside edit mode this is a plain [FrostCard]: tapping it opens the row's + * target, same as before edit mode existed. In edit mode the row grows a + * leading [Checkbox] (selection for the batch delete/confirm flow), a rename + * pencil, and trailing up/down [IconButton]s for the manual drag order — all + * three gated off via [isSystem] for a row that can't be renamed, selected, or + * reordered (the shelves screen's "Unsorted" shelf; locations have no + * equivalent and always pass `isSystem = false`). */ @Composable fun EditableRow( diff --git a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt index 2cb803a..8eaf38b 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt @@ -315,7 +315,7 @@ fun StorageOverviewScreen( } } - renamingLocation?.let { + renamingLocation?.let { location -> ModalBottomSheet( onDismissRequest = { renamingLocation = null }, sheetState = sheetState, @@ -356,10 +356,7 @@ fun StorageOverviewScreen( Button( onClick = { keyboardController?.hide() - val location = renamingLocation - if (location != null) { - viewModel.rename(location.id, renameName, renameType) - } + viewModel.rename(location.id, renameName, renameType) renamingLocation = null }, enabled = renameName.isNotBlank(), From 14a98ed1a73beeba5397a1e1813030398186d255 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 01:10:39 +0200 Subject: [PATCH 16/40] fix: route Home's swipe-to-delete through the strategy dialog (Task 5b) Home's swipe-to-delete (AllStoragesScreen -> DrawerViewModel.deleteLocation) was the second location-delete entry point on this branch, and nobody wired it up when DeleteLocationRequest made deletion_batch_id unconditionally required and a strategy required whenever a location has shelves. Every swipe-to-delete from the Home dashboard 422s on this branch as shipped. - AllStoragesScreen: swipe gesture removed; delete now lives behind an Edit toggle (per-row trash icon, since Home spans every household and a cross-household batch-select has no coherent move-target semantics), routed through the same DeleteStrategyDialog/locationStrategyOptions Task 5 uses for StorageOverviewScreen. - DrawerViewModel: deleteLocation() replaced with requestDelete/ confirmDelete/cancelDelete/undoDelete, mirroring StorageOverviewViewModel - mints one deletion_batch_id per gesture, sends LocationDeleteStrategy via LocationRepository.deleteWithStrategy(), and wires Undo through RestoreRepository. contentCount is built from shelf_count (not product_count) and requestDelete() re-fetches locations fresh rather than trusting HierarchyStore's cached entries, closing the same staleness gap Task 5 closed one screen over. - LocationRepository.delete() (the bodyless path) is now doc-commented as 422-always so a third screen doesn't make the same mistake. - Two now-dead strings (delete_dialog_location_title/text) removed from both locales. DrawerViewModelTest rewritten: 16 tests, including mutation-verified coverage of the batch-id/strategy requirement, the shelf_count-vs- product_count trap, the fresh-fetch staleness guard, and cross-household move-target isolation. FakeLocationRepository's delete() now throws (models the real DeleteLocationRequest 422) instead of silently succeeding, per the Task-4-crash lesson about fakes that lie about the server. --- .../data/location/LocationRepository.kt | 9 + .../inventory/ui/app/DrawerViewModel.kt | 177 +++++++- .../inventory/ui/home/AllStoragesScreen.kt | 246 +++++------ app/src/main/res/values-nl/strings.xml | 2 - app/src/main/res/values/strings.xml | 2 - .../scuttle/inventory/DrawerViewModelTest.kt | 402 +++++++++++++++--- 6 files changed, 639 insertions(+), 199 deletions(-) diff --git a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt index 04bdb94..ca8b9be 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt @@ -14,6 +14,15 @@ interface LocationRepository { type: String, ): LocationDto + /** + * Bodyless delete — sends no `deletion_batch_id`. The server's + * DeleteLocationRequest requires it unconditionally, so calling this + * directly from a screen 422s every time. Every UI entry point (Storage + * overview's edit mode, Home's swipe-replacement edit mode) must go + * through [deleteWithStrategy] instead — see its own doc comment. Kept on + * the interface only for fakes/tests that exercise failure paths without + * needing the strategy machinery; do not wire a Composable to it. + */ suspend fun delete( householdId: Long, locationId: Long, diff --git a/app/src/main/java/dev/scuttle/inventory/ui/app/DrawerViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/app/DrawerViewModel.kt index e3a4451..9b3b3f7 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/app/DrawerViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/app/DrawerViewModel.kt @@ -6,15 +6,22 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dev.scuttle.inventory.data.HierarchyStore import dev.scuttle.inventory.data.HouseholdWithLocations import dev.scuttle.inventory.data.error.toUserMessage +import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.LocationDeletion +import dev.scuttle.inventory.data.hierarchy.RestoreRepository import dev.scuttle.inventory.data.location.LocationRepository +import dev.scuttle.inventory.ui.hierarchy.DeletePlan +import dev.scuttle.inventory.ui.hierarchy.MoveTarget import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.UUID import javax.inject.Inject data class DrawerUiState( @@ -27,6 +34,29 @@ data class DrawerUiState( // apart from a genuinely empty account (W3) — without this a failed load // rendered the "No storages yet" empty state. val error: String? = null, + /** Non-null while the delete-strategy dialog is open for a Home location. */ + val pendingDelete: DeletePlan? = null, + /** + * The location's household's other live locations — the ONLY signal for + * whether the dialog offers "move" (DeletePlan itself carries no canMove + * flag; see DeletePlan.kt). Empty when the household has no other location + * to move contents into. + */ + val moveTargets: List = emptyList(), + /** The batch just deleted, for the Undo snackbar. Cleared once consumed. */ + val lastBatchId: String? = null, +) + +/** + * The delete-strategy flow's own bit of state, combined with [HierarchyStore]'s + * state to build [DrawerUiState]. Kept separate from the household/location + * projection below because it has nothing to do with what HierarchyStore knows — + * it is local to the dialog Home opens for one location at a time. + */ +private data class DeleteFlowState( + val pendingDelete: DeletePlan? = null, + val moveTargets: List = emptyList(), + val lastBatchId: String? = null, ) @HiltViewModel @@ -35,22 +65,35 @@ class DrawerViewModel constructor( private val store: HierarchyStore, private val locationRepository: LocationRepository, + private val restoreRepository: RestoreRepository, ) : ViewModel() { + private val deleteFlow = MutableStateFlow(DeleteFlowState()) + val state: StateFlow = - store.state - .map { s -> - DrawerUiState( - entries = s.entries, - locationWarnings = s.locationWarnings, - missingItemCount = s.missingItemCount, - loading = s.loading, - refreshing = s.refreshing, - error = s.error, - ) - }.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState()) + combine(store.state, deleteFlow) { s, del -> + DrawerUiState( + entries = s.entries, + locationWarnings = s.locationWarnings, + missingItemCount = s.missingItemCount, + loading = s.loading, + refreshing = s.refreshing, + error = s.error, + pendingDelete = del.pendingDelete, + moveTargets = del.moveTargets, + lastBatchId = del.lastBatchId, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState()) private var deleteJob: Job? = null + // Which location the open strategy dialog (or the last completed batch) + // belongs to. Kept out of DrawerUiState/DeleteFlowState on purpose: the + // dialog only ever needs the DeletePlan + moveTargets, and Undo only + // ever needs to replay these two ids against RestoreRepository. + private var pendingHouseholdId: Long? = null + private var pendingLocationId: Long? = null + private var lastBatchHouseholdId: Long? = null + // One-shot delete failure, surfaced by AllStorages as a snackbar (W10). Kept // separate from the store-derived load `error` above because a delete fails // while the list is populated — the inline ErrorRetry (empty-state only) @@ -60,19 +103,121 @@ class DrawerViewModel fun refresh() = store.refresh(userInitiated = true) - fun deleteLocation( + /** + * Opens the delete-strategy dialog for ONE location. + * + * Home deletes one location per gesture, unlike StorageOverviewScreen's + * batch selection — there is no cross-household batch semantics to build + * here: a MOVE_CONTENTS target has to live in the SAME household as what's + * being deleted, and a selection spanning households would have no single + * household to draw those targets from. + * + * Reads a FRESH copy of the household's locations from the server first — + * the same staleness concern StorageOverviewViewModel.requestDelete() + * guards against: a shelf added via LocationDetailScreen's own add-shelf + * sheet never updates this location's shelf_count in HierarchyStore's + * cached `entries` on its own (see LocationDto.shelf_count's own doc + * comment, which calls out HierarchyStore.refresh() as the fix for a + * screen that only ever reads HierarchyStore's cache). Going straight to + * `locationRepository.list()` buys the same freshness guarantee without + * paying for a full households+shelves+products reload just to open a + * confirmation dialog. + */ + fun requestDelete( householdId: Long, locationId: Long, ) { if (deleteJob?.isActive == true) return deleteJob = viewModelScope.launch { - runCatching { locationRepository.delete(householdId, locationId) } - .onSuccess { store.refresh() } - .onFailure { e -> _actionError.value = e.toUserMessage("Failed to delete location.") } + runCatching { locationRepository.list(householdId) } + .onSuccess { locations -> + val location = locations.firstOrNull { it.id == locationId } ?: return@onSuccess + pendingHouseholdId = householdId + pendingLocationId = locationId + deleteFlow.update { + it.copy( + pendingDelete = + DeletePlan( + itemCount = 1, + productCount = location.product_count, + // The trap: the server asks about a + // location's SHELVES, not its products + // (see DeletePlan.kt) — a location with + // only empty shelves still needs a + // strategy. Do not "simplify" this to + // productCount. + contentCount = location.shelf_count, + ), + moveTargets = + locations + .filter { l -> l.id != locationId } + .map { l -> MoveTarget(id = l.id, name = l.name) }, + ) + } + }.onFailure { e -> _actionError.value = e.toUserMessage("Failed to delete location.") } + } + } + + fun confirmDelete( + strategy: LocationDeleteStrategy?, + targetId: Long?, + ) { + val householdId = pendingHouseholdId + val locationId = pendingLocationId + if (householdId == null || locationId == null || deleteJob?.isActive == true) return + + // Client-minted, one id for the whole gesture — Home only ever deletes + // one location per gesture, but the id still has to exist for Undo to + // find this delete again (the server requires deletion_batch_id on + // every delete, empty container or not). + val batchId = UUID.randomUUID().toString() + + deleteJob = + viewModelScope.launch { + runCatching { + locationRepository.deleteWithStrategy( + householdId, + locationId, + LocationDeletion(batchId, strategy, targetId), + ) + }.onSuccess { + lastBatchHouseholdId = householdId + deleteFlow.update { + it.copy(pendingDelete = null, moveTargets = emptyList(), lastBatchId = batchId) + } + store.refresh() + }.onFailure { e -> + deleteFlow.update { it.copy(pendingDelete = null, moveTargets = emptyList()) } + _actionError.value = e.toUserMessage("Failed to delete location.") + } + pendingHouseholdId = null + pendingLocationId = null + } + } + + fun cancelDelete() { + pendingHouseholdId = null + pendingLocationId = null + deleteFlow.update { it.copy(pendingDelete = null, moveTargets = emptyList()) } + } + + fun undoDelete() { + val householdId = lastBatchHouseholdId + val batchId = deleteFlow.value.lastBatchId + if (householdId == null || batchId == null || deleteJob?.isActive == true) return + deleteJob = + viewModelScope.launch { + runCatching { restoreRepository.restore(householdId, batchId) } + .onSuccess { + deleteFlow.update { it.copy(lastBatchId = null) } + store.refresh() + }.onFailure { e -> _actionError.value = e.toUserMessage("Failed to undo delete.") } } } + fun consumeLastBatch() = deleteFlow.update { it.copy(lastBatchId = null) } + fun consumeActionError() { _actionError.value = null } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt index 3424e5d..f489417 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt @@ -1,8 +1,6 @@ package dev.scuttle.inventory.ui.home -import androidx.compose.foundation.background 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.Spacer @@ -18,13 +16,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.StarOutline -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -32,21 +28,22 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -56,12 +53,12 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import dev.scuttle.inventory.R -import dev.scuttle.inventory.data.HouseholdWithLocations -import dev.scuttle.inventory.data.dto.LocationDto import dev.scuttle.inventory.ui.app.DrawerViewModel import dev.scuttle.inventory.ui.common.ErrorRetry import dev.scuttle.inventory.ui.common.SnackbarErrorEffect import dev.scuttle.inventory.ui.common.storageTypeLabel +import dev.scuttle.inventory.ui.hierarchy.DeleteStrategyDialog +import dev.scuttle.inventory.ui.hierarchy.locationStrategyOptions import dev.scuttle.inventory.ui.theme.FrostCard @OptIn(ExperimentalMaterial3Api::class) @@ -77,10 +74,16 @@ fun AllStoragesScreen( val state by viewModel.state.collectAsState() val localState by localViewModel.state.collectAsState() val actionError by viewModel.actionError.collectAsState() - var pendingDelete by remember { mutableStateOf?>(null) } + // Delete now lives behind edit mode: a swipe should not be able to destroy a + // fridge full of food. Mirrors StorageOverviewScreen's own edit-mode gate + // (Task 5) — kept as plain Compose state here rather than in DrawerViewModel + // because it's purely a "which icon does this row show" toggle, not a + // network-backed concern the way pendingDelete/moveTargets below are. + var editMode by rememberSaveable { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } - // Surface a failed swipe-to-delete as a transient snackbar so it isn't silent (W10). + // Surfaces a failed delete (or a failed undo) as a transient snackbar so it + // isn't silent (W10). SnackbarErrorEffect(actionError, snackbarHostState, onConsumed = viewModel::consumeActionError) val statusBarInsets = WindowInsets.statusBars @@ -93,11 +96,25 @@ fun AllStoragesScreen( windowInsets = statusBarInsets, title = { Text(stringResource(R.string.all_storage_title)) }, actions = { - IconButton(onClick = { viewModel.refresh() }) { - Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.action_refresh)) - } - IconButton(onClick = onOpenSettings) { - Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.action_settings)) + if (editMode) { + TextButton(onClick = { editMode = false }) { + Text(stringResource(R.string.action_cancel)) + } + } else { + if (state.entries.any { it.locations.isNotEmpty() }) { + IconButton(onClick = { editMode = true }) { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(R.string.storage_overview_edit_cd), + ) + } + } + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.action_refresh)) + } + IconButton(onClick = onOpenSettings) { + Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.action_settings)) + } } }, ) @@ -142,11 +159,13 @@ fun AllStoragesScreen( style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - IconButton(onClick = { onOpenStorage(entry.id) }) { - Icon( - Icons.Default.Add, - contentDescription = stringResource(R.string.all_storage_add_location_cd), - ) + if (!editMode) { + IconButton(onClick = { onOpenStorage(entry.id) }) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(R.string.all_storage_add_location_cd), + ) + } } } @@ -155,75 +174,46 @@ fun AllStoragesScreen( val hasWarning = state.locationWarnings[location.id] == true val isFavorite = location.id in localState.favoriteLocationIds - val swipeState = - rememberSwipeToDismissBoxState( - confirmValueChange = { value -> - if (value == SwipeToDismissBoxValue.EndToStart) { - pendingDelete = entry to location - } - false - }, - ) - SwipeToDismissBox( - state = swipeState, - enableDismissFromStartToEnd = false, - modifier = Modifier.fillMaxWidth(), - backgroundContent = { - // Only draw the delete affordance mid-swipe: the frosted (translucent) - // cards otherwise let the red container + bin icon bleed through at - // rest, overlapping the row's own trailing controls. - if (swipeState.dismissDirection == SwipeToDismissBoxValue.EndToStart) { - Box( - modifier = - Modifier - .fillMaxSize() - .background( - color = MaterialTheme.colorScheme.errorContainer, - shape = MaterialTheme.shapes.medium, - ), - contentAlignment = Alignment.CenterEnd, - ) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.action_delete), - tint = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.padding(horizontal = 20.dp), - ) - } - } - }, - ) { - val rowContent: @Composable () -> Unit = { - Row( - modifier = - Modifier - .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { + val rowContent: @Composable () -> Unit = { + Row( + modifier = + Modifier + .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + location.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( - location.name, - style = MaterialTheme.typography.bodyLarge, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + storageTypeLabel(location.type), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (hasWarning) { Text( - storageTypeLabel(location.type), + stringResource(R.string.all_storage_stock_warning), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.error, ) - if (hasWarning) { - Text( - stringResource(R.string.all_storage_stock_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } } } + } + if (editMode) { + IconButton(onClick = { viewModel.requestDelete(entry.id, location.id) }) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.action_delete), + tint = MaterialTheme.colorScheme.error, + ) + } + } else { IconButton(onClick = { localViewModel.toggleFavorite(location.id) }) { Icon( if (isFavorite) Icons.Default.Star else Icons.Outlined.StarOutline, @@ -245,26 +235,26 @@ fun AllStoragesScreen( } } } - if (hasWarning) { - Card( - onClick = { onOpenLocation(entry.id, location.id) }, - modifier = Modifier.fillMaxWidth().testTag("home-location-${location.name}"), - colors = - CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.errorContainer.copy( - alpha = 0.4f, - ), - ), - content = { rowContent() }, - ) - } else { - FrostCard( - onClick = { onOpenLocation(entry.id, location.id) }, - modifier = Modifier.fillMaxWidth().testTag("home-location-${location.name}"), - content = { rowContent() }, - ) - } + } + if (hasWarning) { + Card( + onClick = { onOpenLocation(entry.id, location.id) }, + modifier = Modifier.fillMaxWidth().testTag("home-location-${location.name}"), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.errorContainer.copy( + alpha = 0.4f, + ), + ), + content = { rowContent() }, + ) + } else { + FrostCard( + onClick = { onOpenLocation(entry.id, location.id) }, + modifier = Modifier.fillMaxWidth().testTag("home-location-${location.name}"), + content = { rowContent() }, + ) } } } @@ -275,23 +265,37 @@ fun AllStoragesScreen( } } - pendingDelete?.let { (entry, location) -> - AlertDialog( - onDismissRequest = { pendingDelete = null }, - title = { Text(stringResource(R.string.delete_dialog_location_title, location.name)) }, - text = { Text(stringResource(R.string.delete_dialog_location_text)) }, - confirmButton = { - Button( - onClick = { - viewModel.deleteLocation(entry.id, location.id) - pendingDelete = null - }, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), - ) { Text(stringResource(R.string.action_delete)) } - }, - dismissButton = { - TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.action_cancel)) } - }, + // The delete-strategy dialog for the location just tapped for delete. + // Non-null pendingDelete is the ONLY thing that shows this dialog, and + // confirmDelete() (the only path that actually deletes) is reachable + // exclusively from its confirm button — the per-row trash icon above only + // ever calls requestDelete(). + state.pendingDelete?.let { plan -> + DeleteStrategyDialog( + plan = plan, + options = locationStrategyOptions(), + targets = state.moveTargets, + onDismiss = viewModel::cancelDelete, + onConfirm = { strategy, targetId -> viewModel.confirmDelete(strategy, targetId) }, ) } + + // Undo snackbar. A snackbar with an action, rather than a one-shot error + // effect (which has no action slot). + val undoLabel = stringResource(R.string.delete_undo) + val deletedMessage = stringResource(R.string.locations_deleted) + LaunchedEffect(state.lastBatchId) { + if (state.lastBatchId == null) return@LaunchedEffect + val result = + snackbarHostState.showSnackbar( + message = deletedMessage, + actionLabel = undoLabel, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + viewModel.undoDelete() + } else { + viewModel.consumeLastBatch() + } + } } diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 81e93da..2a616aa 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -60,8 +60,6 @@ Verwijderen uit favorieten - \"%s\" verwijderen? - Alle planken en producten hierin worden permanent verwijderd. \"%s\" verwijderen? Dit product wordt permanent van de plank verwijderd. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e8a77a..6bfb863 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -60,8 +60,6 @@ Remove favorite - Delete \"%s\"? - All shelves and products inside will be permanently deleted. Delete \"%s\"? This product will be permanently removed from the shelf. diff --git a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt index c51e266..e413232 100644 --- a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt @@ -6,6 +6,9 @@ import dev.scuttle.inventory.data.dto.HouseholdDto import dev.scuttle.inventory.data.dto.LocationDto import dev.scuttle.inventory.data.dto.ProductDto import dev.scuttle.inventory.data.dto.ShelfDto +import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.LocationDeletion +import dev.scuttle.inventory.data.hierarchy.RestoreRepository import dev.scuttle.inventory.data.household.HouseholdRepository import dev.scuttle.inventory.data.location.LocationRepository import dev.scuttle.inventory.data.product.ProductEdit @@ -17,10 +20,12 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import java.io.IOException +import java.util.UUID class DrawerViewModelTest { @get:Rule @@ -43,11 +48,33 @@ class DrawerViewModelTest { private class FakeLocationRepository( private val initial: Map> = emptyMap(), ) : LocationRepository { - val data = initial.mapValues { it.value.toMutableList() }.toMutableMap() - - override fun getCached(householdId: Long) = data[householdId] - - override suspend fun list(householdId: Long) = data[householdId].orEmpty() + // getCached() returns a FROZEN snapshot of what was true at construction — + // exactly like HierarchyStore's own `entries`, which is only rebuilt by an + // explicit store.refresh()/loadFromCache(), never by a location-level + // network call made elsewhere. `live` is the separate, mutable "what the + // server actually has right now" that list()/deleteWithStrategy() read + // and write — kept apart from `initial` on purpose, so a test can move it + // out from under the frozen snapshot to prove requestDelete() re-fetches + // instead of trusting a stale cached copy (see + // requesting_delete_fetches_a_fresh_location_list_so_stale_cached_shelf_count_cannot_skip_the_strategy_prompt). + private val cached: Map> = initial + val live = initial.mapValues { it.value.toMutableList() }.toMutableMap() + + // Recorded for assertions below — the real LocationRepository interface bundles + // batchId/strategy/targetLocationId into one LocationDeletion (data/hierarchy/DeleteStrategy.kt), + // so each call's deletion is captured whole and unpacked into these lists. + val batchIdsUsed = mutableListOf() + val strategiesUsed = mutableListOf() + val targetIdsUsed = mutableListOf() + val deletedLocationIds = mutableListOf() + + // When set, deleteWithStrategy throws for this one id instead of deleting it — + // simulates a server-side failure (e.g. an invalid move target) mid-request. + var failDeleteWithStrategyId: Long? = null + + override fun getCached(householdId: Long) = cached[householdId] + + override suspend fun list(householdId: Long) = live[householdId].orEmpty() override suspend fun create( householdId: Long, @@ -55,11 +82,53 @@ class DrawerViewModelTest { type: String, ) = LocationDto(99, name, type) + /** + * Models the REAL server, not a convenient fiction: Laravel's + * DeleteLocationRequest::rules() requires `deletion_batch_id` + * unconditionally (`'deletion_batch_id' => ['required', 'uuid']` — + * see inventory-laravel's DeleteLocationRequest.php), so a bodyless + * DELETE 422s every single time, strategy or no strategy. A fake + * whose plain delete() quietly removed the row would be exactly the + * kind of lying fake that shipped Task 4's crash: it would make a + * regression back to `locationRepository.delete(...)` look green + * here while the real endpoint rejects it. This is what makes the + * tests below DISCRIMINATE: any DrawerViewModel path that reaches + * for this method instead of [deleteWithStrategy] fails immediately. + */ override suspend fun delete( householdId: Long, locationId: Long, + ): Unit = throw IOException("422: The deletion batch id field is required.") + + override suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + deletion: LocationDeletion, ) { - data[householdId]?.removeIf { it.id == locationId } + if (locationId == failDeleteWithStrategyId) throw IOException("delete failed") + batchIdsUsed += deletion.batchId + strategiesUsed += deletion.strategy + targetIdsUsed += deletion.targetLocationId + deletedLocationIds += locationId + live[householdId]?.removeIf { it.id == locationId } + } + } + + private class FakeRestoreRepository : RestoreRepository { + var lastHouseholdId: Long? = null + var lastBatchId: String? = null + var restoreCalls = 0 + var fail = false + + override suspend fun restore( + householdId: Long, + batchId: String, + ): Int { + if (fail) throw IOException("restore failed") + lastHouseholdId = householdId + lastBatchId = batchId + restoreCalls++ + return 1 } } @@ -178,6 +247,12 @@ class DrawerViewModelTest { return store to locRepo } + private fun viewModel( + store: HierarchyStore, + locationRepo: LocationRepository, + restoreRepository: RestoreRepository = FakeRestoreRepository(), + ): DrawerViewModel = DrawerViewModel(store, locationRepo, restoreRepository) + @Test fun refresh_populates_entries_with_locations() = runTest { @@ -186,7 +261,7 @@ class DrawerViewModelTest { households = listOf(HouseholdDto(1, "Home", "AAAA")), locationsByHousehold = mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge"))), ) - val vm = DrawerViewModel(store, locRepo) + val vm = viewModel(store, locRepo) assertEquals(1, vm.state.value.entries.size) assertEquals( @@ -220,7 +295,7 @@ class DrawerViewModelTest { ), ), ) - val vm = DrawerViewModel(store, locRepo) + val vm = viewModel(store, locRepo) assertEquals(1, vm.state.value.missingItemCount) } @@ -229,7 +304,7 @@ class DrawerViewModelTest { fun report_location_warning_updates_map() = runTest { val (store, locRepo) = makeStore(households = emptyList()) - val vm = DrawerViewModel(store, locRepo) + val vm = viewModel(store, locRepo) vm.reportLocationWarning(locationId = 10, hasWarning = true) assertTrue(vm.state.value.locationWarnings[10] == true) @@ -262,7 +337,7 @@ class DrawerViewModelTest { FakeShelfRepository(), FakeProductRepository(), ) - val vm = DrawerViewModel(store, FakeLocationRepository()) + val vm = viewModel(store, FakeLocationRepository()) store.refresh(userInitiated = true) @@ -274,72 +349,283 @@ class DrawerViewModelTest { } @Test - fun delete_location_failure_surfaces_action_error() = + fun request_delete_does_not_delete_anything_until_confirmed() = runTest { - // W10: a failed swipe-to-delete must not be swallowed — it surfaces as a - // one-shot actionError the screen shows as a snackbar. - val throwingLocRepo = - object : LocationRepository { - override fun getCached(householdId: Long): List? = null - - override suspend fun list(householdId: Long) = emptyList() - - override suspend fun create( - householdId: Long, - name: String, - type: String, - ) = throw NotImplementedError() - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) = throw IOException("delete failed") - } - val (store, _) = makeStore(households = emptyList()) - val vm = DrawerViewModel(store, throwingLocRepo) + // The confirm dialog can never be bypassed. Requesting must NOT mutate + // the repository — only an explicit confirmDelete() may do that. + val repo = + FakeLocationRepository( + mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge", shelf_count = 0, product_count = 3))), + ) + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo) - vm.deleteLocation(householdId = 1, locationId = 10) + vm.requestDelete(householdId = 1, locationId = 10) - val message = vm.actionError.first { it != null } - assertNotNull(message) + assertTrue(repo.batchIdsUsed.isEmpty()) + assertNotNull(vm.state.value.pendingDelete) + assertEquals( + 3, + vm.state.value.pendingDelete + ?.productCount, + ) } @Test - fun delete_location_removes_it_from_entries() = + fun deleting_a_location_whose_shelves_are_empty_still_needs_a_strategy() = runTest { - val fakeLocRepo = + // The trap: the server asks about a location's SHELVES, not its + // products. A location with 3 empty shelves has product_count 0 but + // shelf_count 3 — contentCount must be built from shelf_count, or this + // delete goes out with no strategy and 422s (exactly the bug this task + // fixes, one screen over from where Task 5 already fixed it). + val repo = FakeLocationRepository( - mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge"), LocationDto(11, "Pantry", "pantry"))), + mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge", shelf_count = 3, product_count = 0))), ) - val (store, _) = - makeStore( - households = listOf(HouseholdDto(1, "Home", "AAAA")), - locationRepo = fakeLocRepo, + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo) + + vm.requestDelete(householdId = 1, locationId = 10) + + val plan = vm.state.value.pendingDelete + assertNotNull(plan) + assertEquals(3, plan?.contentCount) + assertEquals(0, plan?.productCount) + assertTrue(plan?.needsStrategy == true) + } + + @Test + fun requesting_delete_fetches_a_fresh_location_list_so_stale_cached_shelf_count_cannot_skip_the_strategy_prompt() = + runTest { + // Home renders from HierarchyStore's cached `entries`, which only + // change on the next store.refresh() — a shelf added elsewhere + // (LocationDetailScreen's own add-shelf sheet) never updates them on + // its own. requestDelete() must go straight to + // LocationRepository.list() rather than trusting entries, or this + // scenario reintroduces the 422. + val repo = + FakeLocationRepository( + mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge", shelf_count = 0, product_count = 0))), ) - val vm = DrawerViewModel(store, fakeLocRepo) + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo) + // state.entries is now a snapshot with shelf_count = 0 — the VM never + // reloads it as part of requestDelete(). assertEquals( - 2, + 0, vm.state.value.entries .first() - .locations.size, + .locations + .first() + .shelf_count, ) - fakeLocRepo.data[1L]?.removeIf { it.id == 10L } - store.loadFromCache() + // The server-side shelf count changed through a path this screen never + // observes; only the repository's own live (network-backed) data + // reflects it — the frozen `getCached()` snapshot backing + // state.entries does not, exactly like the real + // LocationRepositoryImpl/HierarchyStore split. + val staleEntry = repo.live.getValue(1L)[0] + repo.live.getValue(1L)[0] = staleEntry.copy(shelf_count = 1) + + vm.requestDelete(householdId = 1, locationId = 10) + + val plan = vm.state.value.pendingDelete + assertNotNull(plan) + assertEquals(1, plan?.contentCount) + assertTrue(plan?.needsStrategy == true) + } + + @Test + fun requesting_delete_offers_only_other_live_locations_in_the_same_household_as_move_targets() = + runTest { + // A move target has to live in the SAME household as what's being + // deleted (HierarchyDeleter::deleteLocation only ever looks up + // $household->locations()). Home shows every household on one + // screen, so this is the one new way this trap could reappear: + // offering another household's location as a place to move + // contents into would 422 (or worse, silently target the wrong + // household's location of the same id). + val repo = + FakeLocationRepository( + mapOf( + 1L to + listOf( + LocationDto(10, "Fridge", "fridge"), + LocationDto(11, "Freezer", "freezer"), + ), + 2L to listOf(LocationDto(20, "Pantry", "pantry")), + ), + ) + val (store, _) = + makeStore( + households = listOf(HouseholdDto(1, "Home", "AAAA"), HouseholdDto(2, "Cabin", "BBBB")), + locationRepo = repo, + ) + val vm = viewModel(store, repo) + + vm.requestDelete(householdId = 1, locationId = 10) assertEquals( - 1, - vm.state.value.entries - .first() - .locations.size, + listOf(11L), + vm.state.value.moveTargets + .map { it.id }, ) - assertEquals( - "Pantry", - vm.state.value.entries - .first() - .locations - .first() - .name, + } + + @Test + fun deleting_the_only_location_in_a_household_offers_no_move_target() = + runTest { + val repo = FakeLocationRepository(mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge")))) + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo) + + vm.requestDelete(householdId = 1, locationId = 10) + + assertTrue( + vm.state.value.moveTargets + .isEmpty(), ) } + + @Test + fun requesting_delete_for_a_location_that_no_longer_exists_does_nothing() = + runTest { + val repo = FakeLocationRepository(mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge")))) + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo) + + vm.requestDelete(householdId = 1, locationId = 999) + + assertNull(vm.state.value.pendingDelete) + } + + @Test + fun confirming_delete_sends_a_strategy_and_a_client_minted_batch_id_via_delete_with_strategy() = + runTest { + // THE regression this task fixes: before it, Home's swipe-to-delete + // called the bodyless LocationRepository.delete() — no strategy, no + // deletion_batch_id — which this fake's delete() now models as a hard + // 422 (see its own doc comment). Reaching for that path instead of + // deleteWithStrategy() turns this test red immediately. + val repo = + FakeLocationRepository( + mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge", shelf_count = 2, product_count = 5))), + ) + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo) + vm.requestDelete(householdId = 1, locationId = 10) + + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + + assertEquals(listOf(10L), repo.deletedLocationIds) + assertEquals(1, repo.batchIdsUsed.size) + // Client-minted and a real uuid — the server's `'deletion_batch_id' => + // ['required', 'uuid']` rule rejects anything else. + UUID.fromString(repo.batchIdsUsed.first()) + assertEquals(listOf(LocationDeleteStrategy.DELETE_CONTENTS), repo.strategiesUsed) + assertEquals(listOf(null), repo.targetIdsUsed) + assertEquals(repo.batchIdsUsed.first(), vm.state.value.lastBatchId) + assertNull(vm.state.value.pendingDelete) + assertNull(vm.actionError.value) + } + + @Test + fun confirming_a_move_delete_sends_the_target_location_id() = + runTest { + val repo = + FakeLocationRepository( + mapOf( + 1L to + listOf( + LocationDto(10, "Fridge", "fridge", shelf_count = 1), + LocationDto(11, "Pantry", "pantry"), + ), + ), + ) + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo) + vm.requestDelete(householdId = 1, locationId = 10) + + vm.confirmDelete(LocationDeleteStrategy.MOVE_CONTENTS, targetId = 11L) + + assertEquals(listOf(11L), repo.targetIdsUsed) + } + + @Test + fun cancel_delete_closes_the_dialog_without_deleting_anything() = + runTest { + val repo = FakeLocationRepository(mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge")))) + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo) + vm.requestDelete(householdId = 1, locationId = 10) + + vm.cancelDelete() + + assertNull(vm.state.value.pendingDelete) + assertTrue(repo.batchIdsUsed.isEmpty()) + + // Confirming after a cancel must be a no-op — there is no pending + // location left to delete. + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + assertTrue(repo.batchIdsUsed.isEmpty()) + } + + @Test + fun a_failed_delete_with_strategy_surfaces_an_action_error_and_closes_the_dialog() = + runTest { + // W10: a failed delete must not be swallowed — it surfaces as a + // one-shot actionError the screen shows as a snackbar, same as the + // legacy bodyless-delete failure path did. + val repo = + FakeLocationRepository(mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge")))).apply { + failDeleteWithStrategyId = 10L + } + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo) + vm.requestDelete(householdId = 1, locationId = 10) + + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + + val message = vm.actionError.first { it != null } + assertNotNull(message) + assertNull(vm.state.value.pendingDelete) + assertNull(vm.state.value.lastBatchId) + } + + @Test + fun undo_delete_restores_the_batch_and_clears_it() = + runTest { + val repo = FakeLocationRepository(mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge")))) + val restore = FakeRestoreRepository() + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo, restore) + vm.requestDelete(householdId = 1, locationId = 10) + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + val batchId = vm.state.value.lastBatchId + assertNotNull(batchId) + + vm.undoDelete() + + assertEquals(1L, restore.lastHouseholdId) + assertEquals(batchId, restore.lastBatchId) + assertNull(vm.state.value.lastBatchId) + } + + @Test + fun consume_last_batch_clears_it_without_restoring() = + runTest { + val repo = FakeLocationRepository(mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge")))) + val restore = FakeRestoreRepository() + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo, restore) + vm.requestDelete(householdId = 1, locationId = 10) + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + + vm.consumeLastBatch() + + assertNull(vm.state.value.lastBatchId) + assertEquals(0, restore.restoreCalls) + } } From 378159db12a10adc4e25f85efe3335fce71de406 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 02:03:42 +0200 Subject: [PATCH 17/40] =?UTF-8?q?fix:=20address=20Task=205/5b=20code=20rev?= =?UTF-8?q?iew=20=E2=80=94=20null-default=20delete=20DTOs,=20stale=20flow?= =?UTF-8?q?=20test,=20refresh=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical-1: DeleteLocationRequest/DeleteShelfRequest's strategy and target_*_id had no property default, so the app's Json (explicitNulls=true, encodeDefaults=false) always encoded them — including as an explicit null. That put a PRESENT-but-null key on the wire for every delete except move_contents/move_products, which the server's Rule::requiredIf validates as a type error rather than "absent", 422ing on every empty-container or delete-contents/unsort_products delete. Give both fields `= null` defaults so a value still equal to the default is OMITTED, matching what the server expects. DeleteRequestSerializationTest now asserts the actual encoded body for every strategy (empty / delete-contents / move+target) instead of just "has no default" — the old assertion happily pinned the broken shape. Important-2: DeleteLocationFlowTest still drove the old swipe-to-delete UI Task 5 removed; rewritten against the current edit-mode -> select -> Delete -> DeleteStrategyDialog -> confirm flow, mirroring DeleteShelfFlowTest. Minor-3: hierarchyStore.refresh()/store.refresh() calls throughout StorageOverviewViewModel and DrawerViewModel.confirmDelete had no test coverage — mutating any of them to Unit left the suite green. Added 6 tests (create/rename/reorder/confirm/undo in StorageOverviewViewModelTest, confirm in DrawerViewModelTest) that wait on HierarchyStore's own state, each verified to fail red when its call site is removed. Minor-4: StorageOverviewViewModelTest's fake LocationRepository.delete() silently succeeded, unlike the real (strategy-required) endpoint and unlike DrawerViewModelTest's equivalent fake. Now throws, matching the real 422. Minor-5: HierarchyStore hard-coded CoroutineScope(SupervisorJob() + Dispatchers.IO) — a real background thread outside any test's control. Injectable now, mirroring LiveUpdates' own constructor-overload shape; TestHierarchy.store() and DrawerViewModelTest default to an unconfined test dispatcher instead. Minor-6: StorageOverviewViewModel.confirmDelete's vanished-selection guard returned early without closing the dialog or exiting edit mode, leaving a dead Confirm button. Extracted closeDeleteFlow() and call it on that path too. All four Android gates green: testDebugUnitTest (217 tests), compileDebugAndroidTestKotlin, ktlintCheck, detekt. --- app/detekt-baseline.xml | 4 +- .../inventory/flow/DeleteLocationFlowTest.kt | 50 +- .../scuttle/inventory/data/HierarchyStore.kt | 489 +++++++++--------- .../inventory/data/dto/LocationDtos.kt | 18 +- .../scuttle/inventory/data/dto/ShelfDtos.kt | 14 +- .../ui/storage/StorageOverviewViewModel.kt | 30 +- .../DeleteRequestSerializationTest.kt | 133 ++++- .../scuttle/inventory/DrawerViewModelTest.kt | 50 ++ .../inventory/StorageOverviewViewModelTest.kt | 177 ++++++- .../dev/scuttle/inventory/TestHierarchy.kt | 18 +- 10 files changed, 700 insertions(+), 283 deletions(-) diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 9f54a4c..91c4a0d 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -9,14 +9,14 @@ ConstructorParameterNaming:ErrorDtos.kt$ClientErrorRequest$val error_code: String ConstructorParameterNaming:HouseholdDtos.kt$HouseholdDto$val join_code: String ConstructorParameterNaming:LocationDtos.kt$DeleteLocationRequest$val deletion_batch_id: String - ConstructorParameterNaming:LocationDtos.kt$DeleteLocationRequest$val target_location_id: Long? + ConstructorParameterNaming:LocationDtos.kt$DeleteLocationRequest$val target_location_id: Long? = null ConstructorParameterNaming:LocationDtos.kt$LocationDto$val product_count: Int = 0 ConstructorParameterNaming:LocationDtos.kt$LocationDto$val shelf_count: Int = 0 ConstructorParameterNaming:SearchDtos.kt$SearchResultDto$val household_id: Long? = null ConstructorParameterNaming:SearchDtos.kt$SearchResultDto$val location_id: Long? = null ConstructorParameterNaming:SearchDtos.kt$SearchResultDto$val shelf_id: Long? = null ConstructorParameterNaming:ShelfDtos.kt$DeleteShelfRequest$val deletion_batch_id: String - ConstructorParameterNaming:ShelfDtos.kt$DeleteShelfRequest$val target_shelf_id: Long? + ConstructorParameterNaming:ShelfDtos.kt$DeleteShelfRequest$val target_shelf_id: Long? = null ConstructorParameterNaming:ShelfDtos.kt$ShelfDto$val is_system: Boolean = false ConstructorParameterNaming:ShelfDtos.kt$ShelfDto$val location_id: Long ConstructorParameterNaming:ShelfDtos.kt$ShelfDto$val product_count: Int = 0 diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt index 764819d..305aa1a 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt @@ -13,18 +13,30 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.test.waitUntilAtLeastOneExists import dagger.hilt.android.testing.HiltAndroidTest import dev.scuttle.inventory.FlowTestBase import dev.scuttle.inventory.ui.dashboard.DASHBOARD_TITLE_TEST_TAG import org.junit.Test +/** + * The regression test for the bug that started Task 5: swiping a location off + * StorageOverviewScreen used to delete it with NO confirmation dialog at all + * (and, one level up, no strategy — see the Critical-1 fix in the Task 5/5b + * review). This drives the full edit-mode flow that replaced the swipe — enter + * edit mode, select, tap Delete, and the delete only actually happens once the + * strategy/confirm dialog's own Delete button is tapped. Mirrors + * DeleteShelfFlowTest.kt one level up: shelves -> locations. + * + * Fridge here is EMPTY (locations_one.json carries no shelf_count, which + * defaults to 0 on LocationDto), so the dialog renders as a plain confirm with + * no strategy radios — this is exactly the "empty container" case the + * Critical-1 fix's null-default DTOs exist for (see DeleteRequestSerializationTest). + */ @HiltAndroidTest class DeleteLocationFlowTest : FlowTestBase() { @Test - fun swipe_to_delete_location_removes_it_from_list() { + fun select_and_delete_location_requires_confirming_the_strategy_dialog() { mockServer.enqueue(fixture("auth_login.json")) mockServer.route("/households", fixture("households_one.json")) mockServer.route("/households/1/locations", fixture("locations_one.json")) @@ -45,7 +57,8 @@ class DeleteLocationFlowTest : FlowTestBase() { Thread.sleep(2_000) waitForIdle() - // Tap "Add storage location" → StorageOverviewScreen (GET /households/1/locations) + // Tap "Add storage location" (the + icon next to "Home" household) → StorageOverviewScreen + // StorageOverviewViewModel.load() calls GET /households/1/locations mockServer.route("/households/1/locations", fixture("locations_one.json")) waitUntilAtLeastOneExists(hasText("Home"), timeoutMillis = 5_000) onNodeWithContentDescription("Add storage location").performClick() @@ -54,21 +67,34 @@ class DeleteLocationFlowTest : FlowTestBase() { // StorageOverviewScreen shows "Fridge" waitUntilAtLeastOneExists(hasText("Fridge"), timeoutMillis = 5_000) - Thread.sleep(1_500) - waitForIdle() - // Swipe the location card — use first node at the card level (mergeDescendants from clickable) - onAllNodesWithText("Fridge")[0].performTouchInput { swipeLeft() } Thread.sleep(1_000) waitForIdle() - // Confirm dialog "Delete "Fridge"?" — DELETE /households/1/locations/10 → 204 - waitUntilAtLeastOneExists(hasText("Delete \"Fridge\"?"), timeoutMillis = 3_000) + // Tap the pencil ("Edit storage") → edit mode, replacing the old swipe-to-delete + onNodeWithContentDescription("Edit storage").performClick() + waitForIdle() + + // Select "Fridge" row → becomes selected + onNodeWithText("Fridge").performClick() + waitForIdle() + + // Delete (1) only OPENS the strategy/confirm dialog — it must not delete yet. + // requestDelete() refreshes the location list first (GET .../locations). + mockServer.route("/households/1/locations", fixture("locations_one.json")) + onNodeWithText("Delete (1)").performClick() + waitForIdle() + waitUntilAtLeastOneExists(hasText("Delete 1 item(s)?"), timeoutMillis = 3_000) + onNodeWithText("Fridge").assertIsDisplayed() + + // Only now, confirming inside the dialog, does the DELETE fire. + // On success, confirmDelete() refreshes the (now empty) location list again. mockServer.route("/households/1/locations/10", "", code = 204) - onAllNodesWithText("Delete").filterToOne(hasClickAction()).performClick() + mockServer.route("/households/1/locations", fixture("locations_empty.json")) + onNodeWithTag("delete-strategy-confirm").performClick() Thread.sleep(2_000) waitForIdle() - // Fridge removed — StorageOverviewScreen shows empty state + // Fridge removed — StorageOverviewScreen shows its own empty state // (AllStoragesScreen in backstack still has "Fridge" so waitUntilDoesNotExist would never pass) waitUntilAtLeastOneExists(hasText("No storage locations yet", substring = true), timeoutMillis = 5_000) onNodeWithText("No storage locations yet. Tap + to add a fridge, freezer or pantry.").assertIsDisplayed() diff --git a/app/src/main/java/dev/scuttle/inventory/data/HierarchyStore.kt b/app/src/main/java/dev/scuttle/inventory/data/HierarchyStore.kt index f9c6235..278af3f 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/HierarchyStore.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/HierarchyStore.kt @@ -7,6 +7,7 @@ import dev.scuttle.inventory.data.household.HouseholdRepository import dev.scuttle.inventory.data.location.LocationRepository import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.shelf.ShelfRepository +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -79,267 +80,289 @@ data class HierarchyState( val allShelves: List = emptyList(), ) +/** + * Mirrors [dev.scuttle.inventory.data.realtime.LiveUpdates]'s own constructor + * shape: the primary constructor below takes the [dispatcher] its internal + * `scope` runs on, and the Hilt-visible constructor delegates to it with the + * real [Dispatchers.IO]. Before this, `scope` was hard-coded to + * `CoroutineScope(SupervisorJob() + Dispatchers.IO)` — a REAL background + * thread outside any test's control. A test that triggered a refresh (every + * confirm/undo/rename does, as of Task 5/5b) could finish and reset + * `Dispatchers.Main` while that thread was still mid-flight; its later + * `_state.update`/`_state.value =` write then woke a still-active + * `stateIn(viewModelScope)` collector after Main was gone, surfacing as an + * intermittent "Main dispatcher not set" failure in whatever OTHER test + * happened to run next. Tests should reach the primary constructor with a + * test dispatcher instead — see TestHierarchy.store(). + */ @Singleton -class HierarchyStore +class HierarchyStore( + private val householdRepository: HouseholdRepository, + private val locationRepository: LocationRepository, + private val shelfRepository: ShelfRepository, + private val productRepository: ProductRepository, + dispatcher: CoroutineDispatcher, +) { @Inject constructor( - private val householdRepository: HouseholdRepository, - private val locationRepository: LocationRepository, - private val shelfRepository: ShelfRepository, - private val productRepository: ProductRepository, - ) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var activeJob: Job? = null + householdRepository: HouseholdRepository, + locationRepository: LocationRepository, + shelfRepository: ShelfRepository, + productRepository: ProductRepository, + ) : this(householdRepository, locationRepository, shelfRepository, productRepository, Dispatchers.IO) - private val _state = MutableStateFlow(HierarchyState()) - val state: StateFlow = _state.asStateFlow() + private val scope = CoroutineScope(SupervisorJob() + dispatcher) + private var activeJob: Job? = null - fun loadFromCache() { - val households = householdRepository.getCached() ?: return - var totalShelves = 0 - var totalProducts = 0 - var mandatoryWarnings = 0 - val entries = mutableListOf() - val locationStats = mutableListOf() - val missingItems = mutableListOf() - val lowStockItems = mutableListOf() - val allShelves = mutableListOf() - for (hh in households) { - val locations = locationRepository.getCached(hh.id) ?: emptyList() - for (location in locations) { - var count = 0 - val shelves = shelfRepository.getCached(hh.id, location.id) ?: emptyList() - totalShelves += shelves.size - for (shelf in shelves) { - allShelves += ShelfEntry(hh.id, shelf) - val products = productRepository.getCached(hh.id, shelf.id) ?: emptyList() - totalProducts += products.size - count += products.size - val place = ProductPlace(shelf, location, hh.id) - for (p in products) { - mandatoryWarnings += accumulate(classify(p, place), missingItems, lowStockItems) - } + private val _state = MutableStateFlow(HierarchyState()) + val state: StateFlow = _state.asStateFlow() + + fun loadFromCache() { + val households = householdRepository.getCached() ?: return + var totalShelves = 0 + var totalProducts = 0 + var mandatoryWarnings = 0 + val entries = mutableListOf() + val locationStats = mutableListOf() + val missingItems = mutableListOf() + val lowStockItems = mutableListOf() + val allShelves = mutableListOf() + for (hh in households) { + val locations = locationRepository.getCached(hh.id) ?: emptyList() + for (location in locations) { + var count = 0 + val shelves = shelfRepository.getCached(hh.id, location.id) ?: emptyList() + totalShelves += shelves.size + for (shelf in shelves) { + allShelves += ShelfEntry(hh.id, shelf) + val products = productRepository.getCached(hh.id, shelf.id) ?: emptyList() + totalProducts += products.size + count += products.size + val place = ProductPlace(shelf, location, hh.id) + for (p in products) { + mandatoryWarnings += accumulate(classify(p, place), missingItems, lowStockItems) } - locationStats += LocationStats(location, hh.id, count) } - entries += HouseholdWithLocations(hh.id, hh.name, locations, hh.color, hh.icon) - } - missingItems.sortWith(compareBy({ it.locationName }, { it.shelfName }, { it.productName })) - lowStockItems.sortWith(compareBy({ it.locationName }, { it.shelfName }, { it.productName })) - _state.update { s -> - s.copy( - entries = entries, - missingItems = missingItems, - missingItemCount = mandatoryWarnings, - lowStockItems = lowStockItems, - totalShelves = totalShelves, - totalProducts = totalProducts, - mandatoryWarnings = mandatoryWarnings, - locationStats = locationStats, - allShelves = allShelves, - locationWarnings = warningsByLocation(missingItems), - ) + locationStats += LocationStats(location, hh.id, count) } + entries += HouseholdWithLocations(hh.id, hh.name, locations, hh.color, hh.icon) + } + missingItems.sortWith(compareBy({ it.locationName }, { it.shelfName }, { it.productName })) + lowStockItems.sortWith(compareBy({ it.locationName }, { it.shelfName }, { it.productName })) + _state.update { s -> + s.copy( + entries = entries, + missingItems = missingItems, + missingItemCount = mandatoryWarnings, + lowStockItems = lowStockItems, + totalShelves = totalShelves, + totalProducts = totalProducts, + mandatoryWarnings = mandatoryWarnings, + locationStats = locationStats, + allShelves = allShelves, + locationWarnings = warningsByLocation(missingItems), + ) } + } - /** - * Which locations have at least one missing mandatory item, derived from the - * full product set (X2). The home/drawer "⚠ needs attention" indicator reads - * this — before, it was only ever populated by a composed ProductsPane, so a - * missing item on a non-visible shelf showed no warning until the user opened - * that location and swiped to the shelf. - */ - private fun warningsByLocation(missingItems: List): Map = - missingItems.map { it.locationId }.associateWith { true } + /** + * Which locations have at least one missing mandatory item, derived from the + * full product set (X2). The home/drawer "⚠ needs attention" indicator reads + * this — before, it was only ever populated by a composed ProductsPane, so a + * missing item on a non-visible shelf showed no warning until the user opened + * that location and swiped to the shelf. + */ + private fun warningsByLocation(missingItems: List): Map = + missingItems.map { it.locationId }.associateWith { true } - /** Where a product lives — shared context for building warning items. */ - private data class ProductPlace( - val shelf: ShelfDto, - val location: LocationDto, - val householdId: Long, - ) + /** Where a product lives — shared context for building warning items. */ + private data class ProductPlace( + val shelf: ShelfDto, + val location: LocationDto, + val householdId: Long, + ) + + private sealed interface Warning { + data class Missing( + val item: MissingItem, + ) : Warning - private sealed interface Warning { - data class Missing( - val item: MissingItem, - ) : Warning + data class Low( + val item: LowStockItem, + ) : Warning + } - data class Low( - val item: LowStockItem, - ) : Warning + /** + * Classify one product into the warning bucket shared by the cache and network + * paths: mandatory-at-zero -> missing; else at/below its low-stock threshold -> + * running low. Mutually exclusive so an item never carries two warnings. + */ + private fun classify( + product: dev.scuttle.inventory.data.dto.ProductDto, + place: ProductPlace, + ): Warning? { + val threshold = product.low_stock_threshold + return when { + product.is_mandatory == true && product.quantity == 0 -> + Warning.Missing( + MissingItem( + product.name, + place.shelf.name, + place.location.name, + place.householdId, + place.location.id, + ), + ) + threshold != null && product.quantity <= threshold -> + Warning.Low( + LowStockItem( + productName = product.name, + quantity = product.quantity, + threshold = threshold, + shelfName = place.shelf.name, + locationName = place.location.name, + householdId = place.householdId, + locationId = place.location.id, + ), + ) + else -> null } + } - /** - * Classify one product into the warning bucket shared by the cache and network - * paths: mandatory-at-zero -> missing; else at/below its low-stock threshold -> - * running low. Mutually exclusive so an item never carries two warnings. - */ - private fun classify( - product: dev.scuttle.inventory.data.dto.ProductDto, - place: ProductPlace, - ): Warning? { - val threshold = product.low_stock_threshold - return when { - product.is_mandatory == true && product.quantity == 0 -> - Warning.Missing( - MissingItem( - product.name, - place.shelf.name, - place.location.name, - place.householdId, - place.location.id, - ), - ) - threshold != null && product.quantity <= threshold -> - Warning.Low( - LowStockItem( - productName = product.name, - quantity = product.quantity, - threshold = threshold, - shelfName = place.shelf.name, - locationName = place.location.name, - householdId = place.householdId, - locationId = place.location.id, - ), - ) - else -> null + /** Apply one product's warning to the accumulators; returns the missing increment. */ + private fun accumulate( + warning: Warning?, + missing: MutableList, + lowStock: MutableList, + ): Int = + when (warning) { + is Warning.Missing -> { + missing += warning.item + 1 + } + is Warning.Low -> { + lowStock += warning.item + 0 } + null -> 0 } - /** Apply one product's warning to the accumulators; returns the missing increment. */ - private fun accumulate( - warning: Warning?, - missing: MutableList, - lowStock: MutableList, - ): Int = - when (warning) { - is Warning.Missing -> { - missing += warning.item - 1 - } - is Warning.Low -> { - lowStock += warning.item - 0 - } - null -> 0 + /** + * @param userInitiated true when the user pulled to refresh or tapped refresh — + * flips `refreshing` so the pull indicator spins. A post-mutation reload passes + * false: the list still reloads (`loading`), but the pull indicator stays put. + */ + fun refresh(userInitiated: Boolean = false) { + activeJob?.cancel() + _state.update { it.copy(loading = true, refreshing = userInitiated, error = null) } + loadFromCache() + activeJob = + scope.launch { + runCatching { + val households = householdRepository.list() + buildFromNetwork(households) + }.fold( + // buildFromNetwork returns a fresh state with loading/refreshing at their + // false defaults, so both indicators clear on success. Its locationWarnings + // are now computed authoritatively from the full server product set (X2), + // so we take them as-is rather than preserving the old partial map that only + // the visible ProductsPane could populate — the API is server-authoritative, + // and a mutation refreshes after the server confirms, so this is the truth. + onSuccess = { update -> _state.value = update }, + onFailure = { e -> + _state.update { + it.copy(loading = false, refreshing = false, error = e.toUserMessage("Failed to load.")) + } + }, + ) } + } - /** - * @param userInitiated true when the user pulled to refresh or tapped refresh — - * flips `refreshing` so the pull indicator spins. A post-mutation reload passes - * false: the list still reloads (`loading`), but the pull indicator stays put. - */ - fun refresh(userInitiated: Boolean = false) { - activeJob?.cancel() - _state.update { it.copy(loading = true, refreshing = userInitiated, error = null) } - loadFromCache() - activeJob = - scope.launch { - runCatching { - val households = householdRepository.list() - buildFromNetwork(households) - }.fold( - // buildFromNetwork returns a fresh state with loading/refreshing at their - // false defaults, so both indicators clear on success. Its locationWarnings - // are now computed authoritatively from the full server product set (X2), - // so we take them as-is rather than preserving the old partial map that only - // the visible ProductsPane could populate — the API is server-authoritative, - // and a mutation refreshes after the server confirms, so this is the truth. - onSuccess = { update -> _state.value = update }, - onFailure = { e -> - _state.update { - it.copy(loading = false, refreshing = false, error = e.toUserMessage("Failed to load.")) - } - }, - ) - } - } - - /** - * Reset to the empty state and drop any in-flight load. Called on session end - * (logout / new login) so one account's hierarchy never renders to the next — - * loadFromCache() would otherwise repopulate it from the singleton repo caches. - */ - fun clear() { - activeJob?.cancel() - _state.value = HierarchyState() - } + /** + * Reset to the empty state and drop any in-flight load. Called on session end + * (logout / new login) so one account's hierarchy never renders to the next — + * loadFromCache() would otherwise repopulate it from the singleton repo caches. + */ + fun clear() { + activeJob?.cancel() + _state.value = HierarchyState() + } - fun reportLocationWarning( - locationId: Long, - hasWarning: Boolean, - ) { - _state.update { state -> - state.copy(locationWarnings = state.locationWarnings + (locationId to hasWarning)) - } + fun reportLocationWarning( + locationId: Long, + hasWarning: Boolean, + ) { + _state.update { state -> + state.copy(locationWarnings = state.locationWarnings + (locationId to hasWarning)) } + } - /** Fetch one shelf's products and classify them; returns (productCount, missingCount). */ - private suspend fun tallyShelf( - householdId: Long, - location: LocationDto, - shelf: ShelfDto, - missing: MutableList, - lowStock: MutableList, - ): Pair { - val products = productRepository.list(householdId, shelf.id) - val place = ProductPlace(shelf, location, householdId) - var missingCount = 0 - for (product in products) { - missingCount += accumulate(classify(product, place), missing, lowStock) - } - return products.size to missingCount + /** Fetch one shelf's products and classify them; returns (productCount, missingCount). */ + private suspend fun tallyShelf( + householdId: Long, + location: LocationDto, + shelf: ShelfDto, + missing: MutableList, + lowStock: MutableList, + ): Pair { + val products = productRepository.list(householdId, shelf.id) + val place = ProductPlace(shelf, location, householdId) + var missingCount = 0 + for (product in products) { + missingCount += accumulate(classify(product, place), missing, lowStock) } + return products.size to missingCount + } - private suspend fun buildFromNetwork( - households: List, - ): HierarchyState { - val loading = false - val error: String? = null - var totalShelves = 0 - var totalProducts = 0 - var mandatoryWarnings = 0 - val entries = mutableListOf() - val locationStats = mutableListOf() - val missingItems = mutableListOf() - val lowStockItems = mutableListOf() - val allShelves = mutableListOf() + private suspend fun buildFromNetwork( + households: List, + ): HierarchyState { + val loading = false + val error: String? = null + var totalShelves = 0 + var totalProducts = 0 + var mandatoryWarnings = 0 + val entries = mutableListOf() + val locationStats = mutableListOf() + val missingItems = mutableListOf() + val lowStockItems = mutableListOf() + val allShelves = mutableListOf() - for (hh in households) { - val locations = locationRepository.list(hh.id) - for (location in locations) { - var locationProductCount = 0 - val shelves = shelfRepository.list(hh.id, location.id) - totalShelves += shelves.size - for (shelf in shelves) { - allShelves += ShelfEntry(hh.id, shelf) - val (productCount, missingCount) = - tallyShelf(hh.id, location, shelf, missingItems, lowStockItems) - totalProducts += productCount - locationProductCount += productCount - mandatoryWarnings += missingCount - } - locationStats += LocationStats(location, hh.id, locationProductCount) + for (hh in households) { + val locations = locationRepository.list(hh.id) + for (location in locations) { + var locationProductCount = 0 + val shelves = shelfRepository.list(hh.id, location.id) + totalShelves += shelves.size + for (shelf in shelves) { + allShelves += ShelfEntry(hh.id, shelf) + val (productCount, missingCount) = + tallyShelf(hh.id, location, shelf, missingItems, lowStockItems) + totalProducts += productCount + locationProductCount += productCount + mandatoryWarnings += missingCount } - entries += HouseholdWithLocations(hh.id, hh.name, locations, hh.color, hh.icon) + locationStats += LocationStats(location, hh.id, locationProductCount) } + entries += HouseholdWithLocations(hh.id, hh.name, locations, hh.color, hh.icon) + } - missingItems.sortWith(compareBy({ it.locationName }, { it.shelfName }, { it.productName })) - lowStockItems.sortWith(compareBy({ it.locationName }, { it.shelfName }, { it.productName })) + missingItems.sortWith(compareBy({ it.locationName }, { it.shelfName }, { it.productName })) + lowStockItems.sortWith(compareBy({ it.locationName }, { it.shelfName }, { it.productName })) - return HierarchyState( - loading = loading, - error = error, - entries = entries, - missingItems = missingItems, - missingItemCount = mandatoryWarnings, - lowStockItems = lowStockItems, - totalShelves = totalShelves, - totalProducts = totalProducts, - mandatoryWarnings = mandatoryWarnings, - locationStats = locationStats, - allShelves = allShelves, - locationWarnings = warningsByLocation(missingItems), - ) - } + return HierarchyState( + loading = loading, + error = error, + entries = entries, + missingItems = missingItems, + missingItemCount = mandatoryWarnings, + lowStockItems = lowStockItems, + totalShelves = totalShelves, + totalProducts = totalProducts, + mandatoryWarnings = mandatoryWarnings, + locationStats = locationStats, + allShelves = allShelves, + locationWarnings = warningsByLocation(missingItems), + ) } +} diff --git a/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt b/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt index 2a93d62..4876676 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/dto/LocationDtos.kt @@ -51,11 +51,21 @@ data class UpdateLocationRequest( val type: String, ) -// No property defaults: the app's Json has encodeDefaults=false, so a defaulted -// field is OMITTED from the body — and the server 422s without deletion_batch_id. +// `deletion_batch_id` has NO default: the app's Json has encodeDefaults=false, so a +// defaulted field is OMITTED from the body — and the server 422s without it. +// +// `strategy`/`target_location_id` DO default to null, on purpose — the mirror image +// of that same rule. The app's Json also has explicitNulls=true, so a property with +// NO default is ALWAYS encoded, even when it holds null: a strategy-less delete would +// put {"strategy":null,"target_location_id":null,...} on the wire. The server's +// `Rule::requiredIf` validates a present-but-null key as a type error (not "absent"), +// so that 422s on every delete except `move_contents` (see DeleteLocationRequest.php). +// Giving both a `= null` default means a value still equal to that default is OMITTED +// (encodeDefaults=false), matching what the server's requiredIf contract expects; a +// real strategy/target is not equal to the default, so it's still encoded as before. @Serializable data class DeleteLocationRequest( - val strategy: String?, - val target_location_id: Long?, + val strategy: String? = null, + val target_location_id: Long? = null, val deletion_batch_id: String, ) diff --git a/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt b/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt index 6a0791a..2c9584c 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/dto/ShelfDtos.kt @@ -43,11 +43,17 @@ data class UpdateShelfRequest( val name: String, ) -// No property defaults: the app's Json has encodeDefaults=false, so a defaulted -// field is OMITTED from the body — and the server 422s without deletion_batch_id. +// `deletion_batch_id` has NO default: the app's Json has encodeDefaults=false, so a +// defaulted field is OMITTED from the body — and the server 422s without it. +// +// `strategy`/`target_shelf_id` DO default to null, on purpose — the mirror image of +// that same rule. See DeleteLocationRequest (LocationDtos.kt) for the full reasoning, +// one level up: with explicitNulls=true, a property with no default is ALWAYS encoded +// even when null, which 422s on the server's requiredIf contract for every strategy +// except move_products. A `= null` default means a value still equal to it is OMITTED. @Serializable data class DeleteShelfRequest( - val strategy: String?, - val target_shelf_id: Long?, + val strategy: String? = null, + val target_shelf_id: Long? = null, val deletion_batch_id: String, ) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt index 0386746..d805ba6 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt @@ -258,7 +258,15 @@ class StorageOverviewViewModel // vanished from under the user never gets a delete request for an // id that no longer exists. val ids = state.locations.filter { it.id in state.selected }.map { it.id } - if (ids.isEmpty()) return + if (ids.isEmpty()) { + // Every selected location vanished from the live list (someone else + // deleted them first) between requestDelete()'s refresh and this + // call. There is nothing left to delete — close the dialog and exit + // edit mode the same way a successful/failed delete below does, or + // the dialog is stuck open with a Confirm button that does nothing. + closeDeleteFlow() + return + } // ONE batch id for the whole gesture. Deleting three locations is // three requests; if each minted its own id they would land in @@ -299,19 +307,23 @@ class StorageOverviewViewModel hierarchyStore.refresh() } - _state.update { - it.copy( - editMode = false, - selected = emptySet(), - pendingDelete = null, - moveTargets = emptyList(), - ) - } + closeDeleteFlow() failure?.let { throw it } } } + /** Shared by confirmDelete()'s vanished-selection guard and its normal completion. */ + private fun closeDeleteFlow() = + _state.update { + it.copy( + editMode = false, + selected = emptySet(), + pendingDelete = null, + moveTargets = emptyList(), + ) + } + fun cancelDelete() = _state.update { it.copy(pendingDelete = null, moveTargets = emptyList()) } fun undoDelete() { diff --git a/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt b/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt index 3b1b48b..e427a92 100644 --- a/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt @@ -6,35 +6,134 @@ import dev.scuttle.inventory.data.dto.ReorderRequest import dev.scuttle.inventory.data.dto.UpdateHouseholdRequest import dev.scuttle.inventory.data.dto.UpdateLocationRequest import dev.scuttle.inventory.data.dto.UpdateShelfRequest +import dev.scuttle.inventory.di.NetworkModule import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encodeToString +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test /** - * The app's Json has encodeDefaults=false: a property equal to its default is - * silently OMITTED from the request body. For most request DTOs that's exactly - * the bug the server 422s on (a missing `deletion_batch_id`, an unsent strategy - * the server required, etc.) — so these DTOs must have NO defaulted properties. + * The app's Json (see [NetworkModule.provideJson] — used directly here, not + * reimplemented, so this test cannot drift from the real wire config) has + * `encodeDefaults = false` (the kotlinx-serialization default) AND + * `explicitNulls = true` (also the default — deliberately NOT turned off, see + * below). Those two settings combine non-obviously: * - * This asserts on the serializer descriptor rather than an encoded body string. - * `isElementOptional` is true iff the property has a default value, so it is - * exactly discriminating on the field a default was (or wasn't) added to — a - * body-string check like `body.contains("deletion_batch_id")` passes as long as - * ANY value is present, which stays true even when a *different* field on the - * same DTO regresses to a default and silently vanishes. + * - A property EQUAL TO ITS DEFAULT is OMITTED from the body (encodeDefaults=false). + * - A property with NO default is ALWAYS ENCODED, even when its value is null + * (explicitNulls=true only suppresses nulls that come from a *default*). + * + * `strategy`/`target_location_id`/`target_shelf_id` used to have no default, so an + * empty-container delete (which leaves them null) put + * `{"strategy":null,"target_location_id":null,...}` on the wire — a PRESENT key + * with a null value. The server's `Rule::requiredIf` validates a present-but-null + * key as a *type* error, not as "absent", so that 422'd on every delete except + * `move_contents`/`move_products` (the only strategies that also supply a real, + * non-null target). Giving them `= null` defaults fixes this: a value still equal + * to that default is omitted, exactly like a field that was never set. + * + * These tests assert the actual encoded BYTES for every strategy, not just + * "does this property have a default" — asserting only the descriptor shape is + * what let the broken version of this DTO ship in the first place (the old + * `assertNoDefaultedProperties` check happily "pinned" no-default properties as + * correct, without ever encoding an instance to see what they produced on the + * wire). `deletion_batch_id` staying required (no default, no way to omit it) is + * still pinned by construction: it's the one field with no default in the class, + * so leaving it out of a call site would already fail to compile. */ class DeleteRequestSerializationTest { + private val json = NetworkModule.provideJson() + + // --- DeleteLocationRequest --------------------------------------------- + @Test - fun delete_shelf_request_has_no_defaulted_properties() { - assertNoDefaultedProperties(DeleteShelfRequest.serializer().descriptor) + fun delete_location_request_for_an_empty_location_omits_strategy_and_target() { + val body = json.encodeToString(DeleteLocationRequest(deletion_batch_id = "batch-1")) + + assertEquals("""{"deletion_batch_id":"batch-1"}""", body) } @Test - fun delete_location_request_has_no_defaulted_properties() { - assertNoDefaultedProperties(DeleteLocationRequest.serializer().descriptor) + fun delete_location_request_for_delete_contents_omits_only_the_target() { + val body = + json.encodeToString( + DeleteLocationRequest(strategy = "delete_contents", deletion_batch_id = "batch-1"), + ) + + assertEquals("""{"strategy":"delete_contents","deletion_batch_id":"batch-1"}""", body) } + @Test + fun delete_location_request_for_move_contents_encodes_the_target() { + val body = + json.encodeToString( + DeleteLocationRequest( + strategy = "move_contents", + target_location_id = 5L, + deletion_batch_id = "batch-1", + ), + ) + + assertEquals( + """{"strategy":"move_contents","target_location_id":5,"deletion_batch_id":"batch-1"}""", + body, + ) + } + + // --- DeleteShelfRequest -------------------------------------------------- + + @Test + fun delete_shelf_request_for_an_empty_shelf_omits_strategy_and_target() { + val body = json.encodeToString(DeleteShelfRequest(deletion_batch_id = "batch-2")) + + assertEquals("""{"deletion_batch_id":"batch-2"}""", body) + } + + @Test + fun delete_shelf_request_for_unsort_products_omits_only_the_target() { + val body = + json.encodeToString( + DeleteShelfRequest(strategy = "unsort_products", deletion_batch_id = "batch-2"), + ) + + assertEquals("""{"strategy":"unsort_products","deletion_batch_id":"batch-2"}""", body) + } + + @Test + fun delete_shelf_request_for_move_products_encodes_the_target() { + val body = + json.encodeToString( + DeleteShelfRequest( + strategy = "move_products", + target_shelf_id = 7L, + deletion_batch_id = "batch-2", + ), + ) + + assertEquals( + """{"strategy":"move_products","target_shelf_id":7,"deletion_batch_id":"batch-2"}""", + body, + ) + } + + // --- deletion_batch_id must stay required (no way to omit it) ----------- + + @Test + fun delete_location_request_deletion_batch_id_is_not_optional() { + val descriptor = DeleteLocationRequest.serializer().descriptor + assertFalse(descriptor.isElementOptional(descriptor.getElementIndex("deletion_batch_id"))) + } + + @Test + fun delete_shelf_request_deletion_batch_id_is_not_optional() { + val descriptor = DeleteShelfRequest.serializer().descriptor + assertFalse(descriptor.isElementOptional(descriptor.getElementIndex("deletion_batch_id"))) + } + + // --- Other request DTOs: unaffected by the above, still no defaults ------ + @Test fun update_shelf_request_has_no_defaulted_properties() { assertNoDefaultedProperties(UpdateShelfRequest.serializer().descriptor) @@ -59,6 +158,12 @@ class DeleteRequestSerializationTest { * back to the derived default", so THEY must stay un-defaulted. Pin the * asymmetry explicitly so it reads as deliberate, not as a regression of * the no-defaults rule everywhere else in this file. + * + * This is the OPPOSITE shape from DeleteLocationRequest/DeleteShelfRequest + * above, and that's the point: this file must never "fix" this asymmetry by + * globally flipping `explicitNulls` or by giving `color`/`icon` defaults — + * either change would silently break the shipped household colour/icon + * clear-to-default feature. */ @Test fun update_household_request_defaults_only_name() { diff --git a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt index e413232..037b190 100644 --- a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt @@ -16,6 +16,7 @@ import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.shelf.ShelfRepository import dev.scuttle.inventory.ui.app.DrawerViewModel import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -242,6 +243,10 @@ class DrawerViewModelTest { locRepo, FakeShelfRepository(shelvesByLocation), FakeProductRepository(productsByShelf), + // A fresh, unconfined test dispatcher — not the production + // Dispatchers.IO the 4-arg constructor falls back to (Minor 5, + // Task 5/5b review). See TestHierarchy.store()'s own doc comment. + UnconfinedTestDispatcher(), ) store.loadFromCache() return store to locRepo @@ -336,6 +341,7 @@ class DrawerViewModelTest { FakeLocationRepository(), FakeShelfRepository(), FakeProductRepository(), + UnconfinedTestDispatcher(), ) val vm = viewModel(store, FakeLocationRepository()) @@ -531,6 +537,50 @@ class DrawerViewModelTest { assertNull(vm.actionError.value) } + @Test + fun confirming_delete_refreshes_the_hierarchy_store() = + runTest { + // Minor 3 (Task 5/5b review): mutating `store.refresh()` in + // confirmDelete to Unit leaves the suite green — the drawer/home + // would still render a location the server just deleted. The + // cached snapshot (loadFromCache, synchronous, reads + // FakeLocationRepository's FROZEN `cached` map) still has it; + // only a REAL store.refresh() (async, reads the mutable `live` + // map deleteWithStrategy() actually writes to) can make it + // disappear from HierarchyStore's own `entries`. + val repo = + FakeLocationRepository( + mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge", shelf_count = 0, product_count = 0))), + ) + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo) + vm.requestDelete(householdId = 1, locationId = 10) + // The cached snapshot still shows "Fridge" — this is what loadFromCache + // (called once, synchronously, when makeStore() built the store) produced. + assertTrue( + store.state.value.entries + .first() + .locations + .any { it.name == "Fridge" }, + ) + + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + + val refreshed = + store.state.first { + it.entries + .first() + .locations + .none { l -> l.name == "Fridge" } + } + assertTrue( + refreshed.entries + .first() + .locations + .isEmpty(), + ) + } + @Test fun confirming_a_move_delete_sends_the_target_location_id() = runTest { diff --git a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt index fbe3096..3e28abe 100644 --- a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt @@ -10,6 +10,7 @@ import dev.scuttle.inventory.data.household.HouseholdRepository import dev.scuttle.inventory.data.location.LocationRepository import dev.scuttle.inventory.ui.storage.StorageOverviewViewModel import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -18,6 +19,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import java.io.IOException class StorageOverviewViewModelTest { @get:Rule @@ -60,12 +62,22 @@ class StorageOverviewViewModelTest { return dto } + /** + * Models the REAL server, not a convenient fiction: Laravel's + * DeleteLocationRequest::rules() requires `deletion_batch_id` + * unconditionally, so a bodyless DELETE 422s every single time, + * strategy or no strategy (see inventory-laravel's + * DeleteLocationRequest.php, and DrawerViewModelTest's equivalent + * fake — same fix, same reasoning). A fake whose plain delete() + * quietly removed the row would be exactly the kind of lying fake + * that shipped Task 4's crash: it would make a regression back to + * `repository.delete(...)` instead of `deleteWithStrategy(...)` look + * green here while the real endpoint rejects it. + */ override suspend fun delete( householdId: Long, locationId: Long, - ) { - items.removeIf { it.id == locationId } - } + ): Unit = throw IOException("422: The deletion batch id field is required.") override suspend fun rename( householdId: Long, @@ -143,6 +155,30 @@ class StorageOverviewViewModelTest { override suspend fun leave(householdId: Long) = Unit } + /** + * Used only by the `*_refreshes_the_hierarchy_store` tests below (Minor 3, + * Task 5/5b review): each call to [list] is numbered, so a test can wait for + * a SPECIFIC call to have landed in [HierarchyStore.state] — proving that + * exact `hierarchyStore.refresh()` call site fired, rather than merely that + * SOME earlier refresh happened to already leave `entries` non-empty. + */ + private class CountingHouseholdRepository : HouseholdRepository { + var listCalls = 0 + + override fun getCached(): List? = null + + override suspend fun list(): List { + listCalls++ + return listOf(HouseholdDto(1, "Home-$listCalls", "AAAA-1111")) + } + + override suspend fun create(name: String) = throw NotImplementedError() + + override suspend fun join(code: String) = throw NotImplementedError() + + override suspend fun leave(householdId: Long) = Unit + } + private fun viewModel( repo: LocationRepository = FakeLocationRepository(), restoreRepository: RestoreRepository = FakeRestoreRepository(), @@ -718,6 +754,39 @@ class StorageOverviewViewModelTest { assertEquals(listOf(1L), repo.deletedLocationIds) } + @Test + fun confirming_delete_when_every_selected_location_has_vanished_closes_the_dialog() = + runTest { + // Minor 6 (Task 5/5b review): the sibling case to the test above, where + // ALL selected locations (not just some) vanished from the live list. + // confirmDelete() used to return early right there, leaving pendingDelete + // non-null — the dialog stayed open with a Confirm button that no longer + // did anything, and edit mode/selection were never cleared either. + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val vm = viewModel(repo) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + assertNotNull(vm.state.value.pendingDelete) + + // The only selected location disappears server-side (another device) + // while the dialog is open; a refresh updates state.locations but never + // touches state.selected. + repo.items.removeIf { it.id == 1L } + vm.refresh() + + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + + assertTrue("nothing left to delete — no request should fire", repo.batchIdsUsed.isEmpty()) + assertNull("the dialog must close, not hang open with a dead Confirm button", vm.state.value.pendingDelete) + assertFalse(vm.state.value.editMode) + assertTrue( + vm.state.value.selected + .isEmpty(), + ) + } + @Test fun undo_delete_restores_the_batch_and_clears_it() = runTest { @@ -793,4 +862,106 @@ class StorageOverviewViewModelTest { ) assertNull(vm.state.value.pendingDelete) } + + // --- hierarchyStore.refresh() coverage (Minor 3, Task 5/5b review) --------- + // + // Mutating any of these calls to Unit left the whole suite green before this: + // a deleted/created/renamed/reordered/undone location would silently keep + // rendering on Home/the drawer until a manual pull-to-refresh. Each test below + // waits on HierarchyStore's own `state` (built from a real HierarchyStore, not + // a fake) for the numbered household CountingHouseholdRepository.list() mints, + // so a test can only pass once the SPECIFIC `hierarchyStore.refresh()` call + // site under test has actually fired and its result has landed. + + @Test + fun create_refreshes_the_hierarchy_store() = + runTest { + val householdRepo = CountingHouseholdRepository() + val store = TestHierarchy.store(householdRepo) + val vm = viewModel(hierarchyStore = store) + vm.load(householdId = 1) + vm.onNewNameChange("Pantry") + + vm.create() + + val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } + assertTrue(refreshed.entries.any { it.name == "Home-1" }) + } + + @Test + fun rename_refreshes_the_hierarchy_store() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Freezr", "freezer", 0)) } + val householdRepo = CountingHouseholdRepository() + val store = TestHierarchy.store(householdRepo) + val vm = viewModel(repo, hierarchyStore = store) + vm.load(householdId = 1) + + vm.rename(1L, "Freezer", "freezer") + + val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } + assertTrue(refreshed.entries.any { it.name == "Home-1" }) + } + + @Test + fun reordering_refreshes_the_hierarchy_store() = + runTest { + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Middle", "fridge", 1)) + } + val householdRepo = CountingHouseholdRepository() + val store = TestHierarchy.store(householdRepo) + val vm = viewModel(repo, hierarchyStore = store) + vm.load(householdId = 1) + + vm.moveDown(1L) + + val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } + assertTrue(refreshed.entries.any { it.name == "Home-1" }) + } + + @Test + fun confirming_a_delete_refreshes_the_hierarchy_store() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val householdRepo = CountingHouseholdRepository() + val store = TestHierarchy.store(householdRepo) + val vm = viewModel(repo, hierarchyStore = store) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + + val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } + assertTrue(refreshed.entries.any { it.name == "Home-1" }) + } + + @Test + fun undo_delete_refreshes_the_hierarchy_store() = + runTest { + // Isolates undoDelete()'s OWN refresh call from confirmDelete()'s (which + // also refreshes, on the same success path, just before this runs): the + // counting repo mints a distinctly-named household per call, so this can + // only pass once a SECOND refresh — undo's — has landed. + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val householdRepo = CountingHouseholdRepository() + val store = TestHierarchy.store(householdRepo) + val vm = viewModel(repo, hierarchyStore = store) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + // Absorb confirmDelete's own refresh (call #1) before testing undo's. + store.state.first { it.entries.any { e -> e.name == "Home-1" } } + + vm.undoDelete() + + val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-2" } } + assertTrue(refreshed.entries.any { it.name == "Home-2" }) + } } diff --git a/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt b/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt index 1c82241..2b29879 100644 --- a/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt +++ b/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt @@ -10,6 +10,8 @@ import dev.scuttle.inventory.data.location.LocationRepository import dev.scuttle.inventory.data.product.ProductEdit import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.shelf.ShelfRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher /** * Builds a real [HierarchyStore] over empty location/shelf/product repos for VM @@ -120,6 +122,18 @@ object TestHierarchy { ) {} } - fun store(householdRepository: HouseholdRepository): HierarchyStore = - HierarchyStore(householdRepository, EmptyLocations, EmptyShelves, EmptyProducts) + /** + * @param dispatcher Defaults to a FRESH, unconfined test dispatcher — never the + * production `Dispatchers.IO` the real HierarchyStore falls back to (Minor 5, + * Task 5/5b review). Tasks 5/5b made every confirm/undo/rename trigger a + * `hierarchyStore.refresh()` under test; a real IO-backed scope outside + * `runTest`'s control can still be mid-flight when a test finishes and + * `Dispatchers.Main` resets, surfacing as an intermittent failure in whatever + * OTHER test runs next. Unconfined runs `refresh()`'s coroutine eagerly on + * the calling thread instead, so nothing is left running once a test ends. + */ + fun store( + householdRepository: HouseholdRepository, + dispatcher: CoroutineDispatcher = UnconfinedTestDispatcher(), + ): HierarchyStore = HierarchyStore(householdRepository, EmptyLocations, EmptyShelves, EmptyProducts, dispatcher) } From db08c766973de29b48c0e9345a00a061e50b4bad Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 02:18:19 +0200 Subject: [PATCH 18/40] feat: HouseholdsViewModel.update() + edit-mode flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generic rename/re-theme entry point (thin pass-through to HouseholdRepository.update) plus the editMode flag HouseholdsScreen will use to gate row navigation into the upcoming edit screen. update() does NOT infer "leave alone" from null the way updateTheme() does — callers must pass every field's CURRENT value to avoid touching it, because UpdateHouseholdRequest's color/icon have no default and are always encoded on the wire (explicit null clears the theme server-side). updateTheme() now delegates to update() with name = null, relying on name's default to omit the key instead. --- .../ui/households/HouseholdsViewModel.kt | 31 ++++++++- .../inventory/HouseholdsViewModelTest.kt | 63 +++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt index ce3629f..88c98be 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt @@ -23,6 +23,9 @@ data class HouseholdsUiState( val households: List = emptyList(), val newName: String = "", val error: String? = null, + // Gates whether tapping a household row navigates to HouseholdEditScreen — + // mirrors StorageOverviewViewModel/ShelvesViewModel's editMode flag. + val editMode: Boolean = false, ) @HiltViewModel @@ -80,13 +83,28 @@ class HouseholdsViewModel hierarchyStore.refresh() } - /** Persist the chosen theme keys (null = back to the derived default). */ - fun updateTheme( + fun enterEditMode() = _state.update { it.copy(editMode = true) } + + fun exitEditMode() = _state.update { it.copy(editMode = false) } + + /** + * Rename and/or re-theme a household. A thin pass-through to + * [HouseholdRepository.update] — it does NOT infer "leave this alone" from + * a null argument the way the server infers it from an ABSENT key. Callers + * must supply every field they don't want touched with its CURRENT value: + * `color`/`icon` have no default on the wire (UpdateHouseholdRequest), so + * they are always sent, and an explicit null there clears the theme back + * to the derived default. `name` omits itself when null (its default), so + * `name = null` is the correct way to leave the name untouched — see + * [updateTheme], which relies on exactly that. + */ + fun update( householdId: Long, + name: String?, color: String?, icon: String?, ) = launchLoading { - val updated = repository.update(householdId, name = null, color = color, icon = icon) + val updated = repository.update(householdId, name = name, color = color, icon = icon) _state.update { s -> s.copy(households = s.households.map { if (it.id == updated.id) updated else it }) } @@ -94,6 +112,13 @@ class HouseholdsViewModel hierarchyStore.refresh() } + /** Persist the chosen theme keys (null = back to the derived default); the name is never touched. */ + fun updateTheme( + householdId: Long, + color: String?, + icon: String?, + ) = update(householdId, name = null, color = color, icon = icon) + private fun launchLoading( refreshing: Boolean = false, block: suspend () -> Unit, diff --git a/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt index 185b83c..8a78836 100644 --- a/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt @@ -126,6 +126,69 @@ class HouseholdsViewModelTest { ) } + @Test + fun renaming_a_household_updates_it_in_place() = + runTest { + val repo = FakeHouseholdRepository() + val viewModel = HouseholdsViewModel(repo, TestHierarchy.store(repo)) + + viewModel.update(1L, name = "House", color = null, icon = null) + + assertEquals("House", viewModel.state.value.households.first().name) + } + + @Test + fun renaming_a_household_preserves_its_existing_theme_when_passed_through() = + runTest { + // Mirrors HouseholdEditScreen's Save-name action: it passes the + // household's currently-known color/icon back through (not null), + // because UpdateHouseholdRequest's color/icon have no default and are + // ALWAYS encoded on the wire — an explicit null there clears the theme + // server-side. A rename must never have that side effect. + val repo = FakeHouseholdRepository().apply { items[0] = items[0].copy(color = "teal", icon = "cottage") } + val viewModel = HouseholdsViewModel(repo, TestHierarchy.store(repo)) + + viewModel.update(1L, name = "House", color = "teal", icon = "cottage") + + val household = viewModel.state.value.households.first() + assertEquals("House", household.name) + assertEquals("teal", household.color) + assertEquals("cottage", household.icon) + } + + @Test + fun clearing_the_theme_leaves_the_name_untouched() = + runTest { + // Mirrors HouseholdEditScreen's appearance swatches / "Default" action, + // which calls updateTheme(name = null, ...) — relying on `name`'s + // default to omit the key so the server's `sometimes|required` rule + // never sees an explicit null (which would 422) and never touches it. + val repo = FakeHouseholdRepository() + val viewModel = HouseholdsViewModel(repo, TestHierarchy.store(repo)) + viewModel.updateTheme(householdId = 1, color = "teal", icon = "cottage") + + viewModel.updateTheme(householdId = 1, color = null, icon = null) + + val household = viewModel.state.value.households.first() + assertEquals("Garage", household.name) + assertEquals(null, household.color) + assertEquals(null, household.icon) + } + + @Test + fun edit_mode_starts_off_and_toggles_on_enter_and_exit() = + runTest { + val repo = FakeHouseholdRepository() + val viewModel = HouseholdsViewModel(repo, TestHierarchy.store(repo)) + assertFalse(viewModel.state.value.editMode) + + viewModel.enterEditMode() + assertTrue(viewModel.state.value.editMode) + + viewModel.exitEditMode() + assertFalse(viewModel.state.value.editMode) + } + @Test fun list_failure_surfaces_an_error() = runTest { From f3721352f7e50398540d1ba34f47b13df007c387 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 02:19:53 +0200 Subject: [PATCH 19/40] test: pin UpdateHouseholdRequest's asymmetry at the byte level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Descriptor-shape assertions alone ("this property has no default") already proved insufficient once this branch — a prior review flagged that exact gap as how Tasks 4/5 shipped a Critical 422. Add two tests that encode a real instance the way each production call site builds one and assert the literal JSON bytes for both directions: a rename must still encode the current color/icon (not clear them), and a theme-only clear must omit `name` entirely (not blank it, not 422). Verified both directions discriminate: temporarily defaulting color/icon and separately un-defaulting name each turned the new tests (and the pre-existing descriptor test) red; reverted after confirming. --- .../DeleteRequestSerializationTest.kt | 44 +++++++++++++++++++ .../inventory/HouseholdsViewModelTest.kt | 15 +++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt b/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt index e427a92..da818b4 100644 --- a/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt @@ -183,6 +183,50 @@ class DeleteRequestSerializationTest { ) } + /** + * The descriptor-shape test above only pins the serializer's metadata — it + * would still pass if `color`/`icon` were given a default that happened not + * to break anything else, or if `name` lost its default while every call + * site coincidentally still worked in tests that never look at the wire. + * That's exactly the gap the doc comment above `assertNoDefaultedProperties` + * describes: "pinning" a no-default property as correct without ever + * encoding an instance. These two tests encode a real instance the way each + * production call site actually builds one (HouseholdsViewModel.update / + * updateTheme) and assert the literal bytes for BOTH directions of the + * asymmetry, so a regression in either direction shows up here as a diff in + * the JSON string, not just in descriptor shape. + */ + @Test + fun renaming_a_household_encodes_its_current_theme_instead_of_clearing_it() { + // Mirrors the edit screen's Save-name action: color/icon are passed + // through as the household's CURRENT keys (not null) because they have + // no default and are always encoded — an explicit null here would clear + // the theme server-side (`sometimes|nullable`). This is what makes a + // rename side-effect-free on the theme. + val body = + json.encodeToString( + UpdateHouseholdRequest(name = "House", color = "teal", icon = "cottage"), + ) + + assertEquals("""{"name":"House","color":"teal","icon":"cottage"}""", body) + } + + @Test + fun clearing_the_theme_omits_the_name_key_entirely() { + // Mirrors updateTheme()'s call to update(name = null, ...): name's + // default drops the key so the server (`sometimes|required`) never sees + // an explicit null and never touches the name. If `name` lost its + // default, this would encode `"name":null` and 422. If `color`/`icon` + // gained a default, this would encode `{}` and silently no-op instead + // of clearing the theme. + val body = + json.encodeToString( + UpdateHouseholdRequest(name = null, color = null, icon = null), + ) + + assertEquals("""{"color":null,"icon":null}""", body) + } + private fun assertNoDefaultedProperties(descriptor: SerialDescriptor) { for (i in 0 until descriptor.elementsCount) { assertFalse( diff --git a/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt index 8a78836..94da0f2 100644 --- a/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt @@ -134,7 +134,12 @@ class HouseholdsViewModelTest { viewModel.update(1L, name = "House", color = null, icon = null) - assertEquals("House", viewModel.state.value.households.first().name) + assertEquals( + "House", + viewModel.state.value.households + .first() + .name, + ) } @Test @@ -150,7 +155,9 @@ class HouseholdsViewModelTest { viewModel.update(1L, name = "House", color = "teal", icon = "cottage") - val household = viewModel.state.value.households.first() + val household = + viewModel.state.value.households + .first() assertEquals("House", household.name) assertEquals("teal", household.color) assertEquals("cottage", household.icon) @@ -169,7 +176,9 @@ class HouseholdsViewModelTest { viewModel.updateTheme(householdId = 1, color = null, icon = null) - val household = viewModel.state.value.households.first() + val household = + viewModel.state.value.households + .first() assertEquals("Garage", household.name) assertEquals(null, household.color) assertEquals(null, household.icon) From 657d9d09511db4c82df78ec6593568cdbe5c5627 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 02:27:25 +0200 Subject: [PATCH 20/40] feat: household edit page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename plus the colour/icon picker (moved out of the palette-icon dialog), and a danger zone holding Leave. HouseholdsScreen's per-row palette/leave controls are gone; a top-bar edit-mode pencil now gates row navigation into the new screen, mirroring the shelves/locations edit-mode idiom. Leave is not an edit of the household — it ends your membership — so it sits in a separated, error-tinted danger zone (plain Card, not FrostCard — see FrostCard's own doc comment) rather than beside the rename field. Its confirm copy is now honest that leaving as the last member permanently deletes the household, not just "you'll need a new invite". Delete household still does not exist: nobody owns one today. That lands with roles (Spec 2) — this danger zone is where it will go, noted in a comment. The colour/icon swatches apply immediately on tap (existing updateTheme()); only the name has an explicit Save. Leave waits for HouseholdsUiState.left (set once leave() completes server-side) before navigating back, rather than navigating on tap — the same pattern ProductDetailViewModel uses for delete, needed here because the edit screen's own ViewModel instance (and its viewModelScope) would otherwise be torn down mid-request the moment navigation pops its back-stack entry. Updated LeaveHouseholdFlowTest for the new edit-mode -> row -> danger-zone flow (no longer a per-row Leave button). --- .../inventory/flow/LeaveHouseholdFlowTest.kt | 25 +- .../dev/scuttle/inventory/MainActivity.kt | 16 + .../ui/households/HouseholdEditScreen.kt | 304 ++++++++++++++++++ .../ui/households/HouseholdThemeDialog.kt | 133 -------- .../ui/households/HouseholdsScreen.kt | 98 +++--- .../ui/households/HouseholdsViewModel.kt | 7 +- app/src/main/res/values-nl/strings.xml | 11 +- app/src/main/res/values/strings.xml | 11 +- .../inventory/HouseholdsViewModelTest.kt | 17 + 9 files changed, 417 insertions(+), 205 deletions(-) create mode 100644 app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdEditScreen.kt delete mode 100644 app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdThemeDialog.kt diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt index 5ef40dd..d2e41e2 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -43,20 +44,32 @@ class LeaveHouseholdFlowTest : FlowTestBase() { waitUntilAtLeastOneExists(hasText("Office"), timeoutMillis = 5_000) - // Tap Leave on "Home" household (first in list) → confirm dialog + // Leave now lives in the household's own edit screen, reached via edit + // mode: tap the pencil, then the "Home" row. + onNodeWithContentDescription("Edit households").performClick() + waitForIdle() + onNodeWithContentDescription("Home").performClick() + waitForIdle() + + // The edit screen's danger zone. + waitUntilAtLeastOneExists(hasText("Danger zone"), timeoutMillis = 5_000) + // Register a specific route for the DELETE so it doesn't consume the /households GET response mockServer.route("/households/1/leave", "", code = 204) mockServer.route("/households", fixture("households_office_only.json")) + + // Two "Leave" nodes on this screen: [0] = danger-zone button, [1] = the + // confirm dialog's button once it opens. onAllNodesWithText("Leave")[0].performClick() - waitForIdle() - // Dialog "Leave Home?" → 3 Leave nodes: [0]=Home btn, [1]=Office btn, [2]=dialog confirm waitUntilAtLeastOneExists(hasText("Leave Home?"), timeoutMillis = 3_000) - onAllNodesWithText("Leave")[2].performClick() + onAllNodesWithText("Leave")[1].performClick() Thread.sleep(2_000) waitForIdle() - // After leaving Home, the list refreshed. Office is the only remaining household. - // "Leave Home?" dialog is gone and Office's Leave button is the only one shown. + + // leave() only navigates back once it actually completes server-side + // (HouseholdsUiState.left) — back on the households list, "Home" is + // gone and "Office" is the only household left. onNodeWithText("Leave Home?").assertDoesNotExist() onAllNodesWithText("Office")[0].assertIsDisplayed() } diff --git a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt index cb489b3..c6e056b 100644 --- a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt +++ b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt @@ -61,6 +61,7 @@ import dev.scuttle.inventory.ui.auth.AuthViewModel import dev.scuttle.inventory.ui.auth.ForgotPasswordScreen import dev.scuttle.inventory.ui.dashboard.DashboardScreen import dev.scuttle.inventory.ui.home.AllStoragesScreen +import dev.scuttle.inventory.ui.households.HouseholdEditScreen import dev.scuttle.inventory.ui.households.HouseholdsScreen import dev.scuttle.inventory.ui.invite.InviteScreen import dev.scuttle.inventory.ui.location.LocationDetailScreen @@ -137,6 +138,7 @@ private object Routes { const val SCANNER = "scanner" const val DASHBOARD = "dashboard" const val HOUSEHOLDS = "households" + const val HOUSEHOLD_EDIT = "household-edit/{householdId}" const val SETTINGS = "settings" const val STORAGE = "storage/{householdId}" const val SEARCH = "search/{householdId}" @@ -145,6 +147,8 @@ private object Routes { const val PRODUCT_DETAIL = "product-detail/{householdId}/{shelfId}/{productId}" const val MISSING_ITEMS = "missing-items" + fun householdEdit(householdId: Long) = "household-edit/$householdId" + fun storage(householdId: Long) = "storage/$householdId" fun search(householdId: Long) = "search/$householdId" @@ -423,6 +427,18 @@ private fun InventoryNavHost( onBack = { navController.popBackStack() }, onOpenSettings = onOpenSettings, onOpenInvite = { id, name -> navController.navigate(Routes.invite(id, name)) }, + onEditHousehold = { id -> navController.navigate(Routes.householdEdit(id)) }, + ) + } + + composable( + route = Routes.HOUSEHOLD_EDIT, + arguments = listOf(navArgument("householdId") { type = NavType.LongType }), + ) { entry -> + val householdId = entry.arguments?.getLong("householdId") ?: return@composable + HouseholdEditScreen( + householdId = householdId, + onBack = { navController.popBackStack() }, ) } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdEditScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdEditScreen.kt new file mode 100644 index 0000000..05bd453 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdEditScreen.kt @@ -0,0 +1,304 @@ +package dev.scuttle.inventory.ui.households + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +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.draw.clip +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import dev.scuttle.inventory.R +import dev.scuttle.inventory.ui.common.LiveStatusText +import dev.scuttle.inventory.ui.theme.householdAccentsByKey +import dev.scuttle.inventory.ui.theme.householdIconsByKey +import dev.scuttle.inventory.ui.theme.householdTheme + +private val SWATCH_SIZE = 40.dp +private val SWATCH_ICON_SIZE = 22.dp +private const val SWATCH_BACKGROUND_ALPHA = 0.28f + +/** Matches the server-side household name column limit (UpdateHouseholdRequest's `max:50`). */ +private const val MAX_HOUSEHOLD_NAME_LENGTH = 50 + +/** + * Rename a household, pick its colour/icon (moved out of the old palette-icon + * dialog verbatim), and — in a visually separated danger zone — leave it. + * + * Reuses [HouseholdsViewModel] rather than a dedicated view model (per the + * existing screen/ViewModel idiom, one ViewModel per feature, not per route): + * the household list it loads is process-cached in [dev.scuttle.inventory.data.household.HouseholdRepository], + * so arriving here from HouseholdsScreen finds the target household already + * in [dev.scuttle.inventory.ui.households.HouseholdsUiState.households] with no extra round trip. + * + * The colour/icon swatches apply immediately on tap (through [HouseholdsViewModel.updateTheme]), + * matching direct-manipulation pickers elsewhere; only the name has an explicit Save, because + * unlike a swatch tap a name is free text that needs a confirming action. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun HouseholdEditScreen( + householdId: Long, + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + viewModel: HouseholdsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val household = state.households.find { it.id == householdId } + + // Wait for leave() to actually complete server-side (not just for the tap) before + // navigating back — see the doc on HouseholdsUiState.left for why navigating + // immediately would risk cancelling the in-flight request. + LaunchedEffect(state.left) { if (state.left) onBack() } + + var name by remember(household?.id) { mutableStateOf(household?.name.orEmpty()) } + // Kept locally (rather than reading household.color/icon back from state at + // save time) so saveName() below always has the LATEST chosen theme on hand, + // even the instant after a swatch tap, before its own updateTheme() call has + // round-tripped — passing a stale/absent value there would risk clearing it. + var selectedColor by remember(household?.id) { mutableStateOf(household?.color) } + var selectedIcon by remember(household?.id) { mutableStateOf(household?.icon) } + var confirmLeave by remember { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current + + fun saveName() { + val trimmed = name.trim().take(MAX_HOUSEHOLD_NAME_LENGTH) + if (trimmed.isEmpty()) return + keyboardController?.hide() + // Pass the CURRENT theme back through: UpdateHouseholdRequest's color/icon + // have no default and are always encoded, so an explicit null here would + // clear the theme as a side effect of a rename. + viewModel.update(householdId, name = trimmed, color = selectedColor, icon = selectedIcon) + } + + val statusBarInsets = WindowInsets.statusBars + Scaffold( + contentWindowInsets = WindowInsets(0), + modifier = modifier, + topBar = { + TopAppBar( + windowInsets = statusBarInsets, + title = { Text(stringResource(R.string.household_edit_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } + }, + ) + }, + ) { padding -> + if (household != null) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + state.error?.let { LiveStatusText(it) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it.take(MAX_HOUSEHOLD_NAME_LENGTH) }, + label = { Text(stringResource(R.string.households_field_name)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(autoCorrect = false, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { saveName() }), + modifier = Modifier.weight(1f), + ) + Button( + onClick = ::saveName, + enabled = name.isNotBlank() && !state.loading, + ) { + Text(stringResource(R.string.action_save)) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = stringResource(R.string.household_edit_appearance_label), + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = stringResource(R.string.household_theme_color_label), + style = MaterialTheme.typography.labelLarge, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + householdAccentsByKey.forEach { (key, accent) -> + Box( + modifier = + Modifier + .size(SWATCH_SIZE) + .clip(CircleShape) + .background(accent) + .selectionBorder(selected = selectedColor == key) + .clickable { + selectedColor = key + viewModel.updateTheme(householdId, color = key, icon = selectedIcon) + }.testTag("theme-color-$key"), + ) + } + } + + Text( + text = stringResource(R.string.household_theme_icon_label), + style = MaterialTheme.typography.labelLarge, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + val iconBackground = + householdTheme(householdId, selectedColor).accent.copy(alpha = SWATCH_BACKGROUND_ALPHA) + householdIconsByKey.forEach { (key, image) -> + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(SWATCH_SIZE) + .clip(CircleShape) + .background(iconBackground) + .selectionBorder(selected = selectedIcon == key) + .clickable { + selectedIcon = key + viewModel.updateTheme(householdId, color = selectedColor, icon = key) + }.testTag("theme-icon-$key"), + ) { + Icon( + imageVector = image, + contentDescription = key, + modifier = Modifier.size(SWATCH_ICON_SIZE), + ) + } + } + } + + TextButton(onClick = { + selectedColor = null + selectedIcon = null + viewModel.updateTheme(householdId, color = null, icon = null) + }) { + Text(stringResource(R.string.household_theme_default)) + } + } + + // Semantically-coloured card (error tint): stays on plain Card, not + // FrostCard — see FrostCard's own doc comment. + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.household_edit_danger_zone_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringResource(R.string.household_edit_danger_zone_body), + color = MaterialTheme.colorScheme.onErrorContainer, + ) + // No "delete household" here yet — nobody owns a household today + // (all members are equal). That arrives with roles (Spec 2), and + // this danger zone is where it will land. + Button( + onClick = { confirmLeave = true }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.households_leave)) + } + } + } + } + } + } + + if (confirmLeave && household != null) { + AlertDialog( + onDismissRequest = { confirmLeave = false }, + title = { Text(stringResource(R.string.households_leave_dialog_title, household.name)) }, + text = { Text(stringResource(R.string.households_leave_dialog_text)) }, + confirmButton = { + TextButton(onClick = { + viewModel.leave(householdId) + confirmLeave = false + }) { + Text(stringResource(R.string.households_leave), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { confirmLeave = false }) { Text(stringResource(R.string.action_cancel)) } + }, + ) + } +} + +@Composable +private fun Modifier.selectionBorder(selected: Boolean): Modifier = + if (selected) { + border(width = 3.dp, color = MaterialTheme.colorScheme.onSurface, shape = CircleShape) + } else { + this + } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdThemeDialog.kt b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdThemeDialog.kt deleted file mode 100644 index bd835f4..0000000 --- a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdThemeDialog.kt +++ /dev/null @@ -1,133 +0,0 @@ -package dev.scuttle.inventory.ui.households - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -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.draw.clip -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import dev.scuttle.inventory.R -import dev.scuttle.inventory.data.dto.HouseholdDto -import dev.scuttle.inventory.ui.theme.householdAccentsByKey -import dev.scuttle.inventory.ui.theme.householdIconsByKey -import dev.scuttle.inventory.ui.theme.householdTheme - -private val SWATCH_SIZE = 40.dp -private val SWATCH_ICON_SIZE = 22.dp -private const val SWATCH_BACKGROUND_ALPHA = 0.28f - -/** - * Color + icon picker for a household's theme. "Default" (null keys) restores - * the stable id-derived look, so the selected state must distinguish "chose - * the key that happens to equal the derived one" from "no choice". - */ -@Composable -fun HouseholdThemeDialog( - household: HouseholdDto, - onDismiss: () -> Unit, - onSave: (color: String?, icon: String?) -> Unit, -) { - var color by remember { mutableStateOf(household.color) } - var icon by remember { mutableStateOf(household.icon) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.household_theme_title, household.name)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = stringResource(R.string.household_theme_color_label), - style = MaterialTheme.typography.labelLarge, - ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - householdAccentsByKey.forEach { (key, accent) -> - Box( - modifier = - Modifier - .size(SWATCH_SIZE) - .clip(CircleShape) - .background(accent) - .selectionBorder(selected = color == key) - .clickable { color = key } - .testTag("theme-color-$key"), - ) - } - } - Text( - text = stringResource(R.string.household_theme_icon_label), - style = MaterialTheme.typography.labelLarge, - ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - val iconBackground = - householdTheme(household.id, color).accent.copy(alpha = SWATCH_BACKGROUND_ALPHA) - householdIconsByKey.forEach { (key, image) -> - Box( - contentAlignment = Alignment.Center, - modifier = - Modifier - .size(SWATCH_SIZE) - .clip(CircleShape) - .background(iconBackground) - .selectionBorder(selected = icon == key) - .clickable { icon = key } - .testTag("theme-icon-$key"), - ) { - Icon( - imageVector = image, - contentDescription = key, - modifier = Modifier.size(SWATCH_ICON_SIZE), - ) - } - } - } - } - }, - confirmButton = { - TextButton( - onClick = { onSave(color, icon) }, - modifier = Modifier.testTag("theme-save"), - ) { Text(stringResource(R.string.action_save)) } - }, - dismissButton = { - // "Default" clears both keys back to the derived look; Cancel keeps - // whatever is stored. - TextButton(onClick = { onSave(null, null) }) { - Text(stringResource(R.string.household_theme_default)) - } - TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } - }, - ) -} - -@Composable -private fun Modifier.selectionBorder(selected: Boolean): Modifier = - if (selected) { - border(width = 3.dp, color = MaterialTheme.colorScheme.onSurface, shape = CircleShape) - } else { - this - } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsScreen.kt index e8c5850..1b9283f 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsScreen.kt @@ -18,10 +18,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton @@ -40,13 +39,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -54,7 +51,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import dev.scuttle.inventory.R -import dev.scuttle.inventory.data.dto.HouseholdDto import dev.scuttle.inventory.ui.common.LiveStatusText import dev.scuttle.inventory.ui.theme.FrostCard import dev.scuttle.inventory.ui.theme.HouseholdAvatar @@ -66,11 +62,10 @@ fun HouseholdsScreen( onBack: () -> Unit = {}, onOpenSettings: () -> Unit = {}, onOpenInvite: (householdId: Long, householdName: String) -> Unit = { _, _ -> }, + onEditHousehold: (householdId: Long) -> Unit = {}, viewModel: HouseholdsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() - var confirmLeaveId by remember { mutableStateOf(null) } - var themePickerFor by remember { mutableStateOf(null) } var showCreateSheet by rememberSaveable { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val keyboardController = LocalSoftwareKeyboardController.current @@ -84,23 +79,42 @@ fun HouseholdsScreen( windowInsets = statusBarInsets, title = { Text(stringResource(R.string.households_title)) }, navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.action_back), - ) + if (state.editMode) { + TextButton(onClick = viewModel::exitEditMode) { Text(stringResource(R.string.action_cancel)) } + } else { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } } }, actions = { - IconButton(onClick = onOpenSettings) { - Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.action_settings)) + if (!state.editMode) { + IconButton(onClick = onOpenSettings) { + Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.action_settings)) + } + if (state.households.isNotEmpty()) { + IconButton(onClick = viewModel::enterEditMode) { + Icon( + Icons.Default.Edit, + contentDescription = stringResource(R.string.households_edit_cd), + ) + } + } } }, ) }, floatingActionButton = { - FloatingActionButton(modifier = Modifier.navigationBarsPadding(), onClick = { showCreateSheet = true }) { - Icon(Icons.Default.Add, contentDescription = stringResource(R.string.households_create_fab_cd)) + if (!state.editMode) { + FloatingActionButton( + modifier = Modifier.navigationBarsPadding(), + onClick = { showCreateSheet = true }, + ) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.households_create_fab_cd)) + } } }, ) { padding -> @@ -135,7 +149,16 @@ fun HouseholdsScreen( } state.households.forEach { household -> + // Only edit mode makes the row navigate — outside it, the row is + // inert except for the share/invite icon below, same as before. + val onRowClick: (() -> Unit)? = + if (state.editMode) { + { onEditHousehold(household.id) } + } else { + null + } FrostCard( + onClick = onRowClick, modifier = Modifier .fillMaxWidth() @@ -159,24 +182,12 @@ fun HouseholdsScreen( style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f), ) - IconButton( - onClick = { themePickerFor = household }, - modifier = Modifier.testTag("household-theme-${household.id}"), - ) { - Icon( - Icons.Default.Palette, - contentDescription = stringResource(R.string.households_theme_cd, household.name), - ) - } IconButton(onClick = { onOpenInvite(household.id, household.name) }) { Icon( Icons.Default.Share, contentDescription = stringResource(R.string.households_invite_cd, household.name), ) } - TextButton(onClick = { confirmLeaveId = household.id }) { - Text(stringResource(R.string.households_leave), color = MaterialTheme.colorScheme.error) - } } } } @@ -231,35 +242,4 @@ fun HouseholdsScreen( } } } - - themePickerFor?.let { household -> - HouseholdThemeDialog( - household = household, - onDismiss = { themePickerFor = null }, - onSave = { color, icon -> - viewModel.updateTheme(household.id, color, icon) - themePickerFor = null - }, - ) - } - - confirmLeaveId?.let { id -> - val name = state.households.find { it.id == id }?.name ?: "this household" - AlertDialog( - onDismissRequest = { confirmLeaveId = null }, - title = { Text(stringResource(R.string.households_leave_dialog_title, name)) }, - text = { Text(stringResource(R.string.households_leave_dialog_text)) }, - confirmButton = { - TextButton(onClick = { - viewModel.leave(id) - confirmLeaveId = null - }) { - Text(stringResource(R.string.households_leave), color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { confirmLeaveId = null }) { Text(stringResource(R.string.action_cancel)) } - }, - ) - } } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt index 88c98be..04b1bf1 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt @@ -26,6 +26,11 @@ data class HouseholdsUiState( // Gates whether tapping a household row navigates to HouseholdEditScreen — // mirrors StorageOverviewViewModel/ShelvesViewModel's editMode flag. val editMode: Boolean = false, + // Flips true once leave() has actually completed server-side. HouseholdEditScreen + // waits for this (LaunchedEffect) instead of navigating back the instant Leave is + // tapped — same pattern as ProductDetailViewModel.deleted — so its own viewModelScope + // coroutine isn't cancelled mid-flight by the navigation it would otherwise trigger. + val left: Boolean = false, ) @HiltViewModel @@ -79,7 +84,7 @@ class HouseholdsViewModel fun leave(householdId: Long) = launchLoading { repository.leave(householdId) - _state.update { it.copy(households = repository.list()) } + _state.update { it.copy(households = repository.list(), left = true) } hierarchyStore.refresh() } diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2a616aa..83c49b1 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -69,17 +69,22 @@ Uitnodigen voor %s Verlaten %s verlaten? - Je hebt een nieuwe uitnodiging nodig om opnieuw deel te nemen. + Je hebt een nieuwe uitnodiging nodig om opnieuw deel te nemen. Als je het laatste lid bent, wordt het huishouden — en alles daarin — permanent verwijderd. Huishouden aanmaken Naam huishouden Aanmaken - Uiterlijk van %s aanpassen - Uiterlijk van %s + Huishoudens bewerken Kleur Pictogram Standaard Opslaan + + Huishouden bewerken + Uiterlijk + Gevarenzone + Als je het huishouden verlaat, verlies je je toegang ertoe. + Planken %d geselecteerd diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6bfb863..6a8cce1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -69,17 +69,22 @@ Invite to %s Leave Leave %s? - You\'ll need a new invite to rejoin. + You\'ll need a new invite to rejoin. If you\'re the last member, this deletes the household — and everything in it — permanently. Create household Household name - Change look of %s - Look of %s + Edit households Colour Icon Default Save Create + + Edit household + Appearance + Danger zone + Leaving removes your access to this household. + Shelves %d selected diff --git a/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt index 94da0f2..066ffe3 100644 --- a/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt @@ -227,4 +227,21 @@ class HouseholdsViewModelTest { .name, ) } + + @Test + fun leave_sets_the_left_flag_so_the_edit_screen_can_navigate_back() = + runTest { + // HouseholdEditScreen waits for this flag (rather than navigating back + // immediately on tap) so the leave() coroutine — running in the edit + // screen's OWN ViewModel instance, scoped to its own back-stack entry — + // isn't cancelled mid-flight by the navigation it would otherwise + // trigger before the network call completes. + val repo = FakeHouseholdRepository() + val viewModel = HouseholdsViewModel(repo, TestHierarchy.store(repo)) + assertFalse(viewModel.state.value.left) + + viewModel.leave(householdId = 1) + + assertTrue(viewModel.state.value.left) + } } From c372925f3bd25b45d7c0242f504fd0217c08cdf3 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 02:43:53 +0200 Subject: [PATCH 21/40] feat: rework the bottom navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Households leaves the bar — it is a management screen, not a daily destination, and the Storage tab already acts across households. Scan takes the freed centre slot (the primary-action slot; scanning is a weekly action, search an occasional one). Settings comes back into the bar as More, since it now holds households, join/invite and account. Also updates every androidTest flow test that navigated via the removed bottom-nav-households/bottom-nav-search tabs or the Dashboard settings gear (now gone from Dashboard/Home/Missing-items' top bars), and deletes SearchHouseholdPickerFlowTest — the multi-household search picker it covered is dead code now that Search is always opened already scoped to a household. --- .../inventory/flow/EmptyHouseholdsFlowTest.kt | 7 +- .../scuttle/inventory/flow/InviteFlowTest.kt | 7 +- .../flow/JoinFromInviteLinkFlowTest.kt | 6 +- .../inventory/flow/JoinHouseholdFlowTest.kt | 7 +- .../inventory/flow/LeaveHouseholdFlowTest.kt | 7 +- .../scuttle/inventory/flow/LogoutFlowTest.kt | 7 +- .../scuttle/inventory/flow/SearchFlowTest.kt | 34 ++++-- .../flow/SearchHouseholdPickerFlowTest.kt | 103 ------------------ .../inventory/flow/SearchNoResultsFlowTest.kt | 21 ++-- .../inventory/flow/SettingsVersionFlowTest.kt | 6 +- .../dev/scuttle/inventory/MainActivity.kt | 98 ++--------------- .../inventory/ui/dashboard/DashboardScreen.kt | 5 - .../inventory/ui/home/AllStoragesScreen.kt | 5 - .../ui/missing/MissingItemsScreen.kt | 5 - app/src/main/res/values-nl/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 16 files changed, 80 insertions(+), 242 deletions(-) delete mode 100644 app/src/androidTest/java/dev/scuttle/inventory/flow/SearchHouseholdPickerFlowTest.kt diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/EmptyHouseholdsFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/EmptyHouseholdsFlowTest.kt index ee907c1..a52ae1e 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/EmptyHouseholdsFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/EmptyHouseholdsFlowTest.kt @@ -34,9 +34,12 @@ class EmptyHouseholdsFlowTest : FlowTestBase() { Thread.sleep(3_000) waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) - // Households tab; HouseholdsViewModel.refresh() → GET /households → empty + // Households leaves the bottom bar in favour of Settings ("More") → + // "My households"; HouseholdsViewModel.refresh() → GET /households → empty mockServer.route("/households", fixture("households_empty.json")) - onNodeWithTag("bottom-nav-households").performClick() + onNodeWithTag("bottom-nav-more").performClick() + waitForIdle() + onNodeWithText("My households").performClick() waitForIdle() waitUntilAtLeastOneExists(hasText("No households yet", substring = true), timeoutMillis = 5_000) diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/InviteFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/InviteFlowTest.kt index 3358745..b691bf3 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/InviteFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/InviteFlowTest.kt @@ -37,9 +37,12 @@ class InviteFlowTest : FlowTestBase() { Thread.sleep(3_000) waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) - // Households tab + // Households leaves the bottom bar in favour of Settings ("More") → + // "My households". mockServer.route("/households", fixture("households_one.json")) - onNodeWithTag("bottom-nav-households").performClick() + onNodeWithTag("bottom-nav-more").performClick() + waitForIdle() + onNodeWithText("My households").performClick() waitForIdle() // Tap share icon for "Home" → InviteScreen loads — GET /households/1/invite diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/JoinFromInviteLinkFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/JoinFromInviteLinkFlowTest.kt index 7d3da74..72cc9ea 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/JoinFromInviteLinkFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/JoinFromInviteLinkFlowTest.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput @@ -38,7 +38,9 @@ class JoinFromInviteLinkFlowTest : FlowTestBase() { onAllNodesWithText("Sign in").filterToOne(hasClickAction()).performClick() waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 10_000) - onNodeWithContentDescription("Settings").performClick() + // "More" tab → SettingsScreen (Settings left the top-bar gear; it's a + // bottom-nav tab now). + onNodeWithTag("bottom-nav-more").performClick() waitForIdle() // Exactly what the QR carries, and what the scanner used to hand over verbatim. diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/JoinHouseholdFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/JoinHouseholdFlowTest.kt index 4e06497..a10fbc9 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/JoinHouseholdFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/JoinHouseholdFlowTest.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput @@ -36,8 +36,9 @@ class JoinHouseholdFlowTest : FlowTestBase() { Thread.sleep(3_000) waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) - // Dashboard's settings gear → SettingsScreen - onNodeWithContentDescription("Settings").performClick() + // "More" tab → SettingsScreen (Settings left the top-bar gear; it's a + // bottom-nav tab now). + onNodeWithTag("bottom-nav-more").performClick() waitForIdle() // Enter join code and submit — POST /households/join diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt index d2e41e2..c884d90 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt @@ -37,9 +37,12 @@ class LeaveHouseholdFlowTest : FlowTestBase() { Thread.sleep(3_000) waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) - // Households tab + // Households leaves the bottom bar in favour of Settings ("More") → + // "My households". mockServer.route("/households", fixture("households_two.json")) - onNodeWithTag("bottom-nav-households").performClick() + onNodeWithTag("bottom-nav-more").performClick() + waitForIdle() + onNodeWithText("My households").performClick() waitForIdle() waitUntilAtLeastOneExists(hasText("Office"), timeoutMillis = 5_000) diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/LogoutFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/LogoutFlowTest.kt index 3a21e7a..7687c26 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/LogoutFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/LogoutFlowTest.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -37,8 +37,9 @@ class LogoutFlowTest : FlowTestBase() { Thread.sleep(3_000) waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) - // Dashboard's settings gear → SettingsScreen - onNodeWithContentDescription("Settings").performClick() + // "More" tab → SettingsScreen (Settings left the top-bar gear; it's a + // bottom-nav tab now). + onNodeWithTag("bottom-nav-more").performClick() waitForIdle() // Tap "Sign out" to open the confirm dialog. Scroll first: the bottom diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchFlowTest.kt index affae1e..4e1bf45 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchFlowTest.kt @@ -6,10 +6,11 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -45,11 +46,18 @@ class SearchFlowTest : FlowTestBase() { waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) mockServer.route("/households/1/search", fixture("search_results.json")) - // Search tab is disabled until drawerUi.entries loads (HierarchyStore, async - // after login) — tapping it before then is a no-op on a disabled node, not an - // error, so wait for enabled first or the click silently does nothing. - waitUntilAtLeastOneExists(hasTestTag("bottom-nav-search").and(isEnabled()), timeoutMillis = 8_000) - onNodeWithTag("bottom-nav-search").performClick() + // Search leaves the bottom bar (Task 7 — Households out, Scan takes the + // centre slot). It's reached from a household-scoped screen's top bar now: + // Storage tab → "Add storage location" (the + next to "Home") opens + // StorageOverviewScreen for household 1, whose top bar has the search icon. + onNodeWithTag("bottom-nav-home").performClick() + waitForIdle() + // StorageOverviewViewModel.load() calls GET /households/1/locations again. + mockServer.route("/households/1/locations", fixture("locations_one.json")) + waitUntilAtLeastOneExists(hasText("Home"), timeoutMillis = 5_000) + onNodeWithContentDescription("Add storage location").performClick() + waitUntilAtLeastOneExists(hasContentDescription("Search products"), timeoutMillis = 5_000) + onNodeWithContentDescription("Search products").performClick() waitUntilAtLeastOneExists(hasTestTag("search_field"), timeoutMillis = 5_000) onNodeWithTag("search_field").performTextInput("Milk") @@ -92,11 +100,15 @@ class SearchFlowTest : FlowTestBase() { waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) mockServer.route("/households/1/search", fixture("search_results.json")) - // Search tab is disabled until drawerUi.entries loads (HierarchyStore, async - // after login) — tapping it before then is a no-op on a disabled node, not an - // error, so wait for enabled first or the click silently does nothing. - waitUntilAtLeastOneExists(hasTestTag("bottom-nav-search").and(isEnabled()), timeoutMillis = 8_000) - onNodeWithTag("bottom-nav-search").performClick() + // Search leaves the bottom bar (Task 7) — reached via Storage tab → "Add + // storage location" → StorageOverviewScreen's top-bar search icon. + onNodeWithTag("bottom-nav-home").performClick() + waitForIdle() + mockServer.route("/households/1/locations", fixture("locations_one.json")) + waitUntilAtLeastOneExists(hasText("Home"), timeoutMillis = 5_000) + onNodeWithContentDescription("Add storage location").performClick() + waitUntilAtLeastOneExists(hasContentDescription("Search products"), timeoutMillis = 5_000) + onNodeWithContentDescription("Search products").performClick() waitForIdle() // Type query in the search field diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchHouseholdPickerFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchHouseholdPickerFlowTest.kt deleted file mode 100644 index 8e2ef6a..0000000 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchHouseholdPickerFlowTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -@file:OptIn(androidx.compose.ui.test.ExperimentalTestApi::class) - -package dev.scuttle.inventory.flow - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.filterToOne -import androidx.compose.ui.test.hasClickAction -import androidx.compose.ui.test.hasContentDescription -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.isEnabled -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.waitUntilAtLeastOneExists -import dagger.hilt.android.testing.HiltAndroidTest -import dev.scuttle.inventory.FlowTestBase -import dev.scuttle.inventory.ui.dashboard.DASHBOARD_TITLE_TEST_TAG -import org.junit.Test - -/** - * The multi-household search picker (MainActivity's ModalBottomSheet, shown when the - * Search tab is tapped and drawerUi.entries.size > 1) had zero instrumented coverage. - * An earlier review caught a regression where the picker silently always navigated to - * the FIRST household regardless of which row was tapped. This test proves the picker - * opens with two households, and that picking the SECOND one actually issues a search - * request against THAT household's endpoint — not merely that some search screen - * appears — by giving each household a distinct results fixture and asserting on the - * household-2-specific content while asserting the household-1-specific content is - * absent. - */ -@HiltAndroidTest -class SearchHouseholdPickerFlowTest : FlowTestBase() { - @Test - fun picking_second_household_searches_that_households_endpoint() { - mockServer.enqueue(fixture("auth_login.json")) - mockServer.route("/households", fixture("households_two.json")) - mockServer.route("/households/1/locations", fixture("locations_one.json")) - mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one.json")) - mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) - // Household 2 ("Office") has no locations — keeps the fixture set minimal while - // still letting HierarchyStore's login-time refresh fully populate both - // households in drawerUi.entries (required for the picker to show at all). - mockServer.route("/households/2/locations", fixture("locations_empty.json")) - - composeRule.apply { - onNodeWithText("Email").performTextInput("test@example.com") - onNodeWithText("Password").performTextInput("password123") - onAllNodesWithText("Sign in").filterToOne(hasClickAction()).performClick() - - Thread.sleep(3_000) - waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) - - // Distinct fixtures per household: a regression that always searches - // household 1 would surface household 1's content here, not "no content" — - // a strictly stronger signal than just checking a search screen appeared. - mockServer.route("/households/1/search", fixture("search_results.json")) - mockServer.route("/households/2/search", fixture("search_results_household2.json")) - - // The Search tab is disabled until drawerUi.entries loads (HierarchyStore, - // async after login), and — crucially for THIS test — merely waiting for the - // tab to become *enabled* only proves entries is non-empty, i.e. at least ONE - // household loaded. That's not enough here: if only household 1 had loaded, - // entries.size == 1 and tapping Search would navigate straight into its - // search screen instead of opening the picker, silently testing the wrong - // thing. So prove BOTH households are present first: visit the Households tab - // (unconditionally enabled, unrelated to this gating) and wait for the second - // household ("Office") to render there — that only happens once the - // households list itself has loaded seconds after login. Re-queue /households - // since HouseholdsViewModel does its own independent fetch on init/visit. - mockServer.route("/households", fixture("households_two.json")) - onNodeWithTag("bottom-nav-households").performClick() - waitUntilAtLeastOneExists(hasContentDescription("Office"), timeoutMillis = 8_000) - - // Now safe to assume both households are in drawerUi.entries too (same login - // load produces both) — wait for Search to be enabled, then tap it. - waitUntilAtLeastOneExists(hasTestTag("bottom-nav-search").and(isEnabled()), timeoutMillis = 8_000) - - // Two households -> tapping Search must open the picker, not navigate directly. - onNodeWithTag("bottom-nav-search").performClick() - waitUntilAtLeastOneExists(hasTestTag("household-picker-Office"), timeoutMillis = 5_000) - onNodeWithTag("household-picker-Home").assertIsDisplayed() - onNodeWithTag("household-picker-Office").assertIsDisplayed() - - // Pick the SECOND household ("Office", id 2) — not the first. - onNodeWithTag("household-picker-Office").performClick() - waitUntilAtLeastOneExists(hasTestTag("search_field"), timeoutMillis = 5_000) - - onNodeWithTag("search_field").performTextInput("Coffee") - Thread.sleep(1_500) - waitForIdle() - - // Household 2's result shows... - waitUntilAtLeastOneExists(hasText("Pantry › Bottom shelf"), timeoutMillis = 5_000) - onNodeWithText("Pantry › Bottom shelf").assertIsDisplayed() - // ...and household 1's does not: proves the search request hit household 2's - // endpoint, not household 1's (the exact bug an earlier review caught). - onNodeWithText("Fridge › Top shelf").assertDoesNotExist() - } - } -} diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchNoResultsFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchNoResultsFlowTest.kt index 47b41eb..c417f3c 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchNoResultsFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchNoResultsFlowTest.kt @@ -5,10 +5,11 @@ package dev.scuttle.inventory.flow import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.isEnabled import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -37,12 +38,18 @@ class SearchNoResultsFlowTest : FlowTestBase() { Thread.sleep(3_000) waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) - mockServer.route("/households", fixture("households_one.json")) - // Search tab is disabled until drawerUi.entries loads (HierarchyStore, async - // after login) — tapping it before then is a no-op on a disabled node, not an - // error, so wait for enabled first or the click silently does nothing. - waitUntilAtLeastOneExists(hasTestTag("bottom-nav-search").and(isEnabled()), timeoutMillis = 8_000) - onNodeWithTag("bottom-nav-search").performClick() + // Search leaves the bottom bar (Task 7 — Households out, Scan takes the + // centre slot). It's reached from a household-scoped screen's top bar now: + // Storage tab → "Add storage location" (the + next to "Home") opens + // StorageOverviewScreen for household 1, whose top bar has the search icon. + onNodeWithTag("bottom-nav-home").performClick() + waitForIdle() + // StorageOverviewViewModel.load() calls GET /households/1/locations again. + mockServer.route("/households/1/locations", fixture("locations_one.json")) + waitUntilAtLeastOneExists(hasText("Home"), timeoutMillis = 5_000) + onNodeWithContentDescription("Add storage location").performClick() + waitUntilAtLeastOneExists(hasContentDescription("Search products"), timeoutMillis = 5_000) + onNodeWithContentDescription("Search products").performClick() waitForIdle() // Type a query that returns no results diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/SettingsVersionFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/SettingsVersionFlowTest.kt index 29f77f6..4b270fe 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/SettingsVersionFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/SettingsVersionFlowTest.kt @@ -8,7 +8,6 @@ import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick @@ -46,8 +45,9 @@ class SettingsVersionFlowTest : FlowTestBase() { Thread.sleep(3_000) waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) - // Dashboard's settings gear -> SettingsScreen - onNodeWithContentDescription("Settings").performClick() + // "More" tab -> SettingsScreen (Settings left the top-bar gear; it's a + // bottom-nav tab now). + onNodeWithTag("bottom-nav-more").performClick() waitUntilAtLeastOneExists(hasTestTag(SETTINGS_VERSION_TEST_TAG), timeoutMillis = 5_000) // The line sits below Sign out — off-screen on shorter devices. diff --git a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt index c6e056b..abce2c0 100644 --- a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt +++ b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt @@ -7,32 +7,24 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.People -import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.SpaceDashboard import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -43,7 +35,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavType @@ -253,15 +244,17 @@ private fun InventoryNavHost( val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route val drawerUi by drawerViewModel.state.collectAsState() - var showHouseholdPicker by rememberSaveable { mutableStateOf(false) } - val householdPickerSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val bottomTabs = listOf( BottomTab("dashboard", Routes.DASHBOARD, R.string.nav_dashboard, Icons.Filled.SpaceDashboard), BottomTab("home", Routes.HOME, R.string.nav_storage, Icons.Filled.Home), - BottomTab("households", Routes.HOUSEHOLDS, R.string.nav_households, Icons.Filled.People), + // The centre slot is the primary-ACTION slot. Scanning is a weekly + // grocery-trip action; search is an occasional "where did I put it", + // and it already has a top-bar icon. + BottomTab("scanner", Routes.SCANNER, R.string.nav_scan, Icons.Filled.QrCodeScanner), BottomTab("missing-items", Routes.MISSING_ITEMS, R.string.nav_missing_items, Icons.Filled.Warning), - BottomTab("search", Routes.SEARCH, R.string.nav_search, Icons.Filled.Search), + // Not "Settings": it now holds households, join/invite and account. + BottomTab("more", Routes.SETTINGS, R.string.nav_more, Icons.Filled.MoreHoriz), ) val onOpenSettings: () -> Unit = { navController.navigate(Routes.SETTINGS) { launchSingleTop = true } } Scaffold( @@ -272,39 +265,7 @@ private fun InventoryNavHost( bottomTabs.forEach { tab -> NavigationBarItem( selected = currentRoute == tab.route, - // Search needs a householdId to navigate to (or to offer a - // choice of household), which comes from drawerUi.entries — - // loaded asynchronously from HierarchyStore and possibly empty - // for real (a household-less account). Gate the tab on that so - // it's never tappable-but-inert: with nothing loaded yet (or - // ever), the tab shows disabled instead of silently doing - // nothing when tapped. The other four tabs don't depend on - // this data, so they stay always enabled. - enabled = if (tab.key == "search") drawerUi.entries.isNotEmpty() else true, - onClick = { - if (tab.key == "search") { - val entries = drawerUi.entries - when { - entries.size == 1 -> - // No saveState/restoreState: SEARCH is - // parameterized (search/{householdId}); a - // saved back-stack entry would restore - // verbatim with its OLD householdId, silently - // ignoring this one. - navController.navigate(Routes.search(entries.first().id)) { - popUpTo(Routes.DASHBOARD) - launchSingleTop = true - } - entries.size > 1 -> showHouseholdPicker = true - // Unreachable via the UI: `enabled` above already - // keeps the tab untappable when entries is empty. - // Kept as a defensive no-op, not a real dead end. - else -> Unit - } - } else { - navController.navigate(tab.route, tabNavOptions) - } - }, + onClick = { navController.navigate(tab.route, tabNavOptions) }, icon = { if (tab.key == "missing-items" && drawerUi.missingItemCount > 0) { BadgedBox(badge = { Badge { Text("${drawerUi.missingItemCount}") } }) { @@ -329,44 +290,6 @@ private fun InventoryNavHost( } }, ) { scaffoldPadding -> - if (showHouseholdPicker) { - ModalBottomSheet( - onDismissRequest = { showHouseholdPicker = false }, - sheetState = householdPickerSheetState, - ) { - Column( - modifier = - Modifier - .verticalScroll(rememberScrollState()) - .navigationBarsPadding(), - ) { - Text( - stringResource(R.string.search_choose_household_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(16.dp), - ) - drawerUi.entries.forEach { entry -> - NavigationDrawerItem( - label = { Text(entry.name) }, - selected = false, - onClick = { - showHouseholdPicker = false - // No saveState/restoreState: SEARCH is parameterized - // (search/{householdId}); a saved back-stack entry would - // restore verbatim with its OLD householdId, silently - // ignoring the household just picked here. - navController.navigate(Routes.search(entry.id)) { - popUpTo(Routes.DASHBOARD) - launchSingleTop = true - } - }, - modifier = - Modifier.padding(horizontal = 12.dp).testTag("household-picker-${entry.name}"), - ) - } - } - } - } NavHost( navController = navController, startDestination = Routes.AUTH, @@ -386,7 +309,6 @@ private fun InventoryNavHost( composable(Routes.HOME) { AllStoragesScreen( viewModel = drawerViewModel, - onOpenSettings = onOpenSettings, onOpenLocation = { hhId, locId -> navController.navigate(Routes.location(hhId, locId)) }, @@ -398,7 +320,6 @@ private fun InventoryNavHost( composable(Routes.DASHBOARD) { DashboardScreen( - onOpenSettings = onOpenSettings, onOpenLocation = { hhId, locId -> navController.navigate(Routes.location(hhId, locId)) }, @@ -517,7 +438,6 @@ private fun InventoryNavHost( composable(Routes.MISSING_ITEMS) { MissingItemsScreen( onBack = { navController.popBackStack() }, - onOpenSettings = onOpenSettings, onOpenLocation = { hhId, locId -> navController.navigate(Routes.location(hhId, locId)) }, diff --git a/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt index fb9f45b..68ee867 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog @@ -61,7 +60,6 @@ const val DASHBOARD_TITLE_TEST_TAG = "dashboard_top_bar_title" @Composable fun DashboardScreen( modifier: Modifier = Modifier, - onOpenSettings: () -> Unit = {}, onOpenLocation: (householdId: Long, locationId: Long) -> Unit = { _, _ -> }, onOpenHouseholds: () -> Unit = {}, onOpenMissingItems: () -> Unit = {}, @@ -108,9 +106,6 @@ fun DashboardScreen( IconButton(onClick = viewModel::refresh) { Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.action_refresh)) } - IconButton(onClick = onOpenSettings) { - Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.action_settings)) - } }, ) }, diff --git a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt index f489417..4ab895e 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material3.Card @@ -66,7 +65,6 @@ import dev.scuttle.inventory.ui.theme.FrostCard fun AllStoragesScreen( modifier: Modifier = Modifier, viewModel: DrawerViewModel, - onOpenSettings: () -> Unit = {}, onOpenLocation: (householdId: Long, locationId: Long) -> Unit = { _, _ -> }, onOpenStorage: (householdId: Long) -> Unit = {}, localViewModel: AllStoragesViewModel = hiltViewModel(), @@ -112,9 +110,6 @@ fun AllStoragesScreen( IconButton(onClick = { viewModel.refresh() }) { Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.action_refresh)) } - IconButton(onClick = onOpenSettings) { - Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.action_settings)) - } } }, ) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt index 91cc4d0..3ed603a 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -38,7 +37,6 @@ import dev.scuttle.inventory.ui.theme.FrostCard @Composable fun MissingItemsScreen( onBack: () -> Unit, - onOpenSettings: () -> Unit = {}, onOpenLocation: (householdId: Long, locationId: Long) -> Unit, viewModel: MissingItemsViewModel = hiltViewModel(), ) { @@ -68,9 +66,6 @@ fun MissingItemsScreen( modifier = Modifier.padding(end = 16.dp), ) } - IconButton(onClick = onOpenSettings) { - Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.action_settings)) - } }, ) }, diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 83c49b1..96b2601 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -33,6 +33,8 @@ Zoeken Huishoudens Ontbrekend + Scannen + Meer Barcode scannen Cameratoegang is nodig om barcodes te scannen. Je kunt producten nog steeds handmatig toevoegen en bewerken. Scan een barcode diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a8cce1..6073844 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,8 @@ Search Households Missing + Scan + More Scan barcode Camera permission is needed to scan barcodes. You can still add and edit products manually. Scan a barcode From 7380b80b912ee4f4a754a05be9bbb9bf565f5b4d Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 03:06:37 +0200 Subject: [PATCH 22/40] fix: restore Search reachability and fix the dead-end Scan tab (Task 7 review) Task 7's bottom-nav rework shipped with Search only reachable through two buried, pre-existing paths (Dashboard's products stat card and Storage Overview's search icon) instead of the direct top-bar entry the approved spec calls for ("Search loses its tab and keeps the top-bar icon it already has"). Adds a Search icon to the top bar of Dashboard, the Storage tab (AllStoragesScreen) and Missing items, all wired to Routes.search(). Guards reachability with a source-scan JVM test (no Robolectric/compose-ui-test on this module's unit-test classpath) that fails if either MainActivity stops wiring a screen's onOpenSearch to Routes.search(...) or a screen stops actually invoking it from the UI. Also fixes the Scan tab being a dead end: ScannerScreen had exactly one delivery contract (write scanned_code to the previous back-stack entry and pop), designed around its only caller, LocationDetailScreen. The bottom-bar Scan tab opened the same route with no shelf screen underneath it, so scanning from the bar silently did nothing. Makes the caller's intent an explicit route argument (scanner?mode=add|lookup) instead of inferring it from the back stack: ADD keeps the existing add-to-shelf delivery, LOOKUP navigates to Search with the scanned code pre-filled and immediately run (SearchViewModel.searchFor). The mode decision and the two delivery branches are pure, unit-tested functions (ScannerMode, scanDeliveryActionFor); source tests confirm the two branches can't be confused with each other and that each entry point requests the right mode. Removes now-dead nav_households and search_choose_household_title strings; keeps nav_search, reused for the new icons' content description. Every added/modified test was verified to go red on the bug it guards (mutation-tested and reverted) before this commit. --- .../dev/scuttle/inventory/MainActivity.kt | 152 ++++++++++++-- .../inventory/ui/dashboard/DashboardScreen.kt | 8 + .../inventory/ui/home/AllStoragesScreen.kt | 15 ++ .../ui/missing/MissingItemsScreen.kt | 12 ++ .../inventory/ui/search/SearchScreen.kt | 10 + .../inventory/ui/search/SearchViewModel.kt | 11 ++ app/src/main/res/values-nl/strings.xml | 2 - app/src/main/res/values/strings.xml | 2 - .../scuttle/inventory/NavigationWiringTest.kt | 186 ++++++++++++++++++ .../inventory/ScanDeliveryActionTest.kt | 70 +++++++ .../scuttle/inventory/SearchViewModelTest.kt | 27 +++ 11 files changed, 480 insertions(+), 15 deletions(-) create mode 100644 app/src/test/java/dev/scuttle/inventory/NavigationWiringTest.kt create mode 100644 app/src/test/java/dev/scuttle/inventory/ScanDeliveryActionTest.kt diff --git a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt index abce2c0..4a1ad5b 100644 --- a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt +++ b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt @@ -120,19 +120,86 @@ private data class BottomTab( val route: String, val labelRes: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector, + // Where a tap on this tab actually navigates. Defaults to [route] — the plain + // "this tab IS this destination" case every tab but Scan fits. Kept distinct + // from [route] (which is also the pattern matched against currentRoute to + // decide selection/bottom-bar-visibility) because Scan's destination is + // parameterized (Routes.SCANNER = "scanner?mode={mode}"): [route] has to stay + // the bare pattern for that match to work, while the tap needs a concrete + // `mode` value baked in. + val navigateTo: String = route, ) +/** + * Which caller opened the scanner, carried explicitly as a route argument rather + * than inferred from the back stack. Before this, [Routes.SCANNER] had exactly one + * delivery contract — write the code to the previous entry's savedStateHandle and + * pop — which only makes sense when a shelf screen (LocationDetailScreen) is that + * previous entry. Once the bottom-bar Scan tab started opening the same route with + * Dashboard/whatever-tab as the previous entry, that contract silently broke: nothing + * there reads `scanned_code`, so a bottom-bar scan did nothing. Making the caller's + * intent an explicit argument (ADD vs LOOKUP) lets [scanDeliveryActionFor] give each + * caller its own, correct behavior instead of guessing from the stack shape. + */ +enum class ScannerMode( + val argValue: String, +) { + /** Opened from a shelf screen: deliver the code back via savedStateHandle. */ + ADD("add"), + + /** Opened from the bottom bar, with no shelf context: hand the code to Search. */ + LOOKUP("lookup"), + ; + + companion object { + /** Unrecognized or missing values fall back to ADD, the pre-existing behavior + * for the route's only caller before LOOKUP existed. */ + fun from(argValue: String?): ScannerMode = entries.firstOrNull { it.argValue == argValue } ?: ADD + } +} + +/** What a freshly-scanned barcode should do next, decided purely from [ScannerMode] — no + * NavController involved, so the ADD/LOOKUP branch is unit-testable on its own (see + * ScanDeliveryActionTest). Mirrors [authRedirectFor]'s split between pure decision and + * NavController side effect below. + */ +sealed interface ScanDeliveryAction { + /** ADD: deliver [code] to the caller's savedStateHandle and pop back to it — the + * "add this to the shelf I came from" contract LocationDetailScreen expects. + */ + data class DeliverToCaller( + val code: String, + ) : ScanDeliveryAction + + /** LOOKUP: navigate to Search with [code] as the pre-filled, already-run query — + * "do I have this, and where did I put it" (the spec's role for Search), and the + * only sensible outcome when there's no shelf to add to. + */ + data class NavigateToSearch( + val code: String, + ) : ScanDeliveryAction +} + +fun scanDeliveryActionFor( + mode: ScannerMode, + code: String, +): ScanDeliveryAction = + when (mode) { + ScannerMode.ADD -> ScanDeliveryAction.DeliverToCaller(code) + ScannerMode.LOOKUP -> ScanDeliveryAction.NavigateToSearch(code) + } + private object Routes { const val AUTH = "auth" const val FORGOT_PASSWORD = "forgot-password" const val HOME = "home" - const val SCANNER = "scanner" + const val SCANNER = "scanner?mode={mode}" const val DASHBOARD = "dashboard" const val HOUSEHOLDS = "households" const val HOUSEHOLD_EDIT = "household-edit/{householdId}" const val SETTINGS = "settings" const val STORAGE = "storage/{householdId}" - const val SEARCH = "search/{householdId}" + const val SEARCH = "search/{householdId}?query={query}" const val INVITE = "invite/{householdId}/{householdName}" const val LOCATION = "location/{householdId}/{locationId}" const val PRODUCT_DETAIL = "product-detail/{householdId}/{shelfId}/{productId}" @@ -142,7 +209,17 @@ private object Routes { fun storage(householdId: Long) = "storage/$householdId" - fun search(householdId: Long) = "search/$householdId" + fun scanner(mode: ScannerMode) = "scanner?mode=${mode.argValue}" + + /** + * [query] pre-fills and immediately runs a search (the scan-to-lookup flow) — + * left null for every other caller, which just opens Search on an empty query + * exactly as before this existed. + */ + fun search( + householdId: Long, + query: String? = null, + ) = "search/$householdId" + if (query != null) "?query=${java.net.URLEncoder.encode(query, "UTF-8")}" else "" fun invite( householdId: Long, @@ -250,8 +327,15 @@ private fun InventoryNavHost( BottomTab("home", Routes.HOME, R.string.nav_storage, Icons.Filled.Home), // The centre slot is the primary-ACTION slot. Scanning is a weekly // grocery-trip action; search is an occasional "where did I put it", - // and it already has a top-bar icon. - BottomTab("scanner", Routes.SCANNER, R.string.nav_scan, Icons.Filled.QrCodeScanner), + // and it already has a top-bar icon. Opened from here (no shelf + // context), the scan resolves to a Search lookup — see ScannerMode. + BottomTab( + "scanner", + Routes.SCANNER, + R.string.nav_scan, + Icons.Filled.QrCodeScanner, + navigateTo = Routes.scanner(ScannerMode.LOOKUP), + ), BottomTab("missing-items", Routes.MISSING_ITEMS, R.string.nav_missing_items, Icons.Filled.Warning), // Not "Settings": it now holds households, join/invite and account. BottomTab("more", Routes.SETTINGS, R.string.nav_more, Icons.Filled.MoreHoriz), @@ -265,7 +349,7 @@ private fun InventoryNavHost( bottomTabs.forEach { tab -> NavigationBarItem( selected = currentRoute == tab.route, - onClick = { navController.navigate(tab.route, tabNavOptions) }, + onClick = { navController.navigate(tab.navigateTo, tabNavOptions) }, icon = { if (tab.key == "missing-items" && drawerUi.missingItemCount > 0) { BadgedBox(badge = { Badge { Text("${drawerUi.missingItemCount}") } }) { @@ -315,6 +399,9 @@ private fun InventoryNavHost( onOpenStorage = { hhId -> navController.navigate(Routes.storage(hhId)) }, + onOpenSearch = { hhId -> + navController.navigate(Routes.search(hhId)) { launchSingleTop = true } + }, ) } @@ -378,11 +465,20 @@ private fun InventoryNavHost( composable( route = Routes.SEARCH, - arguments = listOf(navArgument("householdId") { type = NavType.LongType }), + arguments = + listOf( + navArgument("householdId") { type = NavType.LongType }, + navArgument("query") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), ) { entry -> val householdId = entry.arguments?.getLong("householdId") ?: return@composable SearchScreen( householdId = householdId, + initialQuery = entry.arguments?.getString("query"), onBack = { navController.popBackStack() }, onOpenSettings = onOpenSettings, onOpenProduct = { hhId, shelfId, productId -> @@ -429,7 +525,7 @@ private fun InventoryNavHost( onOpenProduct = { hhId, shelfId, productId -> navController.navigate(Routes.productDetail(hhId, shelfId, productId)) }, - onOpenScanner = { navController.navigate(Routes.SCANNER) }, + onOpenScanner = { navController.navigate(Routes.scanner(ScannerMode.ADD)) }, scannedCode = scannedCode, onScannedCodeConsumed = { entry.savedStateHandle["scanned_code"] = null }, ) @@ -441,14 +537,48 @@ private fun InventoryNavHost( onOpenLocation = { hhId, locId -> navController.navigate(Routes.location(hhId, locId)) }, + firstHouseholdId = drawerUi.entries.firstOrNull()?.id, + onOpenSearch = { hhId -> + navController.navigate(Routes.search(hhId)) { launchSingleTop = true } + }, ) } - composable(Routes.SCANNER) { + composable( + route = Routes.SCANNER, + arguments = + listOf( + navArgument("mode") { + type = NavType.StringType + defaultValue = ScannerMode.ADD.argValue + }, + ), + ) { entry -> + val mode = ScannerMode.from(entry.arguments?.getString("mode")) ScannerScreen( onScanned = { code -> - navController.previousBackStackEntry?.savedStateHandle?.set("scanned_code", code) - navController.popBackStack() + when (val action = scanDeliveryActionFor(mode, code)) { + is ScanDeliveryAction.DeliverToCaller -> { + navController.previousBackStackEntry + ?.savedStateHandle + ?.set("scanned_code", action.code) + navController.popBackStack() + } + is ScanDeliveryAction.NavigateToSearch -> { + val householdId = drawerUi.entries.firstOrNull()?.id + if (householdId != null) { + navController.navigate(Routes.search(householdId, action.code)) { + popUpTo(Routes.SCANNER) { inclusive = true } + launchSingleTop = true + } + } else { + // Household-less account: nothing to look up in. + // Back out of the scanner instead of stranding the + // user on the camera. + navController.popBackStack() + } + } + } }, onBack = { navController.popBackStack() }, ) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt index 68ee867..62389df 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog @@ -103,6 +104,13 @@ fun DashboardScreen( ) }, actions = { + // Search lost its bottom-nav tab (Task 7) but keeps this top-bar + // icon, per spec — an occasional "where did I put it", not a daily + // destination. Guarded the same way the products stat card below + // is: nothing to search without at least one household. + IconButton(onClick = { state.firstHouseholdId?.let(onOpenSearch) }) { + Icon(Icons.Default.Search, contentDescription = stringResource(R.string.nav_search)) + } IconButton(onClick = viewModel::refresh) { Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.action_refresh)) } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt index 4ab895e..c679400 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.StarOutline import androidx.compose.material3.Card @@ -67,6 +68,7 @@ fun AllStoragesScreen( viewModel: DrawerViewModel, onOpenLocation: (householdId: Long, locationId: Long) -> Unit = { _, _ -> }, onOpenStorage: (householdId: Long) -> Unit = {}, + onOpenSearch: (householdId: Long) -> Unit = {}, localViewModel: AllStoragesViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() @@ -99,6 +101,19 @@ fun AllStoragesScreen( Text(stringResource(R.string.action_cancel)) } } else { + // Search lost its bottom-nav tab (Task 7) but keeps a top-bar + // icon here, per spec — same guard as Dashboard's: nothing to + // search without at least one household loaded. + IconButton( + onClick = { + state.entries + .firstOrNull() + ?.id + ?.let(onOpenSearch) + }, + ) { + Icon(Icons.Default.Search, contentDescription = stringResource(R.string.nav_search)) + } if (state.entries.any { it.locations.isNotEmpty() }) { IconButton(onClick = { editMode = true }) { Icon( diff --git a/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt index 3ed603a..9b2e261 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -38,6 +39,11 @@ import dev.scuttle.inventory.ui.theme.FrostCard fun MissingItemsScreen( onBack: () -> Unit, onOpenLocation: (householdId: Long, locationId: Long) -> Unit, + // MissingItemsUiState has no household list of its own (only items, which are + // empty in the common case this button matters least) — the caller passes the + // same "first household" HierarchyStore already resolves for Dashboard/Home. + firstHouseholdId: Long? = null, + onOpenSearch: (householdId: Long) -> Unit = {}, viewModel: MissingItemsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() @@ -58,6 +64,12 @@ fun MissingItemsScreen( } }, actions = { + // Search lost its bottom-nav tab (Task 7) but keeps a top-bar icon + // here, per spec — same "nothing to search without a household" + // guard as Dashboard/Home. + IconButton(onClick = { firstHouseholdId?.let(onOpenSearch) }) { + Icon(Icons.Default.Search, contentDescription = stringResource(R.string.nav_search)) + } if (state.items.isNotEmpty()) { Icon( imageVector = Icons.Default.Warning, diff --git a/app/src/main/java/dev/scuttle/inventory/ui/search/SearchScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/search/SearchScreen.kt index 025301b..54bcb99 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/search/SearchScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/search/SearchScreen.kt @@ -43,6 +43,10 @@ import dev.scuttle.inventory.ui.theme.FrostCard fun SearchScreen( householdId: Long, modifier: Modifier = Modifier, + // Set by the scan-to-lookup flow (bottom-bar Scan tab, mode=lookup): the + // scanned code, pre-filled as the query and run immediately rather than + // landing on an empty search box — see MainActivity's ScanDeliveryAction. + initialQuery: String? = null, onBack: () -> Unit = {}, onOpenSettings: () -> Unit = {}, onOpenProduct: (householdId: Long, shelfId: Long, productId: Long) -> Unit = { _, _, _ -> }, @@ -54,6 +58,12 @@ fun SearchScreen( LaunchedEffect(householdId) { viewModel.setHousehold(householdId) + // Runs in the same effect as setHousehold, after it: setHousehold on a + // new household resets state to a fresh SearchUiState(), which would + // wipe an initialQuery applied any earlier. + if (!initialQuery.isNullOrBlank()) { + viewModel.searchFor(initialQuery) + } } LaunchedEffect(Unit) { diff --git a/app/src/main/java/dev/scuttle/inventory/ui/search/SearchViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/search/SearchViewModel.kt index d15fd52..9c2beb1 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/search/SearchViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/search/SearchViewModel.kt @@ -65,6 +65,17 @@ class SearchViewModel searchJob = viewModelScope.launch { doSearch() } } + /** + * Applies [query] as the current search text and runs it immediately — + * for callers (the scan-to-lookup flow) that already have the exact term + * to search, not keystrokes to debounce like [onQueryChange]'s 300ms wait. + */ + fun searchFor(query: String) { + searchJob?.cancel() + _state.update { it.copy(query = query, error = null) } + searchJob = viewModelScope.launch { doSearch() } + } + fun setSort(order: SortOrder) = _state.update { it.copy(sort = order) } private suspend fun doSearch() { diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 96b2601..0a96d53 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -31,7 +31,6 @@ Dashboard Opslag Zoeken - Huishoudens Ontbrekend Scannen Meer @@ -158,7 +157,6 @@ Producten zoeken Geen resultaten. Aantal: %s - In welk huishouden zoeken? Instellingen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6073844..bb3dc67 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,7 +31,6 @@ Dashboard Storage Search - Households Missing Scan More @@ -158,7 +157,6 @@ Search products No matches. Qty: %s - Search which household? Settings diff --git a/app/src/test/java/dev/scuttle/inventory/NavigationWiringTest.kt b/app/src/test/java/dev/scuttle/inventory/NavigationWiringTest.kt new file mode 100644 index 0000000..b6c13b4 --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/NavigationWiringTest.kt @@ -0,0 +1,186 @@ +package dev.scuttle.inventory + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Task 7 shipped two Critical bugs that a full green suite + a self-review both + * missed, because nothing asserted *reachability* — only that individual pieces + * compiled and behaved correctly in isolation: + * + * 1. SearchScreen's route was declared but nothing navigated to it — Search had + * a bottom-nav tab removed and no replacement, so it went from reachable to + * orphaned with a fully green suite. + * 2. The scanner route gained a second caller (the bottom-bar Scan tab) with a + * savedStateHandle delivery contract that only makes sense for its original + * caller (LocationDetailScreen) — scanning from the bar silently did nothing. + * + * Neither is expressible as a normal Compose/ViewModel unit test: this module's + * JVM unit tests have no Robolectric or compose-ui-test on the classpath (only + * the instrumented androidTest source set does — see the flow tests under + * app/src/androidTest), so an actual NavHost can't be built or traversed here. + * This file instead scans MainActivity.kt and the top-level screens' source as + * plain text — the cheapest check that still discriminates: it fails if either + * (a) MainActivity stops wiring a screen's search callback to Routes.search(...), + * (b) a screen stops actually invoking that callback from the UI, or (c) the two + * scanner callers' delivery branches stop being distinguishable from each other. + */ +class NavigationWiringTest { + private val appRoot: File by lazy { findAppModuleRoot() } + + private fun read(relativePath: String): String { + val file = File(appRoot, "src/main/java/dev/scuttle/inventory/$relativePath") + check(file.exists()) { "Expected source file not found: ${file.absolutePath}" } + return file.readText() + } + + /** Walks up from the working directory to find the `app` module root, so this + * test doesn't depend on Gradle's exact Test-task working-directory convention. */ + private fun findAppModuleRoot(): File { + var dir: File? = File(".").absoluteFile + while (dir != null) { + if (File(dir, "src/main/java/dev/scuttle/inventory/MainActivity.kt").exists()) return dir + if (File(dir, "app/src/main/java/dev/scuttle/inventory/MainActivity.kt").exists()) { + return File(dir, "app") + } + dir = dir.parentFile + } + error("Could not locate the app module's src/main from working dir ${File(".").absolutePath}") + } + + /** + * The text of the `composable(...)` block containing [marker] — from the + * nearest preceding `composable(` up to (not including) the next one. Handles + * both `composable(Routes.X) { ... }` and `composable(route = Routes.X, ...)`. + */ + private fun composableBlock( + source: String, + marker: String, + ): String { + val markerIndex = source.indexOf(marker) + check(markerIndex >= 0) { "Could not find `$marker` in MainActivity.kt — did a route/wiring rename?" } + val blockStart = source.lastIndexOf("composable(", markerIndex) + check(blockStart >= 0) { "`$marker` isn't inside a composable(...) block" } + val nextBlock = source.indexOf("composable(", blockStart + "composable(".length) + val blockEnd = if (nextBlock == -1) source.length else nextBlock + return source.substring(blockStart, blockEnd) + } + + // --- Critical 1: Search must be reachable from every top-level screen --- + + @Test + fun `MainActivity wires Dashboard, Storage tab and Missing to Routes-search`() { + val mainActivity = read("MainActivity.kt") + listOf( + "Dashboard" to "composable(Routes.DASHBOARD)", + "Storage tab (Routes.HOME)" to "composable(Routes.HOME)", + "Missing items" to "composable(Routes.MISSING_ITEMS)", + ).forEach { (label, marker) -> + val block = composableBlock(mainActivity, marker) + assertTrue( + "$label's composable() block never navigates via Routes.search(...) — Search would be " + + "unreachable from it, exactly Critical 1 from the Task 7 report.", + block.contains("Routes.search("), + ) + } + } + + @Test + fun `Dashboard, AllStoragesScreen and MissingItemsScreen each invoke onOpenSearch from the UI`() { + listOf( + "ui/dashboard/DashboardScreen.kt", + "ui/home/AllStoragesScreen.kt", + "ui/missing/MissingItemsScreen.kt", + ).forEach { path -> + val src = read(path) + // Drop the parameter-declaration line ("onOpenSearch: (...) -> Unit = {}") + // so a screen that merely accepts the callback without ever calling it + // from a real UI element (button/click handler) still fails this check. + val withoutDeclaration = + src.lineSequence().filterNot { it.contains("onOpenSearch:") }.joinToString("\n") + assertTrue( + "$path declares onOpenSearch but never calls it from the UI — Search would be a dead " + + "parameter, not an actual entry point.", + withoutDeclaration.contains("onOpenSearch"), + ) + } + } + + // --- Critical 2: the two scanner callers must stay distinguishable --- + + @Test + fun `LocationDetailScreen opens the scanner in ADD mode, the bottom bar in LOOKUP mode`() { + val mainActivity = read("MainActivity.kt") + + val locationBlock = composableBlock(mainActivity, "route = Routes.LOCATION,") + assertTrue( + "LocationDetailScreen's onOpenScanner no longer opens ScannerMode.ADD — its scanned code " + + "would stop being deliverable back to the shelf it was opened from.", + locationBlock.contains("Routes.scanner(ScannerMode.ADD)"), + ) + + // The bottom-bar Scan tab is defined via Icons.Filled.QrCodeScanner (its + // unique marker in the tab list) rather than a composable(...) block — it's + // a BottomTab entry, not a destination. + val tabIndex = mainActivity.indexOf("Icons.Filled.QrCodeScanner") + check(tabIndex >= 0) { "Could not find the bottom-bar Scan tab's icon in MainActivity.kt" } + val tabWindow = mainActivity.substring(tabIndex, minOf(tabIndex + 300, mainActivity.length)) + assertTrue( + "The bottom-bar Scan tab no longer navigates with ScannerMode.LOOKUP — scanning from the " + + "bar would go back to inferring delivery from the back stack (Critical 2).", + tabWindow.contains("ScannerMode.LOOKUP"), + ) + } + + @Test + fun `the SCANNER destination delegates to scanDeliveryActionFor and its two branches cannot be confused`() { + val mainActivity = read("MainActivity.kt") + val scannerBlock = composableBlock(mainActivity, "route = Routes.SCANNER,") + + assertTrue( + "The SCANNER destination no longer calls scanDeliveryActionFor(...) — it may have gone " + + "back to inferring the caller's intent from the back stack instead of the explicit mode " + + "argument (which is what ScanDeliveryActionTest actually exercises).", + scannerBlock.contains("scanDeliveryActionFor("), + ) + + // Split the block on the two sealed-subtype branches and check each only + // does its OWN thing — this is the "cannot be confused with each other" + // assertion the task calls for, not just "each individually looks right". + val deliverIndex = scannerBlock.indexOf("DeliverToCaller ->") + val navigateIndex = scannerBlock.indexOf("NavigateToSearch ->") + check(deliverIndex >= 0 && navigateIndex >= 0) { + "Expected both ScanDeliveryAction branches (DeliverToCaller, NavigateToSearch) in the " + + "SCANNER destination's onScanned handler." + } + val (deliverBranch, navigateBranch) = + if (deliverIndex < navigateIndex) { + scannerBlock.substring(deliverIndex, navigateIndex) to scannerBlock.substring(navigateIndex) + } else { + scannerBlock.substring(deliverIndex) to scannerBlock.substring(navigateIndex, deliverIndex) + } + + assertTrue( + "DeliverToCaller branch must write to savedStateHandle (the add-to-shelf contract).", + deliverBranch.contains("savedStateHandle"), + ) + assertFalse( + "DeliverToCaller branch must NOT also navigate to Search — that would mean a scan opened " + + "from a shelf screen could ALSO silently redirect to Search, corrupting the add flow.", + deliverBranch.contains("Routes.search("), + ) + + assertTrue( + "NavigateToSearch branch must navigate via Routes.search(...) (the scan-to-lookup contract).", + navigateBranch.contains("Routes.search("), + ) + assertFalse( + "NavigateToSearch branch must NOT also write scanned_code to the previous entry's " + + "savedStateHandle — that's the ADD contract and there is no shelf screen underneath the " + + "bottom-bar Scan tab to receive it.", + navigateBranch.contains("savedStateHandle"), + ) + } +} diff --git a/app/src/test/java/dev/scuttle/inventory/ScanDeliveryActionTest.kt b/app/src/test/java/dev/scuttle/inventory/ScanDeliveryActionTest.kt new file mode 100644 index 0000000..76b9c51 --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/ScanDeliveryActionTest.kt @@ -0,0 +1,70 @@ +package dev.scuttle.inventory + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Guards Task 7's Critical 2 fix: the scanner route used to have exactly one + * delivery contract (write `scanned_code` to the previous back-stack entry's + * savedStateHandle and pop), inferred implicitly from "whoever opened me." That + * broke the moment a second caller (the bottom-bar Scan tab) started opening the + * same route with no shelf screen underneath it — scanning silently did nothing. + * + * [ScannerMode] makes the caller's intent an explicit route argument instead of + * something inferred from the back stack, and [scanDeliveryActionFor] is the pure + * decision table keyed on it — both unit-testable with no NavController involved. + */ +class ScanDeliveryActionTest { + @Test + fun mode_add_delivers_to_caller_via_savedStateHandle() { + val action = scanDeliveryActionFor(ScannerMode.ADD, "0512345678900") + assertEquals(ScanDeliveryAction.DeliverToCaller("0512345678900"), action) + } + + @Test + fun mode_lookup_navigates_to_search() { + val action = scanDeliveryActionFor(ScannerMode.LOOKUP, "0512345678900") + assertEquals(ScanDeliveryAction.NavigateToSearch("0512345678900"), action) + } + + /** + * The two branches must never collapse onto the same behavior — that is + * exactly how Critical 2 shipped (a single hard-coded delivery for both + * callers). Asserting they differ, not just that each individually matches + * its own expectation, is what would catch that regression coming back. + */ + @Test + fun add_and_lookup_never_produce_the_same_action() { + val code = "0512345678900" + val addAction = scanDeliveryActionFor(ScannerMode.ADD, code) + val lookupAction = scanDeliveryActionFor(ScannerMode.LOOKUP, code) + + assert(addAction != lookupAction) { + "ADD and LOOKUP produced the same ScanDeliveryAction: $addAction — the two callers " + + "would be indistinguishable again, which is exactly the bug this route argument fixes." + } + } + + @Test + fun from_parses_add() { + assertEquals(ScannerMode.ADD, ScannerMode.from("add")) + } + + @Test + fun from_parses_lookup() { + assertEquals(ScannerMode.LOOKUP, ScannerMode.from("lookup")) + } + + @Test + fun from_defaults_to_add_when_null() { + // Matches the SCANNER route's own NavType default (ScannerMode.ADD.argValue), + // preserving the original single-caller behavior for anything that reaches + // this composable without an explicit mode. + assertEquals(ScannerMode.ADD, ScannerMode.from(null)) + } + + @Test + fun from_defaults_to_add_when_unrecognized() { + assertEquals(ScannerMode.ADD, ScannerMode.from("bogus")) + } +} diff --git a/app/src/test/java/dev/scuttle/inventory/SearchViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/SearchViewModelTest.kt index 2d597d5..90ef0be 100644 --- a/app/src/test/java/dev/scuttle/inventory/SearchViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/SearchViewModelTest.kt @@ -111,4 +111,31 @@ class SearchViewModelTest { assertEquals("offline", viewModel.state.value.error) } + + /** + * The scan-to-lookup flow (MainActivity's ScanDeliveryAction.NavigateToSearch) + * calls searchFor() with the scanned code so the user lands on an answered + * search, not an empty box. Deliberately does NOT call advanceUntilIdle(): + * onQueryChange's 300ms debounce would leave results empty at this point under + * UnconfinedTestDispatcher (delay() only resolves on an explicit advance) — + * searchFor has no such delay, so this goes red if searchFor is ever + * implemented by routing through onQueryChange instead of running immediately. + */ + @Test + fun searchFor_runs_immediately_with_no_debounce() = + runTest { + val viewModel = SearchViewModel(FakeSearchRepository()) + viewModel.setHousehold(1) + + viewModel.searchFor("ice") + + assertEquals("ice", viewModel.state.value.query) + assertEquals(1, viewModel.state.value.results.size) + assertEquals( + "Vanilla ice cream", + viewModel.state.value.results + .first() + .name, + ) + } } From 851e567b9535c1fd01974818c154755941ac839e Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 03:28:18 +0200 Subject: [PATCH 23/40] feat: collapsible household groups on the storage list Collapse state and household order persist per device. A household's physical structure is shared reality and lives on the server; which of your households you care about is a personal view preference and stays local. --- app/detekt-baseline.xml | 2 +- .../inventory/data/auth/SessionCleaner.kt | 3 + .../data/settings/HouseholdViewStore.kt | 23 ++ .../settings/SharedPrefsHouseholdViewStore.kt | 40 +++ .../dev/scuttle/inventory/di/StorageModule.kt | 8 + .../inventory/ui/home/AllStoragesScreen.kt | 244 +++++++++++------- .../inventory/ui/home/AllStoragesViewModel.kt | 43 ++- app/src/main/res/values-nl/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../inventory/AllStoragesViewModelTest.kt | 133 +++++++++- .../scuttle/inventory/SessionCleanerTest.kt | 31 ++- 11 files changed, 437 insertions(+), 94 deletions(-) create mode 100644 app/src/main/java/dev/scuttle/inventory/data/settings/HouseholdViewStore.kt create mode 100644 app/src/main/java/dev/scuttle/inventory/data/settings/SharedPrefsHouseholdViewStore.kt diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 91c4a0d..e9a83bb 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -77,7 +77,7 @@ LongParameterList:DashboardScreen.kt$( modifier: Modifier = Modifier, onOpenDrawer: () -> Unit = {}, onOpenLocation: (householdId: Long, locationId: Long) -> Unit = { _, _ -> }, onOpenHouseholds: () -> Unit = {}, onOpenMissingItems: () -> Unit = {}, viewModel: DashboardViewModel = hiltViewModel(), ) LongParameterList:LocationDetailScreen.kt$( householdId: Long, locationId: Long, drawerViewModel: DrawerViewModel, modifier: Modifier = Modifier, onBack: () -> Unit = {}, onOpenDrawer: () -> Unit = {}, onOpenProduct: (householdId: Long, shelfId: Long, productId: Long) -> Unit = { _, _, _ -> }, shelvesViewModel: ShelvesViewModel = hiltViewModel(), ) LongParameterList:ProductsPane.kt$( householdId: Long, shelfId: Long, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, refreshKey: Int = 0, onOpenProduct: (ProductDto) -> Unit = {}, onWarningChange: (Boolean) -> Unit = {}, viewModel: ProductsViewModel = hiltViewModel(key = "products-$shelfId"), ) - LongParameterList:SessionCleaner.kt$SessionCleaner$( private val householdRepository: HouseholdRepository, private val locationRepository: LocationRepository, private val shelfRepository: ShelfRepository, private val productRepository: ProductRepository, private val hierarchyStore: HierarchyStore, private val favoritesStore: FavoritesStore, private val defaultHouseholdStore: DefaultHouseholdStore, private val shelfViewStore: ShelfViewStore, ) + LongParameterList:SessionCleaner.kt$SessionCleaner$( private val householdRepository: HouseholdRepository, private val locationRepository: LocationRepository, private val shelfRepository: ShelfRepository, private val productRepository: ProductRepository, private val hierarchyStore: HierarchyStore, private val favoritesStore: FavoritesStore, private val defaultHouseholdStore: DefaultHouseholdStore, private val shelfViewStore: ShelfViewStore, private val householdViewStore: HouseholdViewStore, ) LongParameterList:SettingsScreen.kt$( modifier: Modifier = Modifier, onOpenDrawer: () -> Unit = {}, onSignOut: () -> Unit = {}, onOpenHouseholds: () -> Unit = {}, themeViewModel: ThemeViewModel = hiltViewModel(), languageViewModel: LanguageViewModel = hiltViewModel(), joinViewModel: JoinHouseholdViewModel = hiltViewModel(), ) LongParameterList:StorageOverviewScreen.kt$( householdId: Long, modifier: Modifier = Modifier, onBack: () -> Unit = {}, onOpenDrawer: () -> Unit = {}, onOpenLocation: (Long) -> Unit = {}, onOpenSearch: () -> Unit = {}, viewModel: StorageOverviewViewModel = hiltViewModel(), ) MagicNumber:AuthInterceptor.kt$AuthInterceptor$401 diff --git a/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt b/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt index b65c931..58aae1a 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/auth/SessionCleaner.kt @@ -6,6 +6,7 @@ import dev.scuttle.inventory.data.location.LocationRepository import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.settings.DefaultHouseholdStore import dev.scuttle.inventory.data.settings.FavoritesStore +import dev.scuttle.inventory.data.settings.HouseholdViewStore import dev.scuttle.inventory.data.settings.ShelfViewStore import dev.scuttle.inventory.data.shelf.ShelfRepository import javax.inject.Inject @@ -39,6 +40,7 @@ class SessionCleaner private val favoritesStore: FavoritesStore, private val defaultHouseholdStore: DefaultHouseholdStore, private val shelfViewStore: ShelfViewStore, + private val householdViewStore: HouseholdViewStore, ) { fun clear() { householdRepository.clear() @@ -49,5 +51,6 @@ class SessionCleaner favoritesStore.clear() defaultHouseholdStore.clear() shelfViewStore.clear() + householdViewStore.clear() } } diff --git a/app/src/main/java/dev/scuttle/inventory/data/settings/HouseholdViewStore.kt b/app/src/main/java/dev/scuttle/inventory/data/settings/HouseholdViewStore.kt new file mode 100644 index 0000000..db605db --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/data/settings/HouseholdViewStore.kt @@ -0,0 +1,23 @@ +package dev.scuttle.inventory.data.settings + +/** + * Per-device view state for the storage list's household groups: which are + * collapsed, and the order the user has dragged them into. + * + * A household's *physical* structure (its locations/shelves/products) is + * shared, server-owned reality. Which of a user's households they currently + * care about looking at, and in what order, is a personal view preference — + * it never reaches the server (D8). Mirrors [ShelfViewStore]'s shape. + */ +interface HouseholdViewStore { + fun collapsed(): Set + + fun toggleCollapsed(id: Long) + + fun order(): List + + fun setOrder(ids: List) + + /** Forget collapse state + order so one account's view never carries into the next session. */ + fun clear() {} +} diff --git a/app/src/main/java/dev/scuttle/inventory/data/settings/SharedPrefsHouseholdViewStore.kt b/app/src/main/java/dev/scuttle/inventory/data/settings/SharedPrefsHouseholdViewStore.kt new file mode 100644 index 0000000..4043834 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/data/settings/SharedPrefsHouseholdViewStore.kt @@ -0,0 +1,40 @@ +package dev.scuttle.inventory.data.settings + +import android.content.Context + +class SharedPrefsHouseholdViewStore( + context: Context, +) : HouseholdViewStore { + private val prefs = context.getSharedPreferences("household_view", Context.MODE_PRIVATE) + + override fun collapsed(): Set = + prefs.getStringSet(KEY_COLLAPSED, emptySet())?.mapNotNull { it.toLongOrNull() }?.toSet() ?: emptySet() + + override fun toggleCollapsed(id: Long) { + val current = collapsed().toMutableSet() + if (!current.add(id)) current.remove(id) + prefs.edit().putStringSet(KEY_COLLAPSED, current.map { it.toString() }.toSet()).apply() + } + + // A StringSet can't preserve order, so the drag order is stored as a single + // delimited string instead — same prefs file, a different key/shape than + // `collapsed()`'s set. + override fun order(): List = + prefs.getString(KEY_ORDER, null)?.takeIf { it.isNotEmpty() }?.split(ORDER_DELIMITER)?.mapNotNull { + it.toLongOrNull() + } ?: emptyList() + + override fun setOrder(ids: List) { + prefs.edit().putString(KEY_ORDER, ids.joinToString(ORDER_DELIMITER)).apply() + } + + override fun clear() { + prefs.edit().clear().apply() + } + + private companion object { + const val KEY_COLLAPSED = "collapsed_household_ids" + const val KEY_ORDER = "household_order" + const val ORDER_DELIMITER = "," + } +} diff --git a/app/src/main/java/dev/scuttle/inventory/di/StorageModule.kt b/app/src/main/java/dev/scuttle/inventory/di/StorageModule.kt index 5837f55..49c506e 100644 --- a/app/src/main/java/dev/scuttle/inventory/di/StorageModule.kt +++ b/app/src/main/java/dev/scuttle/inventory/di/StorageModule.kt @@ -9,9 +9,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dev.scuttle.inventory.data.settings.DefaultHouseholdStore import dev.scuttle.inventory.data.settings.FavoritesStore +import dev.scuttle.inventory.data.settings.HouseholdViewStore import dev.scuttle.inventory.data.settings.LanguageStore import dev.scuttle.inventory.data.settings.SharedPrefsDefaultHouseholdStore import dev.scuttle.inventory.data.settings.SharedPrefsFavoritesStore +import dev.scuttle.inventory.data.settings.SharedPrefsHouseholdViewStore import dev.scuttle.inventory.data.settings.SharedPrefsLanguageStore import dev.scuttle.inventory.data.settings.SharedPrefsShelfViewStore import dev.scuttle.inventory.data.settings.SharedPrefsThemeModeStore @@ -60,6 +62,12 @@ object StorageModule { @ApplicationContext context: Context, ): ShelfViewStore = SharedPrefsShelfViewStore(context) + @Provides + @Singleton + fun provideHouseholdViewStore( + @ApplicationContext context: Context, + ): HouseholdViewStore = SharedPrefsHouseholdViewStore(context) + @Provides @Singleton fun provideContentResolver( diff --git a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt index c679400..8a141bf 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt @@ -1,5 +1,7 @@ package dev.scuttle.inventory.ui.home +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,12 +13,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Star @@ -47,8 +51,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -56,6 +64,7 @@ import dev.scuttle.inventory.R import dev.scuttle.inventory.ui.app.DrawerViewModel import dev.scuttle.inventory.ui.common.ErrorRetry import dev.scuttle.inventory.ui.common.SnackbarErrorEffect +import dev.scuttle.inventory.ui.common.orderByPosition import dev.scuttle.inventory.ui.common.storageTypeLabel import dev.scuttle.inventory.ui.hierarchy.DeleteStrategyDialog import dev.scuttle.inventory.ui.hierarchy.locationStrategyOptions @@ -158,113 +167,174 @@ fun AllStoragesScreen( Text(stringResource(R.string.all_storage_empty)) } - state.entries.forEach { entry -> - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp, start = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = entry.name, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Household order is a device-local view preference (D8), never + // server state — see AllStoragesViewModel.orderedEntries's doc. + localViewModel.orderedEntries(state.entries).forEach { entry -> + key(entry.id) { + val isCollapsed = entry.id in localState.collapsedHouseholdIds + // KeyboardArrowDown rotated -90° reads as a right-pointing + // (collapsed) chevron and animates back to pointing-down + // (expanded) — one icon, two states, matching this file's + // existing "no new icon asset for a two-state toggle" + // idiom (see the favorite star below). Keyed by household + // id (like the location loop below) so this per-entry + // animation state is never misattributed to a different + // household across recompositions. + val chevronRotation by animateFloatAsState( + targetValue = if (isCollapsed) -90f else 0f, + label = "household-chevron-${entry.id}", ) - if (!editMode) { - IconButton(onClick = { onOpenStorage(entry.id) }) { + val toggleCollapsedCd = + if (isCollapsed) { + stringResource(R.string.all_storage_expand_household_cd, entry.name) + } else { + stringResource(R.string.all_storage_collapse_household_cd, entry.name) + } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp, start = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = + Modifier + .weight(1f) + .clickable(role = Role.Button) { + localViewModel.toggleCollapsed(entry.id) + }.clearAndSetSemantics { contentDescription = toggleCollapsedCd }, + verticalAlignment = Alignment.CenterVertically, + ) { Icon( - Icons.Default.Add, - contentDescription = stringResource(R.string.all_storage_add_location_cd), + Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.rotate(chevronRotation), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = entry.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + if (!editMode) { + IconButton(onClick = { onOpenStorage(entry.id) }) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(R.string.all_storage_add_location_cd), + ) + } + } } - } - entry.locations.forEach { location -> - key(location.id) { - val hasWarning = state.locationWarnings[location.id] == true - val isFavorite = location.id in localState.favoriteLocationIds + // Collapsing only hides these rows -- it never touches + // edit mode, favorites, or the delete-confirmation flow + // below (state.pendingDelete), which lives outside this + // Column entirely and stays reachable regardless of + // collapse state. A row that isn't rendered can't be + // tapped, so a collapsed group can never carry a pending + // delete the user can't see. + val orderedLocations = + if (isCollapsed) { + emptyList() + } else { + orderByPosition(entry.locations, { it.position }, { it.name }) + } + orderedLocations.forEach { location -> + key(location.id) { + val hasWarning = state.locationWarnings[location.id] == true + val isFavorite = location.id in localState.favoriteLocationIds - val rowContent: @Composable () -> Unit = { - Row( - modifier = - Modifier - .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - location.name, - style = MaterialTheme.typography.bodyLarge, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val rowContent: @Composable () -> Unit = { + Row( + modifier = + Modifier + .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { Text( - storageTypeLabel(location.type), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + location.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) - if (hasWarning) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text( - stringResource(R.string.all_storage_stock_warning), + storageTypeLabel(location.type), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (hasWarning) { + Text( + stringResource(R.string.all_storage_stock_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } } - } - if (editMode) { - IconButton(onClick = { viewModel.requestDelete(entry.id, location.id) }) { - Icon( - Icons.Default.Delete, - contentDescription = stringResource(R.string.action_delete), - tint = MaterialTheme.colorScheme.error, - ) - } - } else { - IconButton(onClick = { localViewModel.toggleFavorite(location.id) }) { - Icon( - if (isFavorite) Icons.Default.Star else Icons.Outlined.StarOutline, - contentDescription = - if (isFavorite) { - stringResource( - R.string.all_storage_favorite_remove_cd, - ) - } else { - stringResource(R.string.all_storage_favorite_add_cd) - }, - tint = + if (editMode) { + IconButton( + onClick = { viewModel.requestDelete(entry.id, location.id) }, + ) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.action_delete), + tint = MaterialTheme.colorScheme.error, + ) + } + } else { + IconButton(onClick = { localViewModel.toggleFavorite(location.id) }) { + Icon( if (isFavorite) { - MaterialTheme.colorScheme.primary + Icons.Default.Star } else { - MaterialTheme.colorScheme.onSurfaceVariant + Icons.Outlined.StarOutline }, - ) + contentDescription = + if (isFavorite) { + stringResource( + R.string.all_storage_favorite_remove_cd, + ) + } else { + stringResource(R.string.all_storage_favorite_add_cd) + }, + tint = + if (isFavorite) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } } } } - } - if (hasWarning) { - Card( - onClick = { onOpenLocation(entry.id, location.id) }, - modifier = Modifier.fillMaxWidth().testTag("home-location-${location.name}"), - colors = - CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.errorContainer.copy( - alpha = 0.4f, - ), - ), - content = { rowContent() }, - ) - } else { - FrostCard( - onClick = { onOpenLocation(entry.id, location.id) }, - modifier = Modifier.fillMaxWidth().testTag("home-location-${location.name}"), - content = { rowContent() }, - ) + if (hasWarning) { + Card( + onClick = { onOpenLocation(entry.id, location.id) }, + modifier = + Modifier.fillMaxWidth().testTag("home-location-${location.name}"), + colors = + CardDefaults.cardColors( + containerColor = + MaterialTheme.colorScheme.errorContainer.copy( + alpha = 0.4f, + ), + ), + content = { rowContent() }, + ) + } else { + FrostCard( + onClick = { onOpenLocation(entry.id, location.id) }, + modifier = + Modifier.fillMaxWidth().testTag("home-location-${location.name}"), + content = { rowContent() }, + ) + } } } } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesViewModel.kt index 34125f5..1ab6b1c 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesViewModel.kt @@ -2,7 +2,10 @@ package dev.scuttle.inventory.ui.home import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import dev.scuttle.inventory.data.HouseholdWithLocations import dev.scuttle.inventory.data.settings.FavoritesStore +import dev.scuttle.inventory.data.settings.HouseholdViewStore +import dev.scuttle.inventory.ui.common.orderByPosition import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -11,6 +14,10 @@ import javax.inject.Inject data class AllStoragesUiState( val favoriteLocationIds: Set = emptySet(), + // Which household groups are currently collapsed on the storage list. Purely + // a presentation toggle -- see AllStoragesViewModel.toggleCollapsed's doc for + // why it must never touch anything else (favorites, edit mode, delete flow). + val collapsedHouseholdIds: Set = emptySet(), ) @HiltViewModel @@ -18,12 +25,46 @@ class AllStoragesViewModel @Inject constructor( private val favoritesStore: FavoritesStore, + private val householdViewStore: HouseholdViewStore, ) : ViewModel() { - private val _state = MutableStateFlow(AllStoragesUiState(favoritesStore.getFavoriteLocations())) + private val _state = + MutableStateFlow( + AllStoragesUiState( + favoriteLocationIds = favoritesStore.getFavoriteLocations(), + collapsedHouseholdIds = householdViewStore.collapsed(), + ), + ) val state: StateFlow = _state.asStateFlow() fun toggleFavorite(id: Long) { favoritesStore.toggleFavoriteLocation(id) _state.update { it.copy(favoriteLocationIds = favoritesStore.getFavoriteLocations()) } } + + /** + * Collapse/expand ONE household group. Deliberately touches nothing but + * [HouseholdViewStore]'s own collapsed-id set: no favorite, no edit-mode + * state, and no delete-flow state (that lives entirely in DrawerViewModel, + * a separate class) is ever read or written here. AllStoragesScreen has no + * persisted multi-select either -- a delete is a single, immediate tap + * that opens a confirmation dialog on the spot -- so there is no "pending + * selection" this toggle could ever hide. Keeping the two concerns + * structurally separate is what makes that true by construction rather + * than by convention. + */ + fun toggleCollapsed(id: Long) { + householdViewStore.toggleCollapsed(id) + _state.update { it.copy(collapsedHouseholdIds = householdViewStore.collapsed()) } + } + + /** + * Household groups in the user's device-local drag order (D8): manual + * position wins, name is the tie-break for households nobody has + * reordered yet -- the same [orderByPosition] rule Task 2 pinned for + * locations/shelves, applied here to household groups themselves. + */ + fun orderedEntries(entries: List): List { + val order = householdViewStore.order() + return orderByPosition(entries, { order.indexOf(it.id).takeIf { idx -> idx >= 0 } }, { it.name }) + } } diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0a96d53..eca8deb 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -59,6 +59,8 @@ ⚠ Voorraadwaarschuwing Toevoegen aan favorieten Verwijderen uit favorieten + %1$s inklappen + %1$s uitklappen \"%s\" verwijderen? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bb3dc67..08fbc43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,6 +59,8 @@ ⚠ Stock warning Add to favorites Remove favorite + Collapse %1$s + Expand %1$s Delete \"%s\"? diff --git a/app/src/test/java/dev/scuttle/inventory/AllStoragesViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/AllStoragesViewModelTest.kt index 52f357a..a5a6d1f 100644 --- a/app/src/test/java/dev/scuttle/inventory/AllStoragesViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/AllStoragesViewModelTest.kt @@ -1,7 +1,11 @@ package dev.scuttle.inventory +import dev.scuttle.inventory.data.HouseholdWithLocations import dev.scuttle.inventory.data.settings.FavoritesStore +import dev.scuttle.inventory.data.settings.HouseholdViewStore import dev.scuttle.inventory.ui.home.AllStoragesViewModel +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -30,29 +34,56 @@ class AllStoragesViewModelTest { override fun isFavoriteShelf(id: Long) = id in shelves } + private class FakeHouseholdViewStore( + initialCollapsed: Set = emptySet(), + initialOrder: List = emptyList(), + ) : HouseholdViewStore { + private val collapsedIds = initialCollapsed.toMutableSet() + private var storedOrder = initialOrder + var cleared = false + + override fun collapsed(): Set = collapsedIds.toSet() + + override fun toggleCollapsed(id: Long) { + if (!collapsedIds.add(id)) collapsedIds.remove(id) + } + + override fun order(): List = storedOrder + + override fun setOrder(ids: List) { + storedOrder = ids + } + + override fun clear() { + cleared = true + collapsedIds.clear() + storedOrder = emptyList() + } + } + @Test fun initial_state_loads_favorites_from_store() { - val vm = AllStoragesViewModel(FakeFavoritesStore(initialLocations = setOf(10L))) + val vm = AllStoragesViewModel(FakeFavoritesStore(initialLocations = setOf(10L)), FakeHouseholdViewStore()) assertTrue(10L in vm.state.value.favoriteLocationIds) } @Test fun toggle_adds_favorite_when_not_set() { - val vm = AllStoragesViewModel(FakeFavoritesStore()) + val vm = AllStoragesViewModel(FakeFavoritesStore(), FakeHouseholdViewStore()) vm.toggleFavorite(10L) assertTrue(10L in vm.state.value.favoriteLocationIds) } @Test fun toggle_removes_favorite_when_already_set() { - val vm = AllStoragesViewModel(FakeFavoritesStore(initialLocations = setOf(10L))) + val vm = AllStoragesViewModel(FakeFavoritesStore(initialLocations = setOf(10L)), FakeHouseholdViewStore()) vm.toggleFavorite(10L) assertFalse(10L in vm.state.value.favoriteLocationIds) } @Test fun toggle_is_idempotent_add_remove_add() { - val vm = AllStoragesViewModel(FakeFavoritesStore()) + val vm = AllStoragesViewModel(FakeFavoritesStore(), FakeHouseholdViewStore()) vm.toggleFavorite(5L) assertTrue(5L in vm.state.value.favoriteLocationIds) vm.toggleFavorite(5L) @@ -60,4 +91,98 @@ class AllStoragesViewModelTest { vm.toggleFavorite(5L) assertTrue(5L in vm.state.value.favoriteLocationIds) } + + @Test + fun collapsing_a_household_persists() = + runTest { + val store = FakeHouseholdViewStore() + val viewModel = AllStoragesViewModel(FakeFavoritesStore(), store) + + viewModel.toggleCollapsed(1L) + + assertTrue(1L in viewModel.state.value.collapsedHouseholdIds) + assertTrue(1L in store.collapsed()) + } + + @Test + fun initial_state_loads_collapsed_ids_from_store() { + val vm = AllStoragesViewModel(FakeFavoritesStore(), FakeHouseholdViewStore(initialCollapsed = setOf(2L))) + assertTrue(2L in vm.state.value.collapsedHouseholdIds) + } + + @Test + fun expanding_a_collapsed_household_persists() { + val store = FakeHouseholdViewStore(initialCollapsed = setOf(3L)) + val vm = AllStoragesViewModel(FakeFavoritesStore(), store) + + vm.toggleCollapsed(3L) + + assertFalse(3L in vm.state.value.collapsedHouseholdIds) + assertFalse(3L in store.collapsed()) + } + + @Test + fun collapsing_one_household_does_not_collapse_another() { + // A naive "single collapsed id" or boolean implementation would make every + // group collapse together -- collapse must be keyed PER household. + val vm = AllStoragesViewModel(FakeFavoritesStore(), FakeHouseholdViewStore()) + + vm.toggleCollapsed(1L) + + assertTrue(1L in vm.state.value.collapsedHouseholdIds) + assertFalse(2L in vm.state.value.collapsedHouseholdIds) + } + + @Test + fun collapsing_a_household_does_not_touch_favorites_or_other_households() { + // Collapsing is a pure presentation toggle: it must never mutate anything + // that could hide or lose a selection/favorite the user made elsewhere. + // Asserts the favorite set is EXACTLY unchanged (not just "still contains + // the original entry") -- a mutation that also toggled the household id + // into favorites would slip past a weaker "still contains 7L" check. + val vm = + AllStoragesViewModel( + FakeFavoritesStore(initialLocations = setOf(7L)), + FakeHouseholdViewStore(initialCollapsed = setOf(2L)), + ) + + vm.toggleCollapsed(1L) + + assertEquals(setOf(7L), vm.state.value.favoriteLocationIds) + assertEquals(setOf(1L, 2L), vm.state.value.collapsedHouseholdIds) + } + + @Test + fun ordered_entries_falls_back_to_name_when_no_stored_order() { + val vm = AllStoragesViewModel(FakeFavoritesStore(), FakeHouseholdViewStore()) + val entries = + listOf( + HouseholdWithLocations(id = 2L, name = "Office", locations = emptyList()), + HouseholdWithLocations(id = 1L, name = "Home", locations = emptyList()), + ) + + val ordered = vm.orderedEntries(entries) + + assertEquals(listOf("Home", "Office"), ordered.map { it.name }) + } + + @Test + fun ordered_entries_honors_stored_order_over_name() { + val vm = + AllStoragesViewModel( + FakeFavoritesStore(), + FakeHouseholdViewStore(initialOrder = listOf(2L, 1L)), + ) + val entries = + listOf( + HouseholdWithLocations(id = 1L, name = "Home", locations = emptyList()), + HouseholdWithLocations(id = 2L, name = "Office", locations = emptyList()), + ) + + val ordered = vm.orderedEntries(entries) + + // Office (stored position 0) before Home (stored position 1) -- drag order + // wins over the alphabetical tie-break, exactly as HierarchyOrder mandates. + assertEquals(listOf("Office", "Home"), ordered.map { it.name }) + } } diff --git a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt index 3ae190c..bce8f78 100644 --- a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt @@ -13,6 +13,7 @@ import dev.scuttle.inventory.data.product.ProductEdit import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.settings.DefaultHouseholdStore import dev.scuttle.inventory.data.settings.FavoritesStore +import dev.scuttle.inventory.data.settings.HouseholdViewStore import dev.scuttle.inventory.data.settings.ShelfViewStore import dev.scuttle.inventory.data.shelf.ShelfRepository import org.junit.Assert.assertTrue @@ -202,6 +203,22 @@ class SessionCleanerTest { } } + private class RecordingHouseholdViewStore : HouseholdViewStore { + var cleared = false + + override fun collapsed() = emptySet() + + override fun toggleCollapsed(id: Long) = Unit + + override fun order() = emptyList() + + override fun setOrder(ids: List) = Unit + + override fun clear() { + cleared = true + } + } + @Test fun clear_fans_out_to_every_cache_and_store() { // X1: session end must wipe ALL per-account singleton state — miss one and @@ -213,9 +230,20 @@ class SessionCleanerTest { val favorites = RecordingFavoritesStore() val defaultHousehold = RecordingDefaultHouseholdStore() val shelfView = RecordingShelfViewStore() + val householdView = RecordingHouseholdViewStore() val store = HierarchyStore(household, location, shelf, product) - SessionCleaner(household, location, shelf, product, store, favorites, defaultHousehold, shelfView).clear() + SessionCleaner( + household, + location, + shelf, + product, + store, + favorites, + defaultHousehold, + shelfView, + householdView, + ).clear() assertTrue(household.cleared) assertTrue(location.cleared) @@ -224,5 +252,6 @@ class SessionCleanerTest { assertTrue(favorites.cleared) assertTrue(defaultHousehold.cleared) assertTrue(shelfView.cleared) + assertTrue(householdView.cleared) } } From fbfa07018b5b587776f86ec85f3a2645a8985485 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 04:02:48 +0200 Subject: [PATCH 24/40] test: flow tests for the delete-strategy path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the bug the whole change exists to fix: deleting a shelf or location that holds contents now stops and asks, instead of destroying them silently. - DeleteShelfStrategyFlowTest: a non-empty shelf forces an explicit strategy choice, Move is never offered with nowhere to go, the Unsorted shelf can't be selected or offered as a move target, Undo restores a non-destructively deleted shelf WITH its products, and one multi-shelf gesture shares a single deletion_batch_id across every request. - DeleteLocationFlowTest: adds the location-side half of the contentCount asymmetry — a location holding only empty shelves still requires a strategy, because the server counts shelves, not products. The original test already drove edit mode correctly (its swipe-gesture staleness was fixed in 378159d, before this task); left untouched. - RenameLocationFlowTest: fills a real gap — zero flow-level coverage previously existed for the edit-mode rename affordance. - All wire-body assertions read the actual recorded MockWebServer requests (method/path/body), not just UI state, since several guarantees are about what the client puts on the wire (e.g. omitting null strategy/target keys). --- .../assets/fixtures/location_renamed.json | 1 + .../locations_one_with_empty_shelf.json | 1 + .../assets/fixtures/restore_ok.json | 1 + .../fixtures/shelves_one_with_products.json | 1 + .../fixtures/shelves_two_with_products.json | 1 + .../fixtures/shelves_unsorted_only.json | 1 + .../fixtures/shelves_with_unsorted.json | 1 + .../inventory/flow/DeleteLocationFlowTest.kt | 79 +++++ .../flow/DeleteShelfStrategyFlowTest.kt | 330 ++++++++++++++++++ .../inventory/flow/RenameLocationFlowTest.kt | 105 ++++++ 10 files changed, 521 insertions(+) create mode 100644 app/src/androidTest/assets/fixtures/location_renamed.json create mode 100644 app/src/androidTest/assets/fixtures/locations_one_with_empty_shelf.json create mode 100644 app/src/androidTest/assets/fixtures/restore_ok.json create mode 100644 app/src/androidTest/assets/fixtures/shelves_one_with_products.json create mode 100644 app/src/androidTest/assets/fixtures/shelves_two_with_products.json create mode 100644 app/src/androidTest/assets/fixtures/shelves_unsorted_only.json create mode 100644 app/src/androidTest/assets/fixtures/shelves_with_unsorted.json create mode 100644 app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt create mode 100644 app/src/androidTest/java/dev/scuttle/inventory/flow/RenameLocationFlowTest.kt diff --git a/app/src/androidTest/assets/fixtures/location_renamed.json b/app/src/androidTest/assets/fixtures/location_renamed.json new file mode 100644 index 0000000..bf0886c --- /dev/null +++ b/app/src/androidTest/assets/fixtures/location_renamed.json @@ -0,0 +1 @@ +{"data":{"id":10,"name":"Walk-in Fridge","type":"fridge","shelf_count":0,"product_count":0}} diff --git a/app/src/androidTest/assets/fixtures/locations_one_with_empty_shelf.json b/app/src/androidTest/assets/fixtures/locations_one_with_empty_shelf.json new file mode 100644 index 0000000..ce986ae --- /dev/null +++ b/app/src/androidTest/assets/fixtures/locations_one_with_empty_shelf.json @@ -0,0 +1 @@ +{"data":[{"id":10,"name":"Fridge","type":"fridge","shelf_count":3,"product_count":0}]} diff --git a/app/src/androidTest/assets/fixtures/restore_ok.json b/app/src/androidTest/assets/fixtures/restore_ok.json new file mode 100644 index 0000000..a57ba23 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/restore_ok.json @@ -0,0 +1 @@ +{"message":"1 shelf restored.","restored":1} diff --git a/app/src/androidTest/assets/fixtures/shelves_one_with_products.json b/app/src/androidTest/assets/fixtures/shelves_one_with_products.json new file mode 100644 index 0000000..ad0c3a7 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/shelves_one_with_products.json @@ -0,0 +1 @@ +{"data":[{"id":100,"name":"Top shelf","position":0,"location_id":10,"product_count":1}]} diff --git a/app/src/androidTest/assets/fixtures/shelves_two_with_products.json b/app/src/androidTest/assets/fixtures/shelves_two_with_products.json new file mode 100644 index 0000000..0665ac2 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/shelves_two_with_products.json @@ -0,0 +1 @@ +{"data":[{"id":100,"name":"Top shelf","position":0,"location_id":10,"product_count":1},{"id":101,"name":"Middle shelf","position":1,"location_id":10,"product_count":1}]} diff --git a/app/src/androidTest/assets/fixtures/shelves_unsorted_only.json b/app/src/androidTest/assets/fixtures/shelves_unsorted_only.json new file mode 100644 index 0000000..378e199 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/shelves_unsorted_only.json @@ -0,0 +1 @@ +{"data":[{"id":900,"name":"Unsorted","position":0,"location_id":10,"is_system":true,"product_count":3}]} diff --git a/app/src/androidTest/assets/fixtures/shelves_with_unsorted.json b/app/src/androidTest/assets/fixtures/shelves_with_unsorted.json new file mode 100644 index 0000000..5257778 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/shelves_with_unsorted.json @@ -0,0 +1 @@ +{"data":[{"id":100,"name":"Top shelf","position":1,"location_id":10,"product_count":1},{"id":900,"name":"Unsorted","position":0,"location_id":10,"is_system":true,"product_count":3}]} diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt index 305aa1a..554127d 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt @@ -16,8 +16,10 @@ import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.waitUntilAtLeastOneExists import dagger.hilt.android.testing.HiltAndroidTest import dev.scuttle.inventory.FlowTestBase +import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy import dev.scuttle.inventory.ui.dashboard.DASHBOARD_TITLE_TEST_TAG import org.junit.Test +import java.util.concurrent.TimeUnit /** * The regression test for the bug that started Task 5: swiping a location off @@ -100,4 +102,81 @@ class DeleteLocationFlowTest : FlowTestBase() { onNodeWithText("No storage locations yet. Tap + to add a fridge, freezer or pantry.").assertIsDisplayed() } } + + /** + * The asymmetry LocationDto.kt's own doc comment warns about: what the + * server counts as "has contents" for a LOCATION is its SHELF count, not + * its product count. A location holding shelves that are themselves + * completely empty (product_count = 0) must still stop and ask — getting + * this wrong 422s every such delete. locations_one_with_empty_shelf.json + * carries shelf_count=3, product_count=0 to pin exactly that case, as + * opposed to the plain-confirm (shelf_count=0) case above. + */ + @Test + fun a_location_holding_only_empty_shelves_still_requires_a_strategy() { + mockServer.enqueue(fixture("auth_login.json")) + mockServer.route("/households", fixture("households_one.json")) + mockServer.route("/households/1/locations", fixture("locations_one_with_empty_shelf.json")) + + composeRule.apply { + onNodeWithText("Email").performTextInput("test@example.com") + onNodeWithText("Password").performTextInput("password123") + onAllNodesWithText("Sign in").filterToOne(hasClickAction()).performClick() + + Thread.sleep(3_000) + waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) + + onNodeWithTag("bottom-nav-home").performClick() + waitForIdle() + Thread.sleep(2_000) + waitForIdle() + + mockServer.route("/households/1/locations", fixture("locations_one_with_empty_shelf.json")) + waitUntilAtLeastOneExists(hasText("Home"), timeoutMillis = 5_000) + onNodeWithContentDescription("Add storage location").performClick() + Thread.sleep(2_000) + waitForIdle() + + waitUntilAtLeastOneExists(hasText("Fridge"), timeoutMillis = 5_000) + Thread.sleep(1_000) + waitForIdle() + + onNodeWithContentDescription("Edit storage").performClick() + waitForIdle() + onNodeWithText("Fridge").performClick() + waitForIdle() + + // requestDelete() refreshes the location list first. + mockServer.route("/households/1/locations", fixture("locations_one_with_empty_shelf.json")) + onNodeWithText("Delete (1)").performClick() + waitForIdle() + waitUntilAtLeastOneExists(hasText("Delete 1 item(s)?"), timeoutMillis = 3_000) + + // The strategy question renders despite ZERO products — it's the + // shelves, not the products, that force it. Fridge is the household's + // only location, so "move" is never offered (nowhere to move to). + onNodeWithText("0 product(s) are stored inside. What should happen to them?").assertIsDisplayed() + onNodeWithText("Move them somewhere else").assertDoesNotExist() + + onNodeWithText("Delete them too").performClick() + waitForIdle() + + mockServer.route("/households/1/locations/10", "", code = 204) + mockServer.route("/households/1/locations", fixture("locations_empty.json")) + onNodeWithTag("delete-strategy-confirm").performClick() + Thread.sleep(2_000) + waitForIdle() + + waitUntilAtLeastOneExists(hasText("No storage locations yet", substring = true), timeoutMillis = 5_000) + } + + val deleteRequest = + generateSequence { mockServer.server.takeRequest(1, TimeUnit.SECONDS) } + .firstOrNull { it.method == "DELETE" && it.path == "/households/1/locations/10" } + requireNotNull(deleteRequest) { "App never sent DELETE /households/1/locations/10" } + val body = deleteRequest.body.readUtf8() + val expectedStrategy = "\"strategy\":\"${LocationDeleteStrategy.DELETE_CONTENTS.wire}\"" + assert(body.contains(expectedStrategy)) { "strategy missing/wrong — body was: $body" } + assert(!body.contains("target_location_id")) { "target_location_id must be OMITTED — body was: $body" } + } } diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt new file mode 100644 index 0000000..8fc2c2a --- /dev/null +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt @@ -0,0 +1,330 @@ +@file:OptIn(androidx.compose.ui.test.ExperimentalTestApi::class) + +package dev.scuttle.inventory.flow + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.waitUntilAtLeastOneExists +import androidx.compose.ui.test.waitUntilDoesNotExist +import dagger.hilt.android.testing.HiltAndroidTest +import dev.scuttle.inventory.FlowTestBase +import dev.scuttle.inventory.data.hierarchy.ShelfDeleteStrategy +import dev.scuttle.inventory.ui.dashboard.DASHBOARD_TITLE_TEST_TAG +import okhttp3.mockwebserver.RecordedRequest +import org.junit.Test +import java.util.concurrent.TimeUnit + +/** + * The delete-strategy dialog for SHELVES, driven through the real screen — the + * companion to `DeleteLocationFlowTest` one level up. `DeleteShelfFlowTest` + * (Task 4) only ever exercises the EMPTY-shelf path (shelves_two.json's shelves + * both default `product_count` to 0, so `DeletePlan.needsStrategy` is false and + * the dialog renders as a plain confirm with no radios — see its own doc + * comment). These tests are the ones that actually drive a shelf with contents + * through the real UI, which is the case the strategy dialog exists for. + * + * All four tests share one household/location (1 / 10 "Fridge") and drain + * `mockServer.server`'s recorded requests afterwards to assert on the actual + * wire bytes — several of the guarantees below are about what the client sends, + * not just what renders. + */ +@HiltAndroidTest +class DeleteShelfStrategyFlowTest : FlowTestBase() { + @Test + fun deleting_a_shelf_with_products_requires_choosing_a_strategy_and_the_wire_body_omits_null_keys() { + mockServer.enqueue(fixture("auth_login.json")) + mockServer.route("/households", fixture("households_one.json")) + mockServer.route("/households/1/locations", fixture("locations_one.json")) + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one_with_products.json")) + mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + + composeRule.apply { + onNodeWithText("Email").performTextInput("test@example.com") + onNodeWithText("Password").performTextInput("password123") + onAllNodesWithText("Sign in").filterToOne(hasClickAction()).performClick() + + Thread.sleep(3_000) + waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) + + // Storage tab → Fridge → LocationDetailScreen + onNodeWithTag("bottom-nav-home").performClick() + waitUntilAtLeastOneExists(hasTestTag("home-location-Fridge"), timeoutMillis = 8_000) + + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one_with_products.json")) + mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + onNodeWithTag("home-location-Fridge").performClick() + waitForIdle() + + waitUntilAtLeastOneExists(hasText("Top shelf"), timeoutMillis = 5_000) + + // Edit mode, select "Top shelf" (the only shelf — no other shelf exists + // to move products to, so this also proves "move" is never offered when + // there is nowhere to move to — guard (a) in DeleteStrategyDialog). + onNodeWithContentDescription("Edit shelves").performClick() + waitForIdle() + onNodeWithText("Top shelf").performClick() + waitForIdle() + + // Delete (1) only OPENS the dialog. requestDelete() refreshes the shelf + // list first, off a fresh product_count, before building the plan. + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one_with_products.json")) + onNodeWithText("Delete (1)").performClick() + waitForIdle() + waitUntilAtLeastOneExists(hasText("Delete 1 item(s)?"), timeoutMillis = 3_000) + + // A non-empty shelf renders the strategy question — not a plain confirm. + onNodeWithText("1 product(s) are stored inside. What should happen to them?").assertIsDisplayed() + // No other shelf exists, so "move" must not be offered as a dead option. + onNodeWithText("Move them somewhere else").assertDoesNotExist() + onNodeWithText("Keep them here, in Unsorted").assertIsDisplayed() + + // Explicitly choose "Delete them too" — the destructive option is never + // applied silently; the user has to tap it (it isn't the safest default, + // which is "Keep them here, in Unsorted"). + onNodeWithText("Delete them too").performClick() + waitForIdle() + + mockServer.route("/households/1/locations/10/shelves/100", "", code = 204) + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_empty.json")) + onNodeWithTag("delete-strategy-confirm").performClick() + Thread.sleep(2_000) + waitForIdle() + + waitUntilDoesNotExist(hasText("Top shelf"), timeoutMillis = 5_000) + // The Undo snackbar is the completion of the confirmation gate: a + // destructive delete is never the last word without a way back. + waitUntilAtLeastOneExists(hasText("Shelves deleted."), timeoutMillis = 5_000) + onNodeWithText("Undo").assertIsDisplayed() + } + + val deleteRequest = drainDeleteRequest(path = "/households/1/locations/10/shelves/100") + val body = deleteRequest.body.readUtf8() + val expectedStrategy = "\"strategy\":\"${ShelfDeleteStrategy.DELETE_PRODUCTS.wire}\"" + assert(body.contains(expectedStrategy)) { "strategy missing/wrong — body was: $body" } + assert(!body.contains("target_shelf_id")) { "target_shelf_id must be OMITTED, not null — body was: $body" } + assert(Regex(""""deletion_batch_id":"[^"]+"""").containsMatchIn(body)) { + "deletion_batch_id missing — body was: $body" + } + } + + @Test + fun the_unsorted_shelf_cannot_be_selected_and_is_never_offered_as_a_move_target() { + mockServer.enqueue(fixture("auth_login.json")) + mockServer.route("/households", fixture("households_one.json")) + mockServer.route("/households/1/locations", fixture("locations_one.json")) + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_with_unsorted.json")) + mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + mockServer.route("/households/1/shelves/900/products", fixture("products_empty.json")) + + composeRule.apply { + onNodeWithText("Email").performTextInput("test@example.com") + onNodeWithText("Password").performTextInput("password123") + onAllNodesWithText("Sign in").filterToOne(hasClickAction()).performClick() + + Thread.sleep(3_000) + waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) + + onNodeWithTag("bottom-nav-home").performClick() + waitUntilAtLeastOneExists(hasTestTag("home-location-Fridge"), timeoutMillis = 8_000) + + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_with_unsorted.json")) + mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + mockServer.route("/households/1/shelves/900/products", fixture("products_empty.json")) + onNodeWithTag("home-location-Fridge").performClick() + waitForIdle() + + waitUntilAtLeastOneExists(hasText("Top shelf"), timeoutMillis = 5_000) + + onNodeWithContentDescription("Edit shelves").performClick() + waitForIdle() + + // Tapping the system shelf must be a no-op selection-wise: the Delete + // button stays the plain, unselected "Delete" (no count), and disabled. + onNodeWithText("Unsorted").performClick() + waitForIdle() + onNodeWithText("Delete").assertIsDisplayed() + + // Now select the real shelf and open the dialog. + onNodeWithText("Top shelf").performClick() + waitForIdle() + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_with_unsorted.json")) + onNodeWithText("Delete (1)").performClick() + waitForIdle() + waitUntilAtLeastOneExists(hasText("Delete 1 item(s)?"), timeoutMillis = 3_000) + + // "Unsorted" is a live shelf that isn't selected — if it were eligible as + // a move target, "move" would be offered. It must not be: Unsorted is + // never a destination for another shelf's contents. + onNodeWithText("Move them somewhere else").assertDoesNotExist() + onNodeWithText("Keep them here, in Unsorted").assertIsDisplayed() + onNodeWithText("Delete them too").assertIsDisplayed() + + // confirmDelete() re-lists the shelves on success, off the fresh + // post-delete state — Top shelf gone, Unsorted still there. + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_unsorted_only.json")) + onNodeWithTag("delete-strategy-confirm").performClick() + Thread.sleep(2_000) + waitForIdle() + + waitUntilDoesNotExist(hasText("Top shelf"), timeoutMillis = 5_000) + // Unsorted itself survives — it was never a candidate for deletion. + onNodeWithText("Unsorted").assertExists() + } + } + + @Test + fun undo_restores_a_shelf_deleted_with_the_keep_products_strategy_along_with_its_products() { + mockServer.enqueue(fixture("auth_login.json")) + mockServer.route("/households", fixture("households_one.json")) + mockServer.route("/households/1/locations", fixture("locations_one.json")) + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one_with_products.json")) + mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + + composeRule.apply { + onNodeWithText("Email").performTextInput("test@example.com") + onNodeWithText("Password").performTextInput("password123") + onAllNodesWithText("Sign in").filterToOne(hasClickAction()).performClick() + + Thread.sleep(3_000) + waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) + + onNodeWithTag("bottom-nav-home").performClick() + waitUntilAtLeastOneExists(hasTestTag("home-location-Fridge"), timeoutMillis = 8_000) + + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one_with_products.json")) + mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + onNodeWithTag("home-location-Fridge").performClick() + waitForIdle() + + waitUntilAtLeastOneExists(hasText("Top shelf"), timeoutMillis = 5_000) + // The product is visible before the delete — the baseline "with its + // products" this test proves Undo returns to. + waitUntilAtLeastOneExists(hasText("Milk"), timeoutMillis = 5_000) + + onNodeWithContentDescription("Edit shelves").performClick() + waitForIdle() + onNodeWithText("Top shelf").performClick() + waitForIdle() + + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one_with_products.json")) + onNodeWithText("Delete (1)").performClick() + waitForIdle() + waitUntilAtLeastOneExists(hasText("Delete 1 item(s)?"), timeoutMillis = 3_000) + + // The non-destructive strategy: the products are NOT deleted, only the + // shelf. This is the strategy Undo must restore products for — a + // destructive delete_products undo can't come back "with its products" + // because the server destroyed them too. + onNodeWithText("Keep them here, in Unsorted").performClick() + waitForIdle() + + mockServer.route("/households/1/locations/10/shelves/100", "", code = 204) + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_empty.json")) + onNodeWithTag("delete-strategy-confirm").performClick() + Thread.sleep(2_000) + waitForIdle() + + waitUntilDoesNotExist(hasText("Top shelf"), timeoutMillis = 5_000) + waitUntilAtLeastOneExists(hasText("Undo"), timeoutMillis = 5_000) + + // Tap the snackbar's Undo action. restore() hits POST + // /households/1/restore/{batch} — the route is registered by PREFIX so + // it matches regardless of the client-minted batch id's actual value. + mockServer.route("/households/1/restore/", fixture("restore_ok.json")) + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one_with_products.json")) + mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + onNodeWithText("Undo").performClick() + Thread.sleep(2_000) + waitForIdle() + + // The shelf is back, and — the actual guarantee — so is its product. + waitUntilAtLeastOneExists(hasText("Top shelf"), timeoutMillis = 5_000) + waitUntilAtLeastOneExists(hasText("Milk"), timeoutMillis = 5_000) + } + } + + @Test + fun deleting_two_shelves_in_one_gesture_shares_one_batch_id_across_both_requests() { + mockServer.enqueue(fixture("auth_login.json")) + mockServer.route("/households", fixture("households_one.json")) + mockServer.route("/households/1/locations", fixture("locations_one.json")) + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_two_with_products.json")) + mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + mockServer.route("/households/1/shelves/101/products", fixture("products_empty.json")) + + composeRule.apply { + onNodeWithText("Email").performTextInput("test@example.com") + onNodeWithText("Password").performTextInput("password123") + onAllNodesWithText("Sign in").filterToOne(hasClickAction()).performClick() + + Thread.sleep(3_000) + waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) + + onNodeWithTag("bottom-nav-home").performClick() + waitUntilAtLeastOneExists(hasTestTag("home-location-Fridge"), timeoutMillis = 8_000) + + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_two_with_products.json")) + mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + mockServer.route("/households/1/shelves/101/products", fixture("products_empty.json")) + onNodeWithTag("home-location-Fridge").performClick() + waitForIdle() + + waitUntilAtLeastOneExists(hasText("Top shelf"), timeoutMillis = 5_000) + + onNodeWithContentDescription("Edit shelves").performClick() + waitForIdle() + + // Select BOTH shelves — one gesture, two items. + onNodeWithText("Top shelf").performClick() + waitForIdle() + onNodeWithText("Middle shelf").performClick() + waitForIdle() + + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_two_with_products.json")) + onNodeWithText("Delete (2)").performClick() + waitForIdle() + waitUntilAtLeastOneExists(hasText("Delete 2 item(s)?"), timeoutMillis = 3_000) + + onNodeWithText("Delete them too").performClick() + waitForIdle() + + mockServer.route("/households/1/locations/10/shelves/100", "", code = 204) + mockServer.route("/households/1/locations/10/shelves/101", "", code = 204) + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_empty.json")) + onNodeWithTag("delete-strategy-confirm").performClick() + Thread.sleep(2_000) + waitForIdle() + + waitUntilDoesNotExist(hasText("Top shelf"), timeoutMillis = 5_000) + waitUntilDoesNotExist(hasText("Middle shelf"), timeoutMillis = 5_000) + } + + val first = drainDeleteRequest(path = "/households/1/locations/10/shelves/100") + val second = drainDeleteRequest(path = "/households/1/locations/10/shelves/101") + val firstBatch = first.body.readUtf8().batchId() + val secondBatch = second.body.readUtf8().batchId() + requireNotNull(firstBatch) { "shelf 100's delete carried no deletion_batch_id" } + assert(firstBatch == secondBatch) { + "the two deletes in one gesture must share ONE batch id — got $firstBatch and $secondBatch" + } + } + + /** Drains the recorded request queue for the DELETE that hit [path] exactly. */ + private fun drainDeleteRequest(path: String): RecordedRequest { + val request = + generateSequence { mockServer.server.takeRequest(1, TimeUnit.SECONDS) } + .firstOrNull { it.method == "DELETE" && it.path == path } + return requireNotNull(request) { "no DELETE was ever recorded for $path" } + } + + private fun String.batchId(): String? = Regex(""""deletion_batch_id":"([^"]+)"""").find(this)?.groupValues?.get(1) +} diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/RenameLocationFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/RenameLocationFlowTest.kt new file mode 100644 index 0000000..9bd3a6f --- /dev/null +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/RenameLocationFlowTest.kt @@ -0,0 +1,105 @@ +@file:OptIn(androidx.compose.ui.test.ExperimentalTestApi::class) + +package dev.scuttle.inventory.flow + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.waitUntilAtLeastOneExists +import dagger.hilt.android.testing.HiltAndroidTest +import dev.scuttle.inventory.FlowTestBase +import dev.scuttle.inventory.ui.dashboard.DASHBOARD_TITLE_TEST_TAG +import org.junit.Test + +/** + * Edit mode's rename affordance (Task 5) had zero flow-level coverage before + * this: the per-row pencil on StorageOverviewScreen opens a bottom sheet + * (title/content-description both "Edit storage location" — + * R.string.storage_edit_title, passed as EditableRow's renameLabelRes) that + * PATCHes the location. This isn't one of the delete-safety guarantees this + * task's other tests pin — it's the companion regression net for the other + * edit-mode action on the same screen, over the same real UI and wire. + */ +@HiltAndroidTest +class RenameLocationFlowTest : FlowTestBase() { + @Test + fun renaming_a_location_updates_the_list_and_sends_the_new_name_on_the_wire() { + mockServer.enqueue(fixture("auth_login.json")) + mockServer.route("/households", fixture("households_one.json")) + mockServer.route("/households/1/locations", fixture("locations_one.json")) + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one.json")) + mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + + composeRule.apply { + onNodeWithText("Email").performTextInput("test@example.com") + onNodeWithText("Password").performTextInput("password123") + onAllNodesWithText("Sign in").filterToOne(hasClickAction()).performClick() + + Thread.sleep(3_000) + waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) + + // Storage tab → AllStoragesScreen + onNodeWithTag("bottom-nav-home").performClick() + waitForIdle() + Thread.sleep(2_000) + waitForIdle() + + // "Add storage location" (the + icon next to "Home") → StorageOverviewScreen + mockServer.route("/households/1/locations", fixture("locations_one.json")) + waitUntilAtLeastOneExists(hasText("Home"), timeoutMillis = 5_000) + onNodeWithContentDescription("Add storage location").performClick() + Thread.sleep(2_000) + waitForIdle() + + waitUntilAtLeastOneExists(hasText("Fridge"), timeoutMillis = 5_000) + + // Edit mode → per-row rename pencil (content description "Edit storage + // location" — distinct from the top bar's "Edit storage" that enters + // edit mode in the first place). + onNodeWithContentDescription("Edit storage").performClick() + waitForIdle() + onNodeWithContentDescription("Edit storage location").performClick() + waitForIdle() + + waitUntilAtLeastOneExists(hasText("Edit storage location"), timeoutMillis = 3_000) + + // "Fridge" is ambiguous on this sheet — it's both the prefilled name + // field's value AND the "Fridge" type FilterChip's label. The name + // field is the sheet's only set-text-actionable node, so select by + // that action rather than by text (which the clear below would + // invalidate on a second, matcher-re-evaluated lookup). Mirrors + // EditProductDetailFlowTest's own re-query-per-action idiom. + waitUntilAtLeastOneExists(hasSetTextAction(), timeoutMillis = 3_000) + onAllNodes(hasSetTextAction())[0].performTextClearance() + onAllNodes(hasSetTextAction())[0].performTextInput("Walk-in Fridge") + + mockServer.route("/households/1/locations/10", fixture("location_renamed.json")) + onNodeWithText("Save").performClick() + waitForIdle() + Thread.sleep(1_000) + waitForIdle() + + waitUntilAtLeastOneExists(hasText("Walk-in Fridge"), timeoutMillis = 5_000) + onNodeWithText("Walk-in Fridge").assertIsDisplayed() + onNodeWithText("Fridge").assertDoesNotExist() + } + + val patchRequest = + generateSequence { mockServer.server.takeRequest(1, java.util.concurrent.TimeUnit.SECONDS) } + .firstOrNull { it.path == "/households/1/locations/10" } + requireNotNull(patchRequest) { "App never called PATCH /households/1/locations/10" } + assert(patchRequest.method == "PATCH") { "expected PATCH, got ${patchRequest.method}" } + val body = patchRequest.body.readUtf8() + assert(body.contains("\"name\":\"Walk-in Fridge\"")) { "new name missing from body: $body" } + } +} From 3442fa054340b1d6f7a461df7ba05038ea0c77b2 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 04:13:50 +0200 Subject: [PATCH 25/40] docs: rewrite CLAUDE.md for the new nav, the roles decision, and delete invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The navigation section still described the old 5-tab bar (Households on the bar, Settings behind a gear on every screen) and the scope guardrails still banned roles/permissions — both now contradicted by shipped behaviour and by the 2026-07-13 roles decision. Also documents, as LOCKED load-bearing rules, the delete-confirmation/strategy/batch-undo/null-omission invariants this branch introduced (each was a real Critical/Important bug here), plus two testing lessons (a lying fake, and asserting descriptor shape instead of wire bytes) and the detekt/androidTest coverage gap Task 9 found. Every claim was verified against the current code on this branch, not carried over from the plan — see .superpowers/sdd/task-10-report.md for the file:line trail. No production code changed; ./gradlew ktlintCheck is unaffected. --- CLAUDE.md | 142 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 125 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2e3ffee..f9e7415 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,54 @@ multi-user, multi-household inventory. General-purpose; freezer/fridge/pantry ar - **Tenancy via URL.** Every household-scoped call goes through `/households/{household}/...`; the active household is explicit, never implicit. +## Deletes — LOCKED, do not relitigate +Rewritten this branch after a tester hit an unconfirmed, unstrategized, silent bulk +delete. Every point below was a real Critical/Important bug on this branch — the *why* +is the cost of "simplifying" it back out. + +- **Every destructive delete goes through a confirmation dialog.** No silent delete + exists anywhere: shelves/locations go through edit-mode selection → + `DeleteStrategyDialog`; products reveal a delete button on swipe, and the button opens + an `AlertDialog`; leaving a household is behind its own confirm. Don't add a bare + swipe-to-delete or a one-tap destructive action anywhere — that's the bug this branch + exists to close. +- **A non-empty container needs an EXPLICIT strategy — the server never guesses (422s + otherwise).** Shelves: move / unsort / delete the products. Locations: move / delete + the contents — **no unsort at the location level**, on purpose: + `LocationDeleteStrategy` has no unsort variant because "unsorted" means off-shelf but + still IN the location, and the location itself is what's being deleted. +- **`contentCount` asymmetry:** a SHELF asks when it has PRODUCTS; a LOCATION asks when + it has SHELVES — **even empty ones** (`ShelfDto.product_count` / + `LocationDto.shelf_count`, `ui/hierarchy/DeletePlan.kt`). Collapsing this to "asks + when it holds products" 422s every delete of a location holding one empty shelf — a + real bug caught in Task-1 review before it shipped. +- **The CLIENT mints `deletion_batch_id`** (`UUID.randomUUID()`, once per user gesture, + reused across every request that gesture fires — see `ShelvesViewModel`, + `StorageOverviewViewModel`, `DrawerViewModel`). That one shared id per gesture is what + makes Undo restore the *whole* gesture instead of one item out of N. +- **Request DTOs: an unset key must be OMITTED, not encoded as `null`.** This app's + `Json` runs on kotlinx's real defaults — `explicitNulls = true`, `encodeDefaults = + false` — so a property with NO Kotlin default is *always* encoded, even when null; + giving it `= null` makes an unset value OMITTED instead. `DeleteShelfRequest` / + `DeleteLocationRequest` rely on this: `strategy`/`target_*` default to null so an + empty-container delete's body never carries `"strategy":null`, which the server 422s + as a type error (this was a Critical: every delete but "move" failed). + `UpdateHouseholdRequest` is the deliberate ASYMMETRIC exception: `name` HAS a default + (omitted = "don't touch the name" — Laravel's `sometimes|required` 422s an *explicit* + null), while `color`/`icon` have NO default (an explicit null IS encoded, and clears + the theme back to its derived default — Laravel's `sometimes|nullable`). Do not + "tidy" this into one shape and do not flip `explicitNulls` globally — both directions + are pinned at the byte level (`DeleteRequestSerializationTest`), because a + descriptor-shape assertion alone ("this property has no default") already let this + exact bug ship once. See "Testing lessons" below. +- **Ordering: drag order always wins. A star/favourite is a MARKER, never a sort key.** + `ui/common/HierarchyOrder.kt`'s `orderByPosition(items, position, name)` deliberately + takes no favourites parameter, so "the starred item floats to the top" is + structurally unwritable, not just untested. +- **The Unsorted shelf is a system shelf (`is_system`)**: never renamable, never + selectable for deletion, never a move target, always sorts last. Any new shelf-list + UI must gate on `is_system` the way `EditableRow` / `ShelvesViewModel` already do. + ## Auth flows - Email/password → `POST /auth/register` or `/auth/login` → store token. - Google → native Google Sign-In → obtain Google **ID token** → `POST /auth/google { id_token }` @@ -42,15 +90,38 @@ multi-user, multi-household inventory. General-purpose; freezer/fridge/pantry ar ## Screens (D-020/D-022/D-023) 1. **Auth** — email/password + Google sign-in. 2. **Storage overview** — household's locations (type + shelf/item counts). -3. **Shelves** — `ScrollableTabRow` (tab strip) + `HorizontalPager` (swipe); add targets active shelf. +3. **Shelves** — `ScrollableTabRow` (tab strip) + `HorizontalPager` (swipe), or a plain + list — user-toggled and persisted (`ShelfViewStore`); add targets active shelf. 4. **Products** — name + quantity; add/remove quantity; relocate (move). 5. **Search** — global product search; results show location › shelf + quantity. 6. **Invite** — join code + copyable link + QR (QR rendered from the link). -7. **Settings** — theme (System/Light/Dark), household management, account / sign out. -Navigation: Household → Storage overview → Shelves (tabs) → Products. A 5-tab bottom -bar (Dashboard / Storage / Households / Missing / Search) is the app's only navigation -surface — there is no drawer. Settings is reached via a gear icon in each top-level -screen's app bar. +7. **More** (route `settings`) — theme (System/Light/Dark), language, join-by-code, + household management (→ Households screen), account / sign out. Reached from the + bottom bar, not a gear icon. + +Navigation: Household → Storage overview → Shelves (tabs *or* list) → Products. A 5-tab +bottom bar (**Dashboard / Storage / Scan / Missing / More**) is the app's only +navigation surface — there is no drawer. **Households** left the bar: it's a +tenant-management screen reached under **More**, which absorbed Settings *and* +households. **Search** lost its tab; it's reached via a top-bar icon on every +household-scoped screen (Dashboard, Storage overview, Home, Missing items). Households +and Search each keep their own small gear-icon shortcut back to Settings, since neither +sits on the bottom bar itself — that's the only place a gear icon survives, not "every +screen" as before this branch. The centre **Scan** tab always opens the scanner in +LOOKUP mode (hands the scanned code to Search, pre-filled and already run); opening the +scanner from a shelves screen is ADD mode instead — same route, a `ScannerMode` argument +picks the behavior and delivers the code back to that screen via `savedStateHandle` +rather than to Search (`MainActivity.kt`: `ScannerMode`, `scanDeliveryActionFor`). + +**Editing the hierarchy** lives behind a pencil (edit mode) on the households, locations +and shelves lists — never inside Settings/More itself. On locations and shelves, edit +mode turns each row into a checkbox (multi-select → delete-strategy dialog) plus a +rename pencil and up/down reorder buttons; the row body itself only toggles selection. +Households have no multi-select or reorder: edit mode instead makes the row tappable, +opening a full household edit page (name, colour/icon theme, and a danger zone that +today only offers "Leave" — household *delete* has no owner yet, see Scope guardrails). +Deleting a non-empty shelf or location always asks what to do with the contents — see +"Deletes" above. ## Design — B · Frost (D-021) - Frosted-glass cards, icy-blue accent **#7dd3fc**, rounded controls, **Plus Jakarta Sans**. @@ -60,10 +131,17 @@ screen's app bar. Compose theme to match these. ## Scope guardrails — refuse to add -No expiry/reminders, recipes, shopping list, offline mode, roles/permissions. +No expiry/reminders, recipes, shopping list, offline mode. **Phase 2 unlocked 2026-07-10** (user decision) and since shipped: barcode scanning, the low-stock "running low" view, filter/sort, household color/icon theming, and the live-updates client — see `ROADMAP.md` / `BACKLOG.md`. +**Roles/permissions: coming (2026-07-13 decision, overrides the earlier refuse-to-add).** +Owner/Admin/Member is now in scope (Spec 2). The seam already exists server-side: every +mutating storage-structure route authorizes against `HouseholdPolicy@restructure`, +which today grants any member (all members stay equal in practice) — roles change that +one method body, not its call sites. Nothing client-side reads the seam yet: every user +sees every edit-mode pencil, because `restructure` always returns true today. Keep this +repo's guardrail in sync with `inventory-laravel/CLAUDE.md`'s matching bullet. ## Conventions - Explicit over magic; SRP; document the *why*. @@ -71,6 +149,30 @@ live-updates client — see `ROADMAP.md` / `BACKLOG.md`. - Tests cover critical paths: auth/token handling, household scoping, stock actions, error/empty/offline states. No trivial UI tests. +## Testing lessons — this branch shipped Criticals through a fully green suite +- **A fake that lies about the server is worse than no test.** `FakeShelfRepository. + reorder()` used to model a response the real reorder endpoint never returns — its own + comment asserted the opposite of the truth. That alone hid a guaranteed `LazyColumn` + duplicate-key crash (reorder duplicating the Unsorted shelf) behind green tests. It's + fixed now, and documents the real contract explicitly: `ShelfController::reorder` ends + `return $this->index(...)` — the FULL list, system shelf included. Before trusting a + fake, check it against the real controller in + `inventory-laravel/src/Http/Controllers/Api/`. +- **Assert the bytes, not the shape.** `DeleteRequestSerializationTest` used to assert + only "this DTO property has no default" via the serializer descriptor — that PINNED a + broken wire format (every delete but "move" 422ing) as passing. Fixed by also encoding + a real instance per production call site and asserting the literal JSON string. A + descriptor-shape test can pass while the wire format is wrong; only the encoded bytes + prove it. +- **Instrumented flow tests rot silently.** They keep compiling after the UI they drive + is removed or reworked, so a green local build proves nothing about them — they only + fail on the nightly emulator CI gate. Re-read a flow test's actual gestures (not just + its name) whenever the screen it drives changes. +- **`./gradlew detekt` does not currently scan `app/src/androidTest`**, though + `ktlintCheck` does (`ktlintAndroidTestSourceSetCheck` genuinely runs; + `detekt`'s own file-count metrics exclude every androidTest file). Don't treat a + green `detekt` as having checked flow-test code. + ## Measuring type or layout on a device — read the device's settings first Android rewrites text before your measurement ever sees it. **Check these before trusting any on-device font/layout number, and state their values alongside the result:** @@ -102,13 +204,19 @@ the device isn't on, render the composable in isolation and override `LocalDensi (see `ProductFilterSortRowTest`). ## Status -Functionally-complete (MVP + Phase 2), CI-green, running against the **production -backend at `https://inventory.scuttle.dev/api/v1`**. The single-activity Compose + -Hilt + Retrofit app covers auth (email/password + Google, device-smoke-tested), -storage overview, shelves (tabs/pager), products (add/remove/move, detail, image), -search, invite (QR/join), settings, dashboard, missing-items, barcode scanning, -low-stock, and live updates — with EN + NL localization and the Frost theme. Covered by -JVM unit tests + instrumented flow tests (the latter run nightly on an emulator in CI). -Distribution is **debug builds only** (tag-driven GitHub prerelease APKs) — no store -presence yet. Forward-looking work lives in [`ROADMAP.md`](ROADMAP.md); shipped history -in [`BACKLOG.md`](BACKLOG.md). +Functionally-complete (MVP + Phase 2 + storage-architecture editing), CI-green, running +against the **production backend at `https://inventory.scuttle.dev/api/v1`**. The +single-activity Compose + Hilt + Retrofit app covers auth (email/password + Google, +device-smoke-tested), storage overview, shelves (tabs/list toggle), products +(add/remove/move, detail, image), search, invite (QR/join), More/Settings (theme, +language, join-by-code, households, account/sign out), dashboard, missing-items, +barcode scanning (LOOKUP from the Scan tab, ADD from a shelf screen), low-stock, and +live updates — with EN + NL localization and the Frost theme. The full hierarchy +(households, locations, shelves) is now editable in place behind a pencil: rename, +up/down reorder, and delete-through-a-mandatory-strategy-dialog with a +client-minted `deletion_batch_id` per gesture and snackbar Undo — see "Deletes" above. +Covered by JVM unit tests + instrumented flow tests (the latter run nightly on an +emulator in CI; `ktlintCheck` covers `androidTest`, `detekt` currently does not — see +"Testing lessons"). Distribution is **debug builds only** (tag-driven GitHub prerelease +APKs) — no store presence yet. Forward-looking work lives in +[`ROADMAP.md`](ROADMAP.md); shipped history in [`BACKLOG.md`](BACKLOG.md). From bf02c9b9575acfa96f623eaf289d338158eb87ee Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 04:38:18 +0200 Subject: [PATCH 26/40] fix: delete the dead bodyless shelf/location delete() methods (Blocker 3) ShelfRepository.delete() and LocationRepository.delete() (plus their ShelfApi/LocationApi backing endpoints) had zero production callers and sent a bodyless DELETE that the server unconditionally 422s now that deletion_batch_id is required. Left in place they were a landmine: the next caller to wire one up would reproduce the Task-5b bug. Removed the interface declarations, implementations, backing Retrofit endpoints, and every fake override across the unit-test suite (deleteWithStrategy is the only delete path either repository exposes now). --- .../scuttle/inventory/data/api/LocationApi.kt | 7 ---- .../scuttle/inventory/data/api/ShelfApi.kt | 8 ----- .../data/location/LocationRepository.kt | 14 -------- .../data/location/LocationRepositoryImpl.kt | 8 ----- .../inventory/data/shelf/ShelfRepository.kt | 6 ---- .../data/shelf/ShelfRepositoryImpl.kt | 10 ------ .../inventory/DashboardViewModelTest.kt | 11 ------ .../scuttle/inventory/DrawerViewModelTest.kt | 35 ++++--------------- .../scuttle/inventory/HierarchyStoreTest.kt | 11 ------ .../inventory/MissingItemsViewModelTest.kt | 11 ------ .../inventory/ProductsViewModelTest.kt | 11 ------ .../scuttle/inventory/SessionCleanerTest.kt | 11 ------ .../scuttle/inventory/ShelvesViewModelTest.kt | 8 ----- .../inventory/StorageOverviewViewModelTest.kt | 23 ------------ .../dev/scuttle/inventory/TestHierarchy.kt | 11 ------ 15 files changed, 7 insertions(+), 178 deletions(-) diff --git a/app/src/main/java/dev/scuttle/inventory/data/api/LocationApi.kt b/app/src/main/java/dev/scuttle/inventory/data/api/LocationApi.kt index 3fc0b31..56c12fb 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/api/LocationApi.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/api/LocationApi.kt @@ -7,7 +7,6 @@ import dev.scuttle.inventory.data.dto.LocationResponse import dev.scuttle.inventory.data.dto.ReorderRequest import dev.scuttle.inventory.data.dto.UpdateLocationRequest import retrofit2.http.Body -import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.HTTP import retrofit2.http.PATCH @@ -39,12 +38,6 @@ interface LocationApi { @Body body: ReorderRequest, ): LocationListResponse - @DELETE("households/{household}/locations/{location}") - suspend fun delete( - @Path("household") householdId: Long, - @Path("location") locationId: Long, - ) - // @DELETE cannot carry a body; @HTTP(hasBody = true) can. The strategy and // the batch id have to travel with the request. @HTTP(method = "DELETE", path = "households/{household}/locations/{location}", hasBody = true) diff --git a/app/src/main/java/dev/scuttle/inventory/data/api/ShelfApi.kt b/app/src/main/java/dev/scuttle/inventory/data/api/ShelfApi.kt index d71a04a..5945931 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/api/ShelfApi.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/api/ShelfApi.kt @@ -7,7 +7,6 @@ import dev.scuttle.inventory.data.dto.ShelfListResponse import dev.scuttle.inventory.data.dto.ShelfResponse import dev.scuttle.inventory.data.dto.UpdateShelfRequest import retrofit2.http.Body -import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.HTTP import retrofit2.http.PATCH @@ -43,13 +42,6 @@ interface ShelfApi { @Body body: ReorderRequest, ): ShelfListResponse - @DELETE("households/{household}/locations/{location}/shelves/{shelf}") - suspend fun delete( - @Path("household") householdId: Long, - @Path("location") locationId: Long, - @Path("shelf") shelfId: Long, - ) - // @DELETE cannot carry a body; @HTTP(hasBody = true) can. The strategy and // the batch id have to travel with the request. @HTTP(method = "DELETE", path = "households/{household}/locations/{location}/shelves/{shelf}", hasBody = true) diff --git a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt index ca8b9be..3022b85 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepository.kt @@ -14,20 +14,6 @@ interface LocationRepository { type: String, ): LocationDto - /** - * Bodyless delete — sends no `deletion_batch_id`. The server's - * DeleteLocationRequest requires it unconditionally, so calling this - * directly from a screen 422s every time. Every UI entry point (Storage - * overview's edit mode, Home's swipe-replacement edit mode) must go - * through [deleteWithStrategy] instead — see its own doc comment. Kept on - * the interface only for fakes/tests that exercise failure paths without - * needing the strategy machinery; do not wire a Composable to it. - */ - suspend fun delete( - householdId: Long, - locationId: Long, - ) - /** * Defaults throw so test fakes only implement what a test actually exercises * (same pattern as [clear] and HouseholdRepository.updateTheme). Without diff --git a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt index 6bbc31d..b154eea 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/location/LocationRepositoryImpl.kt @@ -30,14 +30,6 @@ class LocationRepositoryImpl cache[householdId] = (cache[householdId] ?: emptyList()) + created } - override suspend fun delete( - householdId: Long, - locationId: Long, - ) { - api.delete(householdId, locationId) - cache[householdId] = cache[householdId]?.filter { it.id != locationId } ?: emptyList() - } - override suspend fun rename( householdId: Long, locationId: Long, diff --git a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt index c22a07c..eace19d 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt @@ -20,12 +20,6 @@ interface ShelfRepository { name: String, ): ShelfDto - suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) - /** * Defaults throw so test fakes only implement what a test actually exercises * (same pattern as [clear] and HouseholdRepository.updateTheme). Without diff --git a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt index 2c814cc..f34a518 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepositoryImpl.kt @@ -36,16 +36,6 @@ class ShelfRepositoryImpl cache[key] = (cache[key] ?: emptyList()) + created } - override suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) { - api.delete(householdId, locationId, shelfId) - val key = householdId to locationId - cache[key] = cache[key]?.filter { it.id != shelfId } ?: emptyList() - } - override suspend fun rename( householdId: Long, locationId: Long, diff --git a/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt index bf6fe36..8666ec1 100644 --- a/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt @@ -50,11 +50,6 @@ class DashboardViewModelTest { name: String, type: String, ) = LocationDto(99, name, type) - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} } private class FakeShelfRepository( @@ -75,12 +70,6 @@ class DashboardViewModelTest { locationId: Long, name: String, ) = ShelfDto(99, name, 0, locationId) - - override suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) {} } private class FakeProductRepository( diff --git a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt index 037b190..2e763e3 100644 --- a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt @@ -83,24 +83,6 @@ class DrawerViewModelTest { type: String, ) = LocationDto(99, name, type) - /** - * Models the REAL server, not a convenient fiction: Laravel's - * DeleteLocationRequest::rules() requires `deletion_batch_id` - * unconditionally (`'deletion_batch_id' => ['required', 'uuid']` — - * see inventory-laravel's DeleteLocationRequest.php), so a bodyless - * DELETE 422s every single time, strategy or no strategy. A fake - * whose plain delete() quietly removed the row would be exactly the - * kind of lying fake that shipped Task 4's crash: it would make a - * regression back to `locationRepository.delete(...)` look green - * here while the real endpoint rejects it. This is what makes the - * tests below DISCRIMINATE: any DrawerViewModel path that reaches - * for this method instead of [deleteWithStrategy] fails immediately. - */ - override suspend fun delete( - householdId: Long, - locationId: Long, - ): Unit = throw IOException("422: The deletion batch id field is required.") - override suspend fun deleteWithStrategy( householdId: Long, locationId: Long, @@ -151,12 +133,6 @@ class DrawerViewModelTest { locationId: Long, name: String, ) = ShelfDto(99, name, 0, locationId) - - override suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) {} } private class FakeProductRepository( @@ -511,10 +487,13 @@ class DrawerViewModelTest { fun confirming_delete_sends_a_strategy_and_a_client_minted_batch_id_via_delete_with_strategy() = runTest { // THE regression this task fixes: before it, Home's swipe-to-delete - // called the bodyless LocationRepository.delete() — no strategy, no - // deletion_batch_id — which this fake's delete() now models as a hard - // 422 (see its own doc comment). Reaching for that path instead of - // deleteWithStrategy() turns this test red immediately. + // called a bodyless LocationRepository.delete() — no strategy, no + // deletion_batch_id. That method (and its ShelfRepository twin) has + // since been deleted outright (final review, Blocker 3): it had zero + // production callers and the server unconditionally 422s a bodyless + // delete, so it was a landmine for the next caller to wire one up. + // FakeLocationRepository below only implements deleteWithStrategy — + // any regression back to a bare delete() call now fails to compile. val repo = FakeLocationRepository( mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge", shelf_count = 2, product_count = 5))), diff --git a/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt b/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt index 8c7a75f..746ba02 100644 --- a/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt @@ -56,11 +56,6 @@ class HierarchyStoreTest { name: String, type: String, ) = LocationDto(99, name, type) - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} } private class FakeShelfRepository( @@ -81,12 +76,6 @@ class HierarchyStoreTest { locationId: Long, name: String, ) = ShelfDto(99, name, 0, locationId) - - override suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) {} } private class FakeProductRepository( diff --git a/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt index 6dda025..9f9c368 100644 --- a/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt @@ -52,11 +52,6 @@ class MissingItemsViewModelTest { name: String, type: String, ) = LocationDto(99, name, type) - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} } private class FakeShelfRepository( @@ -77,12 +72,6 @@ class MissingItemsViewModelTest { locationId: Long, name: String, ) = ShelfDto(99, name, 0, locationId) - - override suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) {} } private class FakeProductRepository( diff --git a/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt index 0af080f..ae3894e 100644 --- a/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt @@ -146,11 +146,6 @@ class ProductsViewModelTest { name: String, type: String, ): LocationDto = LocationDto(99, name, type) - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} } private class FakeShelfRepository( @@ -171,12 +166,6 @@ class ProductsViewModelTest { locationId: Long, name: String, ): ShelfDto = ShelfDto(99, name, 0, locationId) - - override suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) {} } private class FakeSearchRepository : SearchRepository { diff --git a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt index bce8f78..d6a9073 100644 --- a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt @@ -51,11 +51,6 @@ class SessionCleanerTest { type: String, ) = throw NotImplementedError() - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} - override fun clear() { cleared = true } @@ -80,12 +75,6 @@ class SessionCleanerTest { name: String, ) = throw NotImplementedError() - override suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) {} - override fun clear() { cleared = true } diff --git a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt index 7b66696..6230d0a 100644 --- a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt @@ -65,14 +65,6 @@ class ShelvesViewModelTest { return dto } - override suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) { - items.removeIf { it.id == shelfId } - } - override suspend fun rename( householdId: Long, locationId: Long, diff --git a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt index 3e28abe..351e8c9 100644 --- a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt @@ -19,7 +19,6 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test -import java.io.IOException class StorageOverviewViewModelTest { @get:Rule @@ -62,23 +61,6 @@ class StorageOverviewViewModelTest { return dto } - /** - * Models the REAL server, not a convenient fiction: Laravel's - * DeleteLocationRequest::rules() requires `deletion_batch_id` - * unconditionally, so a bodyless DELETE 422s every single time, - * strategy or no strategy (see inventory-laravel's - * DeleteLocationRequest.php, and DrawerViewModelTest's equivalent - * fake — same fix, same reasoning). A fake whose plain delete() - * quietly removed the row would be exactly the kind of lying fake - * that shipped Task 4's crash: it would make a regression back to - * `repository.delete(...)` instead of `deleteWithStrategy(...)` look - * green here while the real endpoint rejects it. - */ - override suspend fun delete( - householdId: Long, - locationId: Long, - ): Unit = throw IOException("422: The deletion batch id field is required.") - override suspend fun rename( householdId: Long, locationId: Long, @@ -285,11 +267,6 @@ class StorageOverviewViewModelTest { gate.await() return LocationDto(id = 1, name = name, type = type) } - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) = gate.await() } @Test diff --git a/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt b/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt index 2b29879..357b37a 100644 --- a/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt +++ b/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt @@ -29,11 +29,6 @@ object TestHierarchy { name: String, type: String, ) = throw NotImplementedError() - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} } private object EmptyShelves : ShelfRepository { @@ -52,12 +47,6 @@ object TestHierarchy { locationId: Long, name: String, ) = throw NotImplementedError() - - override suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) {} } private object EmptyProducts : ProductRepository { From e20798e084d378336a61df7eb1d856e09ed48337 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 04:45:58 +0200 Subject: [PATCH 27/40] fix: snap back a rejected shelf/location reorder (Blocker 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShelvesViewModel.move() and StorageOverviewViewModel.move() applied the optimistic reorder frame and then, on a failed repository.reorder() call, did nothing but set an error string — the optimistic order stood forever, silently lying about server order for the rest of the screen visit. Both methods' own comments already asserted the snap-back happened; it didn't. Capture the pre-move list before applying the optimistic frame and restore it when the server call fails, in both ViewModels. Added a discriminating failure test to each VM's suite (confirmed red against the pre-fix code, now green) using a new failReorder flag on each fake. Splitting StorageOverviewViewModelTest's hierarchyStore.refresh() coverage into its own file (StorageOverviewViewModelHierarchyRefreshTest.kt) — the new failure test pushed the original class over detekt's LargeClass threshold. --- .../inventory/ui/shelves/ShelvesViewModel.kt | 45 ++-- .../ui/storage/StorageOverviewViewModel.kt | 43 ++-- .../scuttle/inventory/ShelvesViewModelTest.kt | 35 +++ ...geOverviewViewModelHierarchyRefreshTest.kt | 211 ++++++++++++++++++ .../inventory/StorageOverviewViewModelTest.kt | 164 ++++---------- 5 files changed, 345 insertions(+), 153 deletions(-) create mode 100644 app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelHierarchyRefreshTest.kt diff --git a/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt index 3450b11..ea02239 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt @@ -200,25 +200,44 @@ class ShelvesViewModel val reordered = current.toMutableList().apply { add(target, removeAt(index)) } + // Captured BEFORE the optimistic frame overwrites state, so a + // rejected reorder has something faithful to snap back to. + val preMove = _state.value.shelves + // Optimistic: the row visibly moves on tap. The server call rewrites // every position in one transaction, so a failure snaps the whole list // back rather than leaving it half-sorted. _state.update { it.copy(shelves = reordered + systemShelves) } launchLoading { - // The COMPLETE ordered id list, system shelf excluded — a partial - // list produces duplicate positions server-side, and the server - // does not want the system shelf in the payload at all. - val server = repository.reorder(h, l, reordered.map { it.id }) - // The server's response is the FULL shelf list for this location, - // is_system included (PATCH .../shelves/reorder ends with `return - // $this->index(...)`). Appending our own local systemShelves on - // top of that would duplicate the system shelf's id, and the list - // view's LazyColumn keys itemsIndexed by shelf.id — a duplicate - // key throws IllegalArgumentException on every reorder. Strip any - // system shelf(s) the server sent back before adding ours. - _state.update { it.copy(shelves = orderShelves(server.filterNot { s -> s.is_system } + systemShelves)) } - hierarchyStore.refresh() + // The COMPLETE ordered id list, system shelf excluded — a + // partial list produces duplicate positions server-side, and + // the server does not want the system shelf in the payload at + // all. + val result = runCatching { repository.reorder(h, l, reordered.map { it.id }) } + result.exceptionOrNull()?.let { if (it is CancellationException) throw it } + if (result.isSuccess) { + // The server's response is the FULL shelf list for this + // location, is_system included (PATCH .../shelves/reorder + // ends with `return $this->index(...)`). Appending our own + // local systemShelves on top of that would duplicate the + // system shelf's id, and the list view's LazyColumn keys + // itemsIndexed by shelf.id — a duplicate key throws + // IllegalArgumentException on every reorder. Strip any + // system shelf(s) the server sent back before adding ours. + val server = result.getOrThrow() + _state.update { + it.copy(shelves = orderShelves(server.filterNot { s -> s.is_system } + systemShelves)) + } + hierarchyStore.refresh() + } else { + // The write never landed — the optimistic frame is a lie + // about server order. Snap back to what was true before + // this gesture rather than leaving it standing; launchLoading's + // own catch still turns the exception into state.error below. + _state.update { it.copy(shelves = preMove) } + result.exceptionOrNull()?.let { throw it } + } } } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt index d805ba6..1da6111 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt @@ -167,6 +167,11 @@ class StorageOverviewViewModel val reordered = current.toMutableList().apply { add(target, removeAt(index)) } + // Captured BEFORE the optimistic frame overwrites state (== `current`, + // named separately for clarity at the point it's used below), so a + // rejected reorder has something faithful to snap back to. + val preMove = current + // Optimistic: the row visibly moves on tap. The server call rewrites // every position in one transaction, so a failure snaps the whole list // back rather than leaving it half-sorted. @@ -176,20 +181,30 @@ class StorageOverviewViewModel // The COMPLETE ordered id list — a partial list produces duplicate // positions server-side (LocationController::reorder rejects any // list that isn't exactly every live location in this household). - val server = repository.reorder(id, reordered.map { it.id }) - // The server's response IS the full, current location list for - // this household (PATCH .../locations/reorder ends with `return - // $this->index(...)`, same shape as the shelves endpoint). Replace - // local state with it directly — do NOT merge/append anything - // else on top of it, or a location ends up listed twice and the - // list's LazyColumn (keyed by location.id) crashes on the - // duplicate key. (This is the exact bug Task 4 shipped for - // shelves, at the level above: there it was re-merging a local - // systemShelves copy onto a response that already contained it; - // here there is no system entry, so the fix is simply "replace, - // never merge.") - _state.update { it.copy(locations = orderLocations(server)) } - hierarchyStore.refresh() + val result = runCatching { repository.reorder(id, reordered.map { it.id }) } + result.exceptionOrNull()?.let { if (it is CancellationException) throw it } + if (result.isSuccess) { + // The server's response IS the full, current location list for + // this household (PATCH .../locations/reorder ends with `return + // $this->index(...)`, same shape as the shelves endpoint). Replace + // local state with it directly — do NOT merge/append anything + // else on top of it, or a location ends up listed twice and the + // list's LazyColumn (keyed by location.id) crashes on the + // duplicate key. (This is the exact bug Task 4 shipped for + // shelves, at the level above: there it was re-merging a local + // systemShelves copy onto a response that already contained it; + // here there is no system entry, so the fix is simply "replace, + // never merge.") + _state.update { it.copy(locations = orderLocations(result.getOrThrow())) } + hierarchyStore.refresh() + } else { + // The write never landed — the optimistic frame is a lie about + // server order. Snap back to what was true before this gesture + // rather than leaving it standing; launchLoading's own catch + // still turns the exception into state.error below. + _state.update { it.copy(locations = preMove) } + result.exceptionOrNull()?.let { throw it } + } } } diff --git a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt index 6230d0a..670de04 100644 --- a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt @@ -18,6 +18,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import java.io.IOException class ShelvesViewModelTest { @get:Rule @@ -80,12 +81,17 @@ class ShelvesViewModelTest { var reorderGate: CompletableDeferred? = null + // Simulates a rejected reorder (offline, or another member's concurrent + // edit triggering a 422) — the request never lands server-side at all. + var failReorder = false + override suspend fun reorder( householdId: Long, locationId: Long, ids: List, ): List { reorderGate?.await() + if (failReorder) throw IOException("reorder failed") lastReorderIds = ids // Only positions of the given ids change... ids.forEachIndexed { i, id -> @@ -394,6 +400,35 @@ class ShelvesViewModelTest { ) } + @Test + fun a_rejected_reorder_snaps_the_list_back_to_the_pre_move_order() = + runTest { + // Blocker 1 (final review): the server call rewrites every position + // in one transaction, so a REJECTED reorder (offline, or another + // member's concurrent edit 422ing this one) must not leave the + // optimistic frame standing — the list would silently lie about + // server order for the rest of the screen visit otherwise. + val repo = + FakeShelfRepository().apply { + items.add(ShelfDto(1, "Top", 0, 1L)) + items.add(ShelfDto(2, "Middle", 1, 1L)) + items.add(ShelfDto(3, "Bottom", 2, 1L)) + items.add(ShelfDto(4, "Unsorted", 3, 1L, is_system = true)) + failReorder = true + } + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + + vm.moveUp(3L) + + assertEquals( + listOf("Top", "Middle", "Bottom", "Unsorted"), + vm.state.value.shelves + .map { it.name }, + ) + assertNotNull(vm.state.value.error) + } + @Test fun the_unsorted_shelf_cannot_be_selected_for_deletion() = runTest { diff --git a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelHierarchyRefreshTest.kt b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelHierarchyRefreshTest.kt new file mode 100644 index 0000000..0561cb2 --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelHierarchyRefreshTest.kt @@ -0,0 +1,211 @@ +package dev.scuttle.inventory + +import dev.scuttle.inventory.data.HierarchyStore +import dev.scuttle.inventory.data.dto.HouseholdDto +import dev.scuttle.inventory.data.dto.LocationDto +import dev.scuttle.inventory.data.hierarchy.LocationDeleteStrategy +import dev.scuttle.inventory.data.hierarchy.LocationDeletion +import dev.scuttle.inventory.data.hierarchy.RestoreRepository +import dev.scuttle.inventory.data.household.HouseholdRepository +import dev.scuttle.inventory.data.location.LocationRepository +import dev.scuttle.inventory.ui.storage.StorageOverviewViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +/** + * Split out of StorageOverviewViewModelTest (final review, detekt LargeClass) — + * these are the `hierarchyStore.refresh()` coverage tests (Minor 3, Task 5/5b + * review). Mutating any of the underlying `hierarchyStore.refresh()` call sites + * to `Unit` left the whole suite green before this: a deleted/created/renamed/ + * reordered/undone location would silently keep rendering on Home/the drawer + * until a manual pull-to-refresh. Each test below waits on HierarchyStore's own + * `state` (built from a real HierarchyStore, not a fake) for the numbered + * household [CountingHouseholdRepository.list] mints, so a test can only pass + * once the SPECIFIC `hierarchyStore.refresh()` call site under test has + * actually fired and its result has landed. + */ +class StorageOverviewViewModelHierarchyRefreshTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + /** A minimal, faithful fake — only what load()/create()/rename()/reorder()/deleteWithStrategy() need. */ + private class FakeLocationRepository : LocationRepository { + val items = mutableListOf() + + override fun getCached(householdId: Long): List? = null + + override suspend fun list(householdId: Long): List = items.toList() + + override suspend fun create( + householdId: Long, + name: String, + type: String, + ): LocationDto { + val dto = LocationDto(id = (items.size + 1).toLong(), name = name, type = type) + items.add(dto) + return dto + } + + override suspend fun rename( + householdId: Long, + locationId: Long, + name: String, + type: String, + ): LocationDto { + val index = items.indexOfFirst { it.id == locationId } + val updated = items[index].copy(name = name, type = type) + items[index] = updated + return updated + } + + override suspend fun reorder( + householdId: Long, + ids: List, + ): List { + ids.forEachIndexed { i, id -> + val index = items.indexOfFirst { it.id == id } + if (index >= 0) items[index] = items[index].copy(position = i) + } + return items.toList() + } + + override suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + deletion: LocationDeletion, + ) { + items.removeIf { it.id == locationId } + } + } + + private class FakeRestoreRepository : RestoreRepository { + override suspend fun restore( + householdId: Long, + batchId: String, + ): Int = 1 + } + + /** + * Each call to [list] is numbered, so a test can wait for a SPECIFIC call to + * have landed in [HierarchyStore.state] — proving that exact + * `hierarchyStore.refresh()` call site fired, rather than merely that SOME + * earlier refresh happened to already leave `entries` non-empty. + */ + private class CountingHouseholdRepository : HouseholdRepository { + var listCalls = 0 + + override fun getCached(): List? = null + + override suspend fun list(): List { + listCalls++ + return listOf(HouseholdDto(1, "Home-$listCalls", "AAAA-1111")) + } + + override suspend fun create(name: String) = throw NotImplementedError() + + override suspend fun join(code: String) = throw NotImplementedError() + + override suspend fun leave(householdId: Long) = Unit + } + + private fun viewModel( + repo: LocationRepository = FakeLocationRepository(), + hierarchyStore: HierarchyStore, + restoreRepository: RestoreRepository = FakeRestoreRepository(), + ): StorageOverviewViewModel = StorageOverviewViewModel(repo, hierarchyStore, restoreRepository) + + @Test + fun create_refreshes_the_hierarchy_store() = + runTest { + val householdRepo = CountingHouseholdRepository() + val store = TestHierarchy.store(householdRepo) + val vm = viewModel(hierarchyStore = store) + vm.load(householdId = 1) + vm.onNewNameChange("Pantry") + + vm.create() + + val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } + assertTrue(refreshed.entries.any { it.name == "Home-1" }) + } + + @Test + fun rename_refreshes_the_hierarchy_store() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Freezr", "freezer", 0)) } + val householdRepo = CountingHouseholdRepository() + val store = TestHierarchy.store(householdRepo) + val vm = viewModel(repo, hierarchyStore = store) + vm.load(householdId = 1) + + vm.rename(1L, "Freezer", "freezer") + + val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } + assertTrue(refreshed.entries.any { it.name == "Home-1" }) + } + + @Test + fun reordering_refreshes_the_hierarchy_store() = + runTest { + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Middle", "fridge", 1)) + } + val householdRepo = CountingHouseholdRepository() + val store = TestHierarchy.store(householdRepo) + val vm = viewModel(repo, hierarchyStore = store) + vm.load(householdId = 1) + + vm.moveDown(1L) + + val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } + assertTrue(refreshed.entries.any { it.name == "Home-1" }) + } + + @Test + fun confirming_a_delete_refreshes_the_hierarchy_store() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val householdRepo = CountingHouseholdRepository() + val store = TestHierarchy.store(householdRepo) + val vm = viewModel(repo, hierarchyStore = store) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + + val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } + assertTrue(refreshed.entries.any { it.name == "Home-1" }) + } + + @Test + fun undo_delete_refreshes_the_hierarchy_store() = + runTest { + // Isolates undoDelete()'s OWN refresh call from confirmDelete()'s (which + // also refreshes, on the same success path, just before this runs): the + // counting repo mints a distinctly-named household per call, so this can + // only pass once a SECOND refresh — undo's — has landed. + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val householdRepo = CountingHouseholdRepository() + val store = TestHierarchy.store(householdRepo) + val vm = viewModel(repo, hierarchyStore = store) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + // Absorb confirmDelete's own refresh (call #1) before testing undo's. + store.state.first { it.entries.any { e -> e.name == "Home-1" } } + + vm.undoDelete() + + val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-2" } } + assertTrue(refreshed.entries.any { it.name == "Home-2" }) + } +} diff --git a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt index 351e8c9..db0c335 100644 --- a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt @@ -10,7 +10,6 @@ import dev.scuttle.inventory.data.household.HouseholdRepository import dev.scuttle.inventory.data.location.LocationRepository import dev.scuttle.inventory.ui.storage.StorageOverviewViewModel import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -19,6 +18,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import java.io.IOException class StorageOverviewViewModelTest { @get:Rule @@ -74,11 +74,16 @@ class StorageOverviewViewModelTest { return updated } + // Simulates a rejected reorder (offline, or another member's concurrent + // edit triggering a 422) — the request never lands server-side at all. + var failReorder = false + override suspend fun reorder( householdId: Long, ids: List, ): List { reorderGate?.await() + if (failReorder) throw IOException("reorder failed") lastReorderIds = ids // Only positions of the given ids change... ids.forEachIndexed { i, id -> @@ -137,30 +142,6 @@ class StorageOverviewViewModelTest { override suspend fun leave(householdId: Long) = Unit } - /** - * Used only by the `*_refreshes_the_hierarchy_store` tests below (Minor 3, - * Task 5/5b review): each call to [list] is numbered, so a test can wait for - * a SPECIFIC call to have landed in [HierarchyStore.state] — proving that - * exact `hierarchyStore.refresh()` call site fired, rather than merely that - * SOME earlier refresh happened to already leave `entries` non-empty. - */ - private class CountingHouseholdRepository : HouseholdRepository { - var listCalls = 0 - - override fun getCached(): List? = null - - override suspend fun list(): List { - listCalls++ - return listOf(HouseholdDto(1, "Home-$listCalls", "AAAA-1111")) - } - - override suspend fun create(name: String) = throw NotImplementedError() - - override suspend fun join(code: String) = throw NotImplementedError() - - override suspend fun leave(householdId: Long) = Unit - } - private fun viewModel( repo: LocationRepository = FakeLocationRepository(), restoreRepository: RestoreRepository = FakeRestoreRepository(), @@ -452,6 +433,34 @@ class StorageOverviewViewModelTest { ) } + @Test + fun a_rejected_reorder_snaps_the_list_back_to_the_pre_move_order() = + runTest { + // Blocker 1 (final review): the server call rewrites every position + // in one transaction, so a REJECTED reorder (offline, or another + // member's concurrent edit 422ing this one) must not leave the + // optimistic frame standing — the list would silently lie about + // server order for the rest of the screen visit otherwise. + val repo = + FakeLocationRepository().apply { + items.add(LocationDto(1, "Top", "freezer", 0)) + items.add(LocationDto(2, "Middle", "fridge", 1)) + items.add(LocationDto(3, "Bottom", "pantry", 2)) + failReorder = true + } + val vm = viewModel(repo) + vm.load(householdId = 1) + + vm.moveUp(3L) + + assertEquals( + listOf("Top", "Middle", "Bottom"), + vm.state.value.locations + .map { it.name }, + ) + assertNotNull(vm.state.value.error) + } + @Test fun request_delete_does_not_delete_anything_until_confirmed() = runTest { @@ -840,105 +849,8 @@ class StorageOverviewViewModelTest { assertNull(vm.state.value.pendingDelete) } - // --- hierarchyStore.refresh() coverage (Minor 3, Task 5/5b review) --------- - // - // Mutating any of these calls to Unit left the whole suite green before this: - // a deleted/created/renamed/reordered/undone location would silently keep - // rendering on Home/the drawer until a manual pull-to-refresh. Each test below - // waits on HierarchyStore's own `state` (built from a real HierarchyStore, not - // a fake) for the numbered household CountingHouseholdRepository.list() mints, - // so a test can only pass once the SPECIFIC `hierarchyStore.refresh()` call - // site under test has actually fired and its result has landed. - - @Test - fun create_refreshes_the_hierarchy_store() = - runTest { - val householdRepo = CountingHouseholdRepository() - val store = TestHierarchy.store(householdRepo) - val vm = viewModel(hierarchyStore = store) - vm.load(householdId = 1) - vm.onNewNameChange("Pantry") - - vm.create() - - val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } - assertTrue(refreshed.entries.any { it.name == "Home-1" }) - } - - @Test - fun rename_refreshes_the_hierarchy_store() = - runTest { - val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Freezr", "freezer", 0)) } - val householdRepo = CountingHouseholdRepository() - val store = TestHierarchy.store(householdRepo) - val vm = viewModel(repo, hierarchyStore = store) - vm.load(householdId = 1) - - vm.rename(1L, "Freezer", "freezer") - - val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } - assertTrue(refreshed.entries.any { it.name == "Home-1" }) - } - - @Test - fun reordering_refreshes_the_hierarchy_store() = - runTest { - val repo = - FakeLocationRepository().apply { - items.add(LocationDto(1, "Top", "freezer", 0)) - items.add(LocationDto(2, "Middle", "fridge", 1)) - } - val householdRepo = CountingHouseholdRepository() - val store = TestHierarchy.store(householdRepo) - val vm = viewModel(repo, hierarchyStore = store) - vm.load(householdId = 1) - - vm.moveDown(1L) - - val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } - assertTrue(refreshed.entries.any { it.name == "Home-1" }) - } - - @Test - fun confirming_a_delete_refreshes_the_hierarchy_store() = - runTest { - val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } - val householdRepo = CountingHouseholdRepository() - val store = TestHierarchy.store(householdRepo) - val vm = viewModel(repo, hierarchyStore = store) - vm.load(householdId = 1) - vm.enterEditMode() - vm.toggleSelection(1L) - vm.requestDelete() - - vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) - - val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-1" } } - assertTrue(refreshed.entries.any { it.name == "Home-1" }) - } - - @Test - fun undo_delete_refreshes_the_hierarchy_store() = - runTest { - // Isolates undoDelete()'s OWN refresh call from confirmDelete()'s (which - // also refreshes, on the same success path, just before this runs): the - // counting repo mints a distinctly-named household per call, so this can - // only pass once a SECOND refresh — undo's — has landed. - val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } - val householdRepo = CountingHouseholdRepository() - val store = TestHierarchy.store(householdRepo) - val vm = viewModel(repo, hierarchyStore = store) - vm.load(householdId = 1) - vm.enterEditMode() - vm.toggleSelection(1L) - vm.requestDelete() - vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) - // Absorb confirmDelete's own refresh (call #1) before testing undo's. - store.state.first { it.entries.any { e -> e.name == "Home-1" } } - - vm.undoDelete() - - val refreshed = store.state.first { it.entries.any { e -> e.name == "Home-2" } } - assertTrue(refreshed.entries.any { it.name == "Home-2" }) - } + // hierarchyStore.refresh() coverage (Minor 3, Task 5/5b review) now lives in + // StorageOverviewViewModelHierarchyRefreshTest.kt — split out of this class + // (final review, detekt LargeClass) once the Blocker-1 snap-back test pushed + // this file over detekt's size threshold. } From 8dc73a24efb8748040855ce332a0a5666d3b3d0c Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 04:58:47 +0200 Subject: [PATCH 28/40] fix: restore multi-household search reachability (Blocker 2) Task 7's bottom-nav rework deleted the Search tab's household picker along with its sole test coverage (SearchHouseholdPickerFlowTest), and every replacement search entry point regressed to hard-coding the FIRST household: MainActivity's scanner LOOKUP branch, DashboardScreen's top-bar icon and products stat card, AllStoragesScreen's top-bar icon, and MissingItemsScreen's top-bar icon. A second household's search was only reachable by drilling Home -> that household's own "+" icon -> Storage overview -> its search icon. Reintroduces the picker as a shared ui/common/HouseholdPickerSheet (ported from git history's MainActivity ModalBottomSheet) and wires it into all four entry points: shown only when the household count is greater than one, skipped (direct navigation) otherwise. The scanner LOOKUP branch now holds the scanned code in NavHost-level state until a household is picked. Re-adds the deleted flow test's coverage as HouseholdPickerFlowTest, proving the picker's SECOND option actually hits that household's search endpoint (via Dashboard's icon, the one entry point drivable without a real camera scan). --- .../inventory/flow/HouseholdPickerFlowTest.kt | 98 +++++++++++++++++++ .../dev/scuttle/inventory/MainActivity.kt | 60 +++++++++--- .../inventory/ui/common/HouseholdOption.kt | 7 ++ .../ui/common/HouseholdPickerSheet.kt | 68 +++++++++++++ .../inventory/ui/dashboard/DashboardScreen.kt | 36 ++++++- .../inventory/ui/home/AllStoragesScreen.kt | 46 +++++++-- .../ui/missing/MissingItemsScreen.kt | 37 ++++++- app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 9 files changed, 326 insertions(+), 28 deletions(-) create mode 100644 app/src/androidTest/java/dev/scuttle/inventory/flow/HouseholdPickerFlowTest.kt create mode 100644 app/src/main/java/dev/scuttle/inventory/ui/common/HouseholdOption.kt create mode 100644 app/src/main/java/dev/scuttle/inventory/ui/common/HouseholdPickerSheet.kt diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/HouseholdPickerFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/HouseholdPickerFlowTest.kt new file mode 100644 index 0000000..eae5d36 --- /dev/null +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/HouseholdPickerFlowTest.kt @@ -0,0 +1,98 @@ +@file:OptIn(androidx.compose.ui.test.ExperimentalTestApi::class) + +package dev.scuttle.inventory.flow + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.waitUntilAtLeastOneExists +import dagger.hilt.android.testing.HiltAndroidTest +import dev.scuttle.inventory.FlowTestBase +import dev.scuttle.inventory.ui.dashboard.DASHBOARD_TITLE_TEST_TAG +import org.junit.Test + +/** + * Replaces the deleted SearchHouseholdPickerFlowTest (Task 7's bottom-nav rework + * removed the Search tab and its household-picker ModalBottomSheet with it, and the + * test along with it — see `git log --diff-filter=D` on + * app/src/androidTest/java/dev/scuttle/inventory/flow/SearchHouseholdPickerFlowTest.kt). + * + * Final review, Blocker 2: every replacement entry point (Dashboard's top-bar icon and + * products stat card, the Storage tab's top-bar icon, Missing items' top-bar icon, the + * bottom-bar Scan tab's LOOKUP mode) had regressed to hard-coding the FIRST household, + * making a second household's search reachable only by drilling Home → that + * household's own "+" icon → Storage overview → its search icon. The picker (now a + * shared HouseholdPickerSheet, reused by all of those entry points) fixes that; this + * test drives it from Dashboard's top-bar search icon — the one entry point reachable + * without a real camera scan — and proves picking the SECOND household actually issues + * a search request against THAT household's endpoint, not merely that some search + * screen appears, by giving each household a distinct results fixture and asserting on + * the household-2-specific content while asserting the household-1-specific content is + * absent. + */ +@HiltAndroidTest +class HouseholdPickerFlowTest : FlowTestBase() { + @Test + fun picking_second_household_from_the_dashboard_picker_searches_that_households_endpoint() { + mockServer.enqueue(fixture("auth_login.json")) + mockServer.route("/households", fixture("households_two.json")) + mockServer.route("/households/1/locations", fixture("locations_one.json")) + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one.json")) + mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + // Household 2 ("Office") has no locations — keeps the fixture set minimal while + // still letting HierarchyStore's login-time refresh fully populate both + // households (required for the picker to show at all). + mockServer.route("/households/2/locations", fixture("locations_empty.json")) + + composeRule.apply { + onNodeWithText("Email").performTextInput("test@example.com") + onNodeWithText("Password").performTextInput("password123") + onAllNodesWithText("Sign in").filterToOne(hasClickAction()).performClick() + + Thread.sleep(3_000) + waitUntilAtLeastOneExists(hasTestTag(DASHBOARD_TITLE_TEST_TAG), timeoutMillis = 5_000) + + // "across 2 households" only renders once DashboardUiState.households has + // BOTH entries — HierarchyStore.refresh() publishes entries atomically + // (buildFromNetwork resolves every household before the one _state.value + // write), so this is the real signal that household 2 finished loading + // too, not just household 1. Tapping search before this could silently + // navigate straight through instead of opening the picker at all, + // testing the wrong thing entirely. + waitUntilAtLeastOneExists(hasText("across 2 households"), timeoutMillis = 8_000) + + mockServer.route("/households/1/search", fixture("search_results.json")) + mockServer.route("/households/2/search", fixture("search_results_household2.json")) + + // Two households -> tapping Dashboard's search icon must open the + // picker, not navigate directly. + onNodeWithContentDescription("Search").performClick() + waitUntilAtLeastOneExists(hasTestTag("household-picker-Office"), timeoutMillis = 5_000) + onNodeWithTag("household-picker-Home").assertIsDisplayed() + onNodeWithTag("household-picker-Office").assertIsDisplayed() + + // Pick the SECOND household ("Office", id 2) — not the first. + onNodeWithTag("household-picker-Office").performClick() + waitUntilAtLeastOneExists(hasTestTag("search_field"), timeoutMillis = 5_000) + + onNodeWithTag("search_field").performTextInput("Coffee") + Thread.sleep(1_500) + waitForIdle() + + // Household 2's result shows... + waitUntilAtLeastOneExists(hasText("Pantry › Bottom shelf"), timeoutMillis = 5_000) + onNodeWithText("Pantry › Bottom shelf").assertIsDisplayed() + // ...and household 1's does not: proves the search request hit household 2's + // endpoint, not household 1's (the exact bug this branch's final review caught). + onNodeWithText("Fridge › Top shelf").assertDoesNotExist() + } + } +} diff --git a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt index 4a1ad5b..b5a2b3b 100644 --- a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt +++ b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt @@ -50,6 +50,8 @@ import dev.scuttle.inventory.ui.app.DrawerViewModel import dev.scuttle.inventory.ui.auth.AuthScreen import dev.scuttle.inventory.ui.auth.AuthViewModel import dev.scuttle.inventory.ui.auth.ForgotPasswordScreen +import dev.scuttle.inventory.ui.common.HouseholdOption +import dev.scuttle.inventory.ui.common.HouseholdPickerSheet import dev.scuttle.inventory.ui.dashboard.DashboardScreen import dev.scuttle.inventory.ui.home.AllStoragesScreen import dev.scuttle.inventory.ui.households.HouseholdEditScreen @@ -321,6 +323,12 @@ private fun InventoryNavHost( val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route val drawerUi by drawerViewModel.state.collectAsState() + // Non-null while the Scan tab's LOOKUP mode is waiting on a household pick + // (Blocker 2, final review): the scanned code, held until the picker below + // resolves it to a household and navigates to Search. The scanner has + // genuinely no household context of its own to fall back to — see + // ScanDeliveryAction.NavigateToSearch's own handling below. + var pendingScanLookupCode by rememberSaveable { mutableStateOf(null) } val bottomTabs = listOf( BottomTab("dashboard", Routes.DASHBOARD, R.string.nav_dashboard, Icons.Filled.SpaceDashboard), @@ -374,6 +382,24 @@ private fun InventoryNavHost( } }, ) { scaffoldPadding -> + pendingScanLookupCode?.let { code -> + HouseholdPickerSheet( + households = drawerUi.entries.map { HouseholdOption(it.id, it.name) }, + onDismiss = { + pendingScanLookupCode = null + // Dismissed without picking: nothing left to look up. Back out + // of the scanner instead of stranding the user on the camera. + navController.popBackStack() + }, + onPick = { householdId -> + pendingScanLookupCode = null + navController.navigate(Routes.search(householdId, code)) { + popUpTo(Routes.SCANNER) { inclusive = true } + launchSingleTop = true + } + }, + ) + } NavHost( navController = navController, startDestination = Routes.AUTH, @@ -537,7 +563,7 @@ private fun InventoryNavHost( onOpenLocation = { hhId, locId -> navController.navigate(Routes.location(hhId, locId)) }, - firstHouseholdId = drawerUi.entries.firstOrNull()?.id, + households = drawerUi.entries.map { HouseholdOption(it.id, it.name) }, onOpenSearch = { hhId -> navController.navigate(Routes.search(hhId)) { launchSingleTop = true } }, @@ -565,17 +591,29 @@ private fun InventoryNavHost( navController.popBackStack() } is ScanDeliveryAction.NavigateToSearch -> { - val householdId = drawerUi.entries.firstOrNull()?.id - if (householdId != null) { - navController.navigate(Routes.search(householdId, action.code)) { - popUpTo(Routes.SCANNER) { inclusive = true } - launchSingleTop = true + val entries = drawerUi.entries + when { + entries.isEmpty() -> { + // Household-less account: nothing to look up + // in. Back out of the scanner instead of + // stranding the user on the camera. + navController.popBackStack() + } + entries.size == 1 -> { + navController.navigate(Routes.search(entries.first().id, action.code)) { + popUpTo(Routes.SCANNER) { inclusive = true } + launchSingleTop = true + } + } + else -> { + // More than one household and, unlike + // Dashboard/Home/Missing items, genuinely no + // row/context to carry one from — this IS the + // no-context case (Blocker 2, final review). + // Ask via the picker rendered above instead of + // hard-coding the first household. + pendingScanLookupCode = action.code } - } else { - // Household-less account: nothing to look up in. - // Back out of the scanner instead of stranding the - // user on the camera. - navController.popBackStack() } } } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/common/HouseholdOption.kt b/app/src/main/java/dev/scuttle/inventory/ui/common/HouseholdOption.kt new file mode 100644 index 0000000..1896849 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/common/HouseholdOption.kt @@ -0,0 +1,7 @@ +package dev.scuttle.inventory.ui.common + +/** A household as [HouseholdPickerSheet] needs it: enough to label a row and pick it. */ +data class HouseholdOption( + val id: Long, + val name: String, +) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/common/HouseholdPickerSheet.kt b/app/src/main/java/dev/scuttle/inventory/ui/common/HouseholdPickerSheet.kt new file mode 100644 index 0000000..9356528 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/common/HouseholdPickerSheet.kt @@ -0,0 +1,68 @@ +package dev.scuttle.inventory.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.scuttle.inventory.R + +/** + * The multi-household picker (final review, Blocker 2 — reintroduced from git history, + * `git show c372925^:app/src/main/java/dev/scuttle/inventory/MainActivity.kt`, where it + * lived inline before Task 7's bottom-nav rework deleted it along with the Search tab). + * + * Every entry point that opens Search from a GLOBAL action — one with no specific row or + * household already in hand (Dashboard's top-bar icon and products stat card, Missing + * items' top-bar icon, the bottom-bar Scan tab's LOOKUP mode) — used to hard-code the + * FIRST household instead of asking. That silently made every household but the first + * unreachable from those entry points: the only working path to a second household's + * search was Home → that household's own "+" icon → Storage overview → its search icon. + * Callers must only show this when there is more than one household (skip it and + * navigate directly otherwise) — a picker over a single option is just an extra tap. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HouseholdPickerSheet( + households: List, + onDismiss: () -> Unit, + onPick: (Long) -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .navigationBarsPadding(), + ) { + Text( + stringResource(R.string.search_choose_household_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + households.forEach { household -> + NavigationDrawerItem( + label = { Text(household.name) }, + selected = false, + onClick = { onPick(household.id) }, + modifier = + Modifier + .padding(horizontal = 12.dp) + .testTag("household-picker-${household.name}"), + ) + } + } + } +} diff --git a/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt index 62389df..f756736 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt @@ -36,6 +36,9 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -46,6 +49,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import dev.scuttle.inventory.R import dev.scuttle.inventory.data.LowStockItem +import dev.scuttle.inventory.ui.common.HouseholdOption +import dev.scuttle.inventory.ui.common.HouseholdPickerSheet import dev.scuttle.inventory.ui.common.LiveStatusText import dev.scuttle.inventory.ui.theme.FrostCard import dev.scuttle.inventory.ui.theme.HouseholdAvatar @@ -76,6 +81,22 @@ fun DashboardScreen( if (state.showHouseholdAttribution) state.householdFor(householdId) else null } + // Blocker 2 (final review): Dashboard's search entry points (the top-bar icon + // and the products stat card below) are both GLOBAL actions — neither is tied + // to a specific row/household the way a favorite or a running-low item is — so + // there's no context to carry the way there is for a per-row tap. With exactly + // one household there's nothing to ask; with more than one, hard-coding the + // FIRST (the bug this fixes) silently made every other household's search + // unreachable from here. Ask instead via the shared picker. + var showHouseholdPicker by rememberSaveable { mutableStateOf(false) } + val openSearch: () -> Unit = { + if (state.households.size > 1) { + showHouseholdPicker = true + } else { + state.firstHouseholdId?.let(onOpenSearch) + } + } + if (state.hasNoHouseholds && !state.loading) { AlertDialog( onDismissRequest = {}, @@ -108,7 +129,7 @@ fun DashboardScreen( // icon, per spec — an occasional "where did I put it", not a daily // destination. Guarded the same way the products stat card below // is: nothing to search without at least one household. - IconButton(onClick = { state.firstHouseholdId?.let(onOpenSearch) }) { + IconButton(onClick = openSearch) { Icon(Icons.Default.Search, contentDescription = stringResource(R.string.nav_search)) } IconButton(onClick = viewModel::refresh) { @@ -160,7 +181,7 @@ fun DashboardScreen( label = stringResource(R.string.dashboard_stat_products), value = state.totalProducts.toString(), modifier = Modifier.weight(1f), - onClick = { state.firstHouseholdId?.let(onOpenSearch) }, + onClick = openSearch, ) } @@ -241,6 +262,17 @@ fun DashboardScreen( } } // end PullToRefreshBox } + + if (showHouseholdPicker) { + HouseholdPickerSheet( + households = state.households.map { HouseholdOption(it.id, it.name) }, + onDismiss = { showHouseholdPicker = false }, + onPick = { householdId -> + showHouseholdPicker = false + onOpenSearch(householdId) + }, + ) + } } @Composable diff --git a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt index 8a141bf..d699f25 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt @@ -63,6 +63,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import dev.scuttle.inventory.R import dev.scuttle.inventory.ui.app.DrawerViewModel import dev.scuttle.inventory.ui.common.ErrorRetry +import dev.scuttle.inventory.ui.common.HouseholdOption +import dev.scuttle.inventory.ui.common.HouseholdPickerSheet import dev.scuttle.inventory.ui.common.SnackbarErrorEffect import dev.scuttle.inventory.ui.common.orderByPosition import dev.scuttle.inventory.ui.common.storageTypeLabel @@ -95,6 +97,25 @@ fun AllStoragesScreen( // isn't silent (W10). SnackbarErrorEffect(actionError, snackbarHostState, onConsumed = viewModel::consumeActionError) + // Blocker 2 (final review): this screen's search icon is a single GLOBAL + // top-bar action, not tied to any one of the household groups rendered below — + // there's no "row tapped" to carry a household from. With exactly one household + // there's nothing to ask; with more than one, hard-coding the FIRST (the bug + // this fixes) silently made every other household's search reachable only by + // drilling into that household's own Storage overview first. Ask via the shared + // picker instead. + var showHouseholdPicker by rememberSaveable { mutableStateOf(false) } + val openSearch: () -> Unit = { + if (state.entries.size > 1) { + showHouseholdPicker = true + } else { + state.entries + .firstOrNull() + ?.id + ?.let(onOpenSearch) + } + } + val statusBarInsets = WindowInsets.statusBars Scaffold( contentWindowInsets = WindowInsets(0), @@ -111,16 +132,10 @@ fun AllStoragesScreen( } } else { // Search lost its bottom-nav tab (Task 7) but keeps a top-bar - // icon here, per spec — same guard as Dashboard's: nothing to - // search without at least one household loaded. - IconButton( - onClick = { - state.entries - .firstOrNull() - ?.id - ?.let(onOpenSearch) - }, - ) { + // icon here, per spec — opens the household picker with more + // than one household (see openSearch above), navigates + // straight through with exactly one, no-ops with none. + IconButton(onClick = openSearch) { Icon(Icons.Default.Search, contentDescription = stringResource(R.string.nav_search)) } if (state.entries.any { it.locations.isNotEmpty() }) { @@ -378,4 +393,15 @@ fun AllStoragesScreen( viewModel.consumeLastBatch() } } + + if (showHouseholdPicker) { + HouseholdPickerSheet( + households = state.entries.map { HouseholdOption(it.id, it.name) }, + onDismiss = { showHouseholdPicker = false }, + onPick = { householdId -> + showHouseholdPicker = false + onOpenSearch(householdId) + }, + ) + } } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt index 9b2e261..4379ae6 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt @@ -25,6 +25,9 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -32,6 +35,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import dev.scuttle.inventory.R import dev.scuttle.inventory.ui.common.ErrorRetry +import dev.scuttle.inventory.ui.common.HouseholdOption +import dev.scuttle.inventory.ui.common.HouseholdPickerSheet import dev.scuttle.inventory.ui.theme.FrostCard @OptIn(ExperimentalMaterial3Api::class) @@ -41,12 +46,22 @@ fun MissingItemsScreen( onOpenLocation: (householdId: Long, locationId: Long) -> Unit, // MissingItemsUiState has no household list of its own (only items, which are // empty in the common case this button matters least) — the caller passes the - // same "first household" HierarchyStore already resolves for Dashboard/Home. - firstHouseholdId: Long? = null, + // same household list HierarchyStore already resolves for Dashboard/Home, so + // this screen's own search icon can ask (Blocker 2, final review) rather than + // silently hard-coding the first one when there's more than one household. + households: List = emptyList(), onOpenSearch: (householdId: Long) -> Unit = {}, viewModel: MissingItemsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsState() + var showHouseholdPicker by rememberSaveable { mutableStateOf(false) } + val openSearch: () -> Unit = { + if (households.size > 1) { + showHouseholdPicker = true + } else { + households.firstOrNull()?.id?.let(onOpenSearch) + } + } val statusBarInsets = WindowInsets.statusBars Scaffold( @@ -65,9 +80,10 @@ fun MissingItemsScreen( }, actions = { // Search lost its bottom-nav tab (Task 7) but keeps a top-bar icon - // here, per spec — same "nothing to search without a household" - // guard as Dashboard/Home. - IconButton(onClick = { firstHouseholdId?.let(onOpenSearch) }) { + // here, per spec — opens the household picker with more than one + // household (see openSearch above), navigates straight through + // with exactly one, no-ops with none. + IconButton(onClick = openSearch) { Icon(Icons.Default.Search, contentDescription = stringResource(R.string.nav_search)) } if (state.items.isNotEmpty()) { @@ -150,4 +166,15 @@ fun MissingItemsScreen( } } } + + if (showHouseholdPicker) { + HouseholdPickerSheet( + households = households, + onDismiss = { showHouseholdPicker = false }, + onPick = { householdId -> + showHouseholdPicker = false + onOpenSearch(householdId) + }, + ) + } } diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index eca8deb..f69f639 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -159,6 +159,7 @@ Producten zoeken Geen resultaten. Aantal: %s + In welk huishouden zoeken? Instellingen diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 08fbc43..c74a0ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -159,6 +159,7 @@ Search products No matches. Qty: %s + Search which household? Settings From 676b6dc9183a915937543e1cdadd7a7c9666d511 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 05:18:33 +0200 Subject: [PATCH 29/40] fix: wire shelf_unsorted, delete_undone/delete_undo_failed, and the location title (ALSO FIX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three string resources shipped and were never wired to the requirement they belonged to, because an unused string breaks no gate: - R.string.shelf_unsorted had zero usages; a Dutch user saw the raw English "Unsorted" everywhere a shelf name renders. Added ui/common/ShelfDisplayName.kt (mirrors storageTypeLabel's pure-lookup + composable split) and wired it into LocationDetailScreen's tab strip, shelf list and warning content description, DashboardScreen's favorite shelves, and ProductsViewModel's move-target list. MoveTarget now carries locationName/shelfName/isSystemShelf raw instead of a pre-baked label, since a ViewModel has no locale-correct way to resolve a string resource itself (this app's language switch is a per-Activity Configuration override, invisible to the singleton Application context a ViewModel could otherwise inject) — the composable renders the label. - delete_undone / delete_undo_failed were dead; a 409 from the restore endpoint fell through toUserMessage to a generic "Something went wrong." Added ui/hierarchy/UndoOutcome and threaded it through ShelvesViewModel, StorageOverviewViewModel and DrawerViewModel's undoDelete() (all three call the same restore endpoint and shared the bug); each screen now shows the specific localized snackbar instead of the generic fallback, without double-messaging via the generic error path. - LocationDetailScreen's top bar always showed the generic "Shelves" title even though the location itself became renamable this branch. It now reads the location's own name from drawerViewModel's already- loaded state, falling back to the generic title only until that loads. Adds StringResourceUsageTest: every string declared in values/strings.xml must have at least one Kotlin/XML reference, so an unwired string like these three fails the JVM suite instead of shipping silently. Also adds LocationTitleWiringTest (source-scan, mirrors NavigationWiringTest) and ShelfDisplayNameTest for the two changes with no other test surface. --- .../inventory/ui/app/DrawerViewModel.kt | 22 ++++- .../inventory/ui/common/ShelfDisplayName.kt | 34 ++++++++ .../inventory/ui/dashboard/DashboardScreen.kt | 3 +- .../inventory/ui/hierarchy/UndoOutcome.kt | 21 +++++ .../inventory/ui/home/AllStoragesScreen.kt | 18 +++++ .../ui/location/LocationDetailScreen.kt | 41 +++++++++- .../inventory/ui/products/ProductsPane.kt | 6 +- .../ui/products/ProductsViewModel.kt | 15 +++- .../inventory/ui/shelves/ShelvesViewModel.kt | 27 ++++++- .../ui/storage/StorageOverviewScreen.kt | 18 +++++ .../ui/storage/StorageOverviewViewModel.kt | 27 ++++++- .../scuttle/inventory/DrawerViewModelTest.kt | 45 +++++++++++ .../inventory/LocationTitleWiringTest.kt | 51 ++++++++++++ .../inventory/ProductsViewModelTest.kt | 39 ++++++++- .../scuttle/inventory/ShelfDisplayNameTest.kt | 25 ++++++ .../scuttle/inventory/ShelvesViewModelTest.kt | 54 +++++++++++++ .../inventory/StorageOverviewViewModelTest.kt | 54 +++++++++++++ .../inventory/StringResourceUsageTest.kt | 80 +++++++++++++++++++ 18 files changed, 561 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/dev/scuttle/inventory/ui/common/ShelfDisplayName.kt create mode 100644 app/src/main/java/dev/scuttle/inventory/ui/hierarchy/UndoOutcome.kt create mode 100644 app/src/test/java/dev/scuttle/inventory/LocationTitleWiringTest.kt create mode 100644 app/src/test/java/dev/scuttle/inventory/ShelfDisplayNameTest.kt create mode 100644 app/src/test/java/dev/scuttle/inventory/StringResourceUsageTest.kt diff --git a/app/src/main/java/dev/scuttle/inventory/ui/app/DrawerViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/app/DrawerViewModel.kt index 9b3b3f7..6494b15 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/app/DrawerViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/app/DrawerViewModel.kt @@ -12,6 +12,7 @@ import dev.scuttle.inventory.data.hierarchy.RestoreRepository import dev.scuttle.inventory.data.location.LocationRepository import dev.scuttle.inventory.ui.hierarchy.DeletePlan import dev.scuttle.inventory.ui.hierarchy.MoveTarget +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -45,6 +46,12 @@ data class DrawerUiState( val moveTargets: List = emptyList(), /** The batch just deleted, for the Undo snackbar. Cleared once consumed. */ val lastBatchId: String? = null, + /** + * One-shot result of the last undoDelete() call — null while none is pending. + * The screen turns this into the matching localized snackbar (delete_undone / + * delete_undo_failed) and calls [DrawerViewModel.consumeUndoResult]. + */ + val undoResult: UndoOutcome? = null, ) /** @@ -57,6 +64,7 @@ private data class DeleteFlowState( val pendingDelete: DeletePlan? = null, val moveTargets: List = emptyList(), val lastBatchId: String? = null, + val undoResult: UndoOutcome? = null, ) @HiltViewModel @@ -81,6 +89,7 @@ class DrawerViewModel pendingDelete = del.pendingDelete, moveTargets = del.moveTargets, lastBatchId = del.lastBatchId, + undoResult = del.undoResult, ) }.stateIn(viewModelScope, SharingStarted.Eagerly, DrawerUiState()) @@ -208,16 +217,25 @@ class DrawerViewModel if (householdId == null || batchId == null || deleteJob?.isActive == true) return deleteJob = viewModelScope.launch { + // A 409 here means the batch was already restored (another + // device, a double-tap) or permanently removed past the undo + // window — NOT a generic action failure, so this does NOT go + // through _actionError/toUserMessage's generic fallback; the + // screen turns undoResult into the specific message instead. runCatching { restoreRepository.restore(householdId, batchId) } .onSuccess { - deleteFlow.update { it.copy(lastBatchId = null) } + deleteFlow.update { it.copy(lastBatchId = null, undoResult = UndoOutcome.SUCCESS) } store.refresh() - }.onFailure { e -> _actionError.value = e.toUserMessage("Failed to undo delete.") } + }.onFailure { + deleteFlow.update { it.copy(undoResult = UndoOutcome.FAILURE) } + } } } fun consumeLastBatch() = deleteFlow.update { it.copy(lastBatchId = null) } + fun consumeUndoResult() = deleteFlow.update { it.copy(undoResult = null) } + fun consumeActionError() { _actionError.value = null } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/common/ShelfDisplayName.kt b/app/src/main/java/dev/scuttle/inventory/ui/common/ShelfDisplayName.kt new file mode 100644 index 0000000..b7fda0b --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/common/ShelfDisplayName.kt @@ -0,0 +1,34 @@ +package dev.scuttle.inventory.ui.common + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import dev.scuttle.inventory.R +import dev.scuttle.inventory.data.dto.ShelfDto + +/** + * A shelf's display name: the localized "Unsorted" label for the system shelf + * (ShelfDto.is_system — the server always stores the literal name "Unsorted", and + * ShelfDto.name's own doc comment already promises "the client localises the label + * off this" — final review, ALSO FIX: it never actually did until this), the raw + * server [name] for every other shelf. Takes the raw fields rather than a [ShelfDto] + * so ProductsViewModel's own `MoveTarget` (built outside any Composable, so it can't + * localize anything itself) can still be displayed correctly at render time — see the + * [ShelfDto] overload below for the common case. + */ +@Composable +fun shelfDisplayName( + name: String, + isSystem: Boolean, +): String = shelfDisplayNameRes(isSystem)?.let { stringResource(it) } ?: name + +@Composable +fun shelfDisplayName(shelf: ShelfDto): String = shelfDisplayName(shelf.name, shelf.is_system) + +/** + * The string resource for a system shelf's localized label, or null for a normal + * shelf (the composable above then falls back to the raw server name). Split out so + * the mapping itself is unit-testable on the JVM, mirroring storageTypeLabelRes. + */ +@StringRes +fun shelfDisplayNameRes(isSystem: Boolean): Int? = if (isSystem) R.string.shelf_unsorted else null diff --git a/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt index f756736..882d4f6 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/dashboard/DashboardScreen.kt @@ -52,6 +52,7 @@ import dev.scuttle.inventory.data.LowStockItem import dev.scuttle.inventory.ui.common.HouseholdOption import dev.scuttle.inventory.ui.common.HouseholdPickerSheet import dev.scuttle.inventory.ui.common.LiveStatusText +import dev.scuttle.inventory.ui.common.shelfDisplayName import dev.scuttle.inventory.ui.theme.FrostCard import dev.scuttle.inventory.ui.theme.HouseholdAvatar @@ -251,7 +252,7 @@ fun DashboardScreen( ) state.favoriteShelves.forEach { entry -> FavoriteRow( - name = entry.shelf.name, + name = shelfDisplayName(entry.shelf), household = badgeFor(entry.householdId), onClick = { onOpenLocation(entry.householdId, entry.shelf.location_id) }, ) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/UndoOutcome.kt b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/UndoOutcome.kt new file mode 100644 index 0000000..d52ca2c --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/UndoOutcome.kt @@ -0,0 +1,21 @@ +package dev.scuttle.inventory.ui.hierarchy + +/** + * One-shot result of an undoDelete() call, consumed by the screen to show the + * matching localized snackbar (R.string.delete_undone / R.string.delete_undo_failed) + * — final review, ALSO FIX: both strings existed with zero usages, so a 409 from the + * restore endpoint (the batch was already restored elsewhere, or permanently removed + * past the undo window) silently fell through to a generic "Something went wrong." + * instead of the spec's message, and a successful undo had no confirmation at all. + * + * Deliberately NOT the pre-baked message string itself: undoDelete() runs in a + * ViewModel, which has no locale-correct way to resolve a string resource (this + * app's language switch is a per-Activity Configuration override — see + * MainActivity.attachBaseContext — invisible to the singleton Application context a + * ViewModel could otherwise inject). The screen's own Composable, which already has + * a correctly-localized [androidx.compose.ui.res.stringResource], turns this into text. + */ +enum class UndoOutcome { + SUCCESS, + FAILURE, +} diff --git a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt index d699f25..f59d78a 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt @@ -69,6 +69,7 @@ import dev.scuttle.inventory.ui.common.SnackbarErrorEffect import dev.scuttle.inventory.ui.common.orderByPosition import dev.scuttle.inventory.ui.common.storageTypeLabel import dev.scuttle.inventory.ui.hierarchy.DeleteStrategyDialog +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import dev.scuttle.inventory.ui.hierarchy.locationStrategyOptions import dev.scuttle.inventory.ui.theme.FrostCard @@ -394,6 +395,23 @@ fun AllStoragesScreen( } } + // The undo OUTCOME, as its own one-shot snackbar — distinct from the "deleted, + // [Undo]" snackbar above. A 409 here (already restored elsewhere, or past the + // undo window) used to fall through to a generic error; this shows the specific + // message instead (final review, ALSO FIX). + val undoneMessage = stringResource(R.string.delete_undone) + val undoFailedMessage = stringResource(R.string.delete_undo_failed) + LaunchedEffect(state.undoResult) { + val message = + when (state.undoResult) { + UndoOutcome.SUCCESS -> undoneMessage + UndoOutcome.FAILURE -> undoFailedMessage + null -> return@LaunchedEffect + } + snackbarHostState.showSnackbar(message) + viewModel.consumeUndoResult() + } + if (showHouseholdPicker) { HouseholdPickerSheet( households = state.entries.map { HouseholdOption(it.id, it.name) }, diff --git a/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt index 2f8406a..b046fe4 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt @@ -78,8 +78,10 @@ import dev.scuttle.inventory.R import dev.scuttle.inventory.data.dto.ShelfDto import dev.scuttle.inventory.ui.app.DrawerViewModel import dev.scuttle.inventory.ui.common.LiveStatusText +import dev.scuttle.inventory.ui.common.shelfDisplayName import dev.scuttle.inventory.ui.hierarchy.DeleteStrategyDialog import dev.scuttle.inventory.ui.hierarchy.EditableRow +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import dev.scuttle.inventory.ui.hierarchy.shelfStrategyOptions import dev.scuttle.inventory.ui.products.ProductsPane import dev.scuttle.inventory.ui.products.ProductsViewModel @@ -111,6 +113,20 @@ fun LocationDetailScreen( val currentShelfId = state.shelves.getOrNull(currentPage)?.id val nonSystemShelfCount = state.shelves.count { !it.is_system } + // The location's own name for the top-bar title (ALSO FIX, final review): this + // screen used to render the generic "Shelves" title always, even though the + // location itself became renamable this branch. drawerViewModel already holds + // every household's locations (it's what got the user here in the first + // place), so no extra network call is needed — just fall back to the generic + // title for the brief window before that data has loaded. + val drawerState by drawerViewModel.state.collectAsState() + val locationName = + drawerState.entries + .firstOrNull { it.id == householdId } + ?.locations + ?.firstOrNull { it.id == locationId } + ?.name + var showAddShelfSheet by rememberSaveable { mutableStateOf(false) } var showAddProductSheet by rememberSaveable { mutableStateOf(false) } var productsRefreshKey by remember { mutableIntStateOf(0) } @@ -141,7 +157,7 @@ fun LocationDetailScreen( if (state.editMode && state.selected.isNotEmpty()) { Text(stringResource(R.string.location_selected_count, state.selected.size)) } else { - Text(stringResource(R.string.location_shelves_title)) + Text(locationName ?: stringResource(R.string.location_shelves_title)) } }, navigationIcon = { @@ -269,7 +285,7 @@ fun LocationDetailScreen( ) { itemsIndexed(state.shelves, key = { _, shelf -> shelf.id }) { index, shelf -> EditableRow( - name = shelf.name, + name = shelfDisplayName(shelf), editMode = state.editMode, isSystem = shelf.is_system, selected = shelf.id in state.selected, @@ -307,7 +323,7 @@ fun LocationDetailScreen( // 1.4.1) and TalkBack announces ", has missing items" (W9). val warningCd = if (tabHasWarning) { - stringResource(R.string.location_shelf_missing_cd, shelf.name) + stringResource(R.string.location_shelf_missing_cd, shelfDisplayName(shelf)) } else { null } @@ -326,7 +342,7 @@ fun LocationDetailScreen( }, ) { Text( - shelf.name, + shelfDisplayName(shelf), color = if (tabHasWarning) { MaterialTheme.colorScheme.error @@ -406,6 +422,23 @@ fun LocationDetailScreen( } } + // The undo OUTCOME, as its own one-shot snackbar — distinct from the "deleted, + // [Undo]" snackbar above. A 409 here (already restored elsewhere, or past the + // undo window) used to fall through to a generic error; this shows the specific + // message instead (final review, ALSO FIX). + val undoneMessage = stringResource(R.string.delete_undone) + val undoFailedMessage = stringResource(R.string.delete_undo_failed) + LaunchedEffect(state.undoResult) { + val message = + when (state.undoResult) { + UndoOutcome.SUCCESS -> undoneMessage + UndoOutcome.FAILURE -> undoFailedMessage + null -> return@LaunchedEffect + } + snackbarHostState.showSnackbar(message) + shelvesViewModel.consumeUndoResult() + } + renamingShelf?.let { shelf -> AlertDialog( onDismissRequest = { renamingShelf = null }, diff --git a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsPane.kt b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsPane.kt index 31d077f..1e5cb87 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsPane.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsPane.kt @@ -59,6 +59,7 @@ import dev.scuttle.inventory.data.dto.ProductDto import dev.scuttle.inventory.ui.common.SnackbarErrorEffect import dev.scuttle.inventory.ui.common.SortMenu import dev.scuttle.inventory.ui.common.SortOrder +import dev.scuttle.inventory.ui.common.shelfDisplayName import dev.scuttle.inventory.ui.theme.FrostCard /** @@ -395,7 +396,10 @@ fun ProductsPane( else -> state.moveTargets.forEach { target -> TextButton(onClick = { viewModel.confirmMove(target.shelfId) }) { - Text(target.label) + Text( + "${target.locationName} › " + + shelfDisplayName(target.shelfName, target.isSystemShelf), + ) } } } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt index 73de972..1068443 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt @@ -22,9 +22,18 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +/** + * A candidate shelf a product could move to. Carries [locationName]/[shelfName] and + * [isSystemShelf] raw — rather than a pre-baked label — because this is built inside a + * ViewModel, which has no locale-correct way to localize the system ("Unsorted") shelf's + * label itself (final review, ALSO FIX). The composable that renders this list builds + * the displayed label via `shelfDisplayName(shelfName, isSystemShelf)`. + */ data class MoveTarget( val shelfId: Long, - val label: String, + val locationName: String, + val shelfName: String, + val isSystemShelf: Boolean, ) sealed interface ScanResult { @@ -251,7 +260,9 @@ class ProductsViewModel val targets = mutableListOf() for (location in locationRepository.list(h)) { for (shelf in shelfRepository.list(h, location.id)) { - if (shelf.id != s) targets.add(MoveTarget(shelf.id, "${location.name} › ${shelf.name}")) + if (shelf.id != s) { + targets.add(MoveTarget(shelf.id, location.name, shelf.name, shelf.is_system)) + } } } targets diff --git a/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt index ea02239..6fe1722 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt @@ -14,6 +14,7 @@ import dev.scuttle.inventory.data.shelf.ShelfRepository import dev.scuttle.inventory.ui.common.orderByPosition import dev.scuttle.inventory.ui.hierarchy.DeletePlan import dev.scuttle.inventory.ui.hierarchy.MoveTarget +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -48,6 +49,12 @@ data class ShelvesUiState( val moveTargets: List = emptyList(), /** The batch just deleted, for the Undo snackbar. Cleared once consumed. */ val lastBatchId: String? = null, + /** + * One-shot result of the last undoDelete() call — null while none is pending. + * The screen turns this into the matching localized snackbar (delete_undone / + * delete_undo_failed) and calls [ShelvesViewModel.consumeUndoResult]. + */ + val undoResult: UndoOutcome? = null, ) // The method count is the required surface for this screen (edit mode, reorder, @@ -368,15 +375,27 @@ class ShelvesViewModel val h = householdId ?: return val batchId = _state.value.lastBatchId ?: return launchLoading { - restoreRepository.restore(h, batchId) - _state.update { it.copy(lastBatchId = null) } - refresh() - hierarchyStore.refresh() + // A 409 here means the batch was already restored (another device, + // a double-tap) or permanently removed past the undo window — NOT + // a generic failure, so it does not rethrow into launchLoading's + // catch-all (state.error would otherwise show "Something went + // wrong." instead of the specific message the screen shows below). + val result = runCatching { restoreRepository.restore(h, batchId) } + result.exceptionOrNull()?.let { if (it is CancellationException) throw it } + if (result.isSuccess) { + _state.update { it.copy(lastBatchId = null, undoResult = UndoOutcome.SUCCESS) } + refresh() + hierarchyStore.refresh() + } else { + _state.update { it.copy(undoResult = UndoOutcome.FAILURE) } + } } } fun consumeLastBatch() = _state.update { it.copy(lastBatchId = null) } + fun consumeUndoResult() = _state.update { it.copy(undoResult = null) } + /** * The one hierarchy ordering rule (orderByPosition) plus the system-shelf * exception: Unsorted always renders last, regardless of its own position, diff --git a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt index 8eaf38b..0ef93db 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt @@ -69,6 +69,7 @@ import dev.scuttle.inventory.ui.common.ErrorRetry import dev.scuttle.inventory.ui.common.storageTypeLabel import dev.scuttle.inventory.ui.hierarchy.DeleteStrategyDialog import dev.scuttle.inventory.ui.hierarchy.EditableRow +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import dev.scuttle.inventory.ui.hierarchy.locationStrategyOptions import dev.scuttle.inventory.ui.theme.FrostCard @@ -315,6 +316,23 @@ fun StorageOverviewScreen( } } + // The undo OUTCOME, as its own one-shot snackbar — distinct from the "deleted, + // [Undo]" snackbar above. A 409 here (already restored elsewhere, or past the + // undo window) used to fall through to a generic error; this shows the specific + // message instead (final review, ALSO FIX). + val undoneMessage = stringResource(R.string.delete_undone) + val undoFailedMessage = stringResource(R.string.delete_undo_failed) + LaunchedEffect(state.undoResult) { + val message = + when (state.undoResult) { + UndoOutcome.SUCCESS -> undoneMessage + UndoOutcome.FAILURE -> undoFailedMessage + null -> return@LaunchedEffect + } + snackbarHostState.showSnackbar(message) + viewModel.consumeUndoResult() + } + renamingLocation?.let { location -> ModalBottomSheet( onDismissRequest = { renamingLocation = null }, diff --git a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt index 1da6111..cdaed44 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewViewModel.kt @@ -13,6 +13,7 @@ import dev.scuttle.inventory.data.location.LocationRepository import dev.scuttle.inventory.ui.common.orderByPosition import dev.scuttle.inventory.ui.hierarchy.DeletePlan import dev.scuttle.inventory.ui.hierarchy.MoveTarget +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -49,6 +50,12 @@ data class StorageOverviewUiState( val moveTargets: List = emptyList(), /** The batch just deleted, for the Undo snackbar. Cleared once consumed. */ val lastBatchId: String? = null, + /** + * One-shot result of the last undoDelete() call — null while none is pending. + * The screen turns this into the matching localized snackbar (delete_undone / + * delete_undo_failed) and calls [StorageOverviewViewModel.consumeUndoResult]. + */ + val undoResult: UndoOutcome? = null, ) // The method count is the required surface for this screen (edit mode, reorder, @@ -345,15 +352,27 @@ class StorageOverviewViewModel val id = householdId ?: return val batchId = _state.value.lastBatchId ?: return launchLoading { - restoreRepository.restore(id, batchId) - _state.update { it.copy(lastBatchId = null) } - refresh() - hierarchyStore.refresh() + // A 409 here means the batch was already restored (another device, + // a double-tap) or permanently removed past the undo window — NOT + // a generic failure, so it does not rethrow into launchLoading's + // catch-all (state.error would otherwise show "Something went + // wrong." instead of the specific message the screen shows below). + val result = runCatching { restoreRepository.restore(id, batchId) } + result.exceptionOrNull()?.let { if (it is CancellationException) throw it } + if (result.isSuccess) { + _state.update { it.copy(lastBatchId = null, undoResult = UndoOutcome.SUCCESS) } + refresh() + hierarchyStore.refresh() + } else { + _state.update { it.copy(undoResult = UndoOutcome.FAILURE) } + } } } fun consumeLastBatch() = _state.update { it.copy(lastBatchId = null) } + fun consumeUndoResult() = _state.update { it.copy(undoResult = null) } + /** The one hierarchy ordering rule (Task 2): manual position, name tie-break. */ private fun orderLocations(locations: List): List = orderByPosition(locations, { it.position }, { it.name }) diff --git a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt index 2e763e3..42daee5 100644 --- a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt @@ -15,6 +15,7 @@ import dev.scuttle.inventory.data.product.ProductEdit import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.shelf.ShelfRepository import dev.scuttle.inventory.ui.app.DrawerViewModel +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -640,6 +641,50 @@ class DrawerViewModelTest { assertEquals(1L, restore.lastHouseholdId) assertEquals(batchId, restore.lastBatchId) assertNull(vm.state.value.lastBatchId) + // ALSO FIX (final review): a successful undo sets undoResult so the + // screen can show the "Restored." snackbar (delete_undone). + assertEquals(UndoOutcome.SUCCESS, vm.state.value.undoResult) + } + + @Test + fun a_failed_undo_sets_the_failure_result_instead_of_a_generic_action_error() = + runTest { + // ALSO FIX (final review): a 409 from the restore endpoint (already + // restored elsewhere, or past the undo window) used to fall through to + // the generic actionError/"Failed to undo delete." — undoResult now + // carries the specific outcome instead, and actionError stays unset + // so the screen doesn't show BOTH messages. + val repo = FakeLocationRepository(mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge")))) + val restore = FakeRestoreRepository() + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo, restore) + vm.requestDelete(householdId = 1, locationId = 10) + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + restore.fail = true + + vm.undoDelete() + + assertEquals(UndoOutcome.FAILURE, vm.state.value.undoResult) + assertNull(vm.actionError.value) + // The batch id survives a failed undo — nothing was actually restored. + assertNotNull(vm.state.value.lastBatchId) + } + + @Test + fun consume_undo_result_clears_it() = + runTest { + val repo = FakeLocationRepository(mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge")))) + val restore = FakeRestoreRepository() + val (store, _) = makeStore(households = listOf(HouseholdDto(1, "Home", "AAAA")), locationRepo = repo) + val vm = viewModel(store, repo, restore) + vm.requestDelete(householdId = 1, locationId = 10) + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + vm.undoDelete() + assertNotNull(vm.state.value.undoResult) + + vm.consumeUndoResult() + + assertNull(vm.state.value.undoResult) } @Test diff --git a/app/src/test/java/dev/scuttle/inventory/LocationTitleWiringTest.kt b/app/src/test/java/dev/scuttle/inventory/LocationTitleWiringTest.kt new file mode 100644 index 0000000..009eea9 --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/LocationTitleWiringTest.kt @@ -0,0 +1,51 @@ +package dev.scuttle.inventory + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * ALSO FIX (final review): LocationDetailScreen's top bar used to always render the + * generic "Shelves" title (R.string.location_shelves_title), even though the location + * itself became renamable this branch — the spec requires the location's actual name. + * Not expressible as a normal Compose/ViewModel unit test (no Robolectric/compose-ui-test + * on this module's JVM classpath — see NavigationWiringTest's own doc), so this is the + * same cheapest-check-that-still-discriminates source scan: it fails if the title stops + * rendering the location's own name. + */ +class LocationTitleWiringTest { + private fun read(relativePath: String): String { + val file = File(findAppModuleRoot(), "src/main/java/dev/scuttle/inventory/$relativePath") + check(file.exists()) { "Expected source file not found: ${file.absolutePath}" } + return file.readText() + } + + private fun findAppModuleRoot(): File { + var dir: File? = File(".").absoluteFile + while (dir != null) { + if (File(dir, "src/main/java/dev/scuttle/inventory/MainActivity.kt").exists()) return dir + if (File(dir, "app/src/main/java/dev/scuttle/inventory/MainActivity.kt").exists()) { + return File(dir, "app") + } + dir = dir.parentFile + } + error("Could not locate the app module's src/main from working dir ${File(".").absolutePath}") + } + + @Test + fun `LocationDetailScreen's top bar title renders the location's own name`() { + val src = read("ui/location/LocationDetailScreen.kt") + + assertTrue( + "LocationDetailScreen no longer derives a locationName from drawerViewModel's " + + "state — the top bar would have nothing but the generic title to fall back to.", + src.contains("val locationName ="), + ) + assertTrue( + "LocationDetailScreen's top bar title no longer renders locationName — it " + + "regressed to always showing the generic \"Shelves\" title even though the " + + "location is renamable.", + src.contains("Text(locationName ?: stringResource(R.string.location_shelves_title))"), + ) + } +} diff --git a/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt index ae3894e..e2ff5a0 100644 --- a/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt @@ -303,7 +303,12 @@ class ProductsViewModelTest { vm.startMove(productId = 1) val targets = vm.state.value.moveTargets assertEquals(1, targets.size) // current shelf (1) excluded, only shelf 2 remains - assertEquals("Chest › Bottom", targets.first().label) + // Raw fields, not a pre-baked label: the ViewModel has no locale-correct + // way to localize a system shelf's name itself (final review, ALSO FIX) + // — the composable that renders this list does that at render time. + assertEquals("Chest", targets.first().locationName) + assertEquals("Bottom", targets.first().shelfName) + assertFalse(targets.first().isSystemShelf) vm.confirmMove(targetShelfId = targets.first().shelfId) @@ -315,6 +320,38 @@ class ProductsViewModelTest { assertEquals(null, vm.state.value.movingProductId) } + @Test + fun start_move_flags_the_unsorted_shelf_as_a_system_target() = + runTest { + // ALSO FIX (final review): the Unsorted shelf is a legitimate move target + // (moving a product off its current shelf into the household's holding + // shelf), so it must appear in this list — but MoveTarget must carry + // isSystemShelf=true for it so the composable renders the LOCALIZED + // "Unsorted" label instead of the server's raw literal name. + val products = FakeProductRepository().apply { items.add(ProductDto(1, "Peas", 2, 1)) } + val locations = FakeLocationRepository(listOf(LocationDto(10, "Chest", "freezer"))) + val shelves = + FakeShelfRepository( + mapOf( + 10L to + listOf( + ShelfDto(1, "Top", 0, 10L), + ShelfDto(2, "Unsorted", 1, 10L, is_system = true), + ), + ), + ) + val vm = viewModel(products, locations, shelves) + vm.load(householdId = 1, shelfId = 1) + + vm.startMove(productId = 1) + + val target = + vm.state.value.moveTargets + .first { it.shelfId == 2L } + assertTrue(target.isSystemShelf) + assertEquals("Unsorted", target.shelfName) + } + @Test fun update_replaces_product_in_list() = runTest { diff --git a/app/src/test/java/dev/scuttle/inventory/ShelfDisplayNameTest.kt b/app/src/test/java/dev/scuttle/inventory/ShelfDisplayNameTest.kt new file mode 100644 index 0000000..c89a30a --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/ShelfDisplayNameTest.kt @@ -0,0 +1,25 @@ +package dev.scuttle.inventory + +import dev.scuttle.inventory.ui.common.shelfDisplayNameRes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Guards the system-shelf localization mapping (final review, ALSO FIX): + * ShelfDto.name's own doc comment always claimed "the client localises the label + * off [is_system]" — nothing actually wired that until this — mirrors + * StorageTypeLabelTest's split between the pure resource lookup and the untested + * composable wrapper (no Robolectric/compose-ui-test on this module's JVM classpath). + */ +class ShelfDisplayNameTest { + @Test + fun a_system_shelf_maps_to_the_unsorted_resource() { + assertEquals(R.string.shelf_unsorted, shelfDisplayNameRes(isSystem = true)) + } + + @Test + fun a_normal_shelf_has_no_override_resource() { + assertNull(shelfDisplayNameRes(isSystem = false)) + } +} diff --git a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt index 670de04..7722567 100644 --- a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt @@ -8,6 +8,7 @@ import dev.scuttle.inventory.data.hierarchy.ShelfDeletion import dev.scuttle.inventory.data.household.HouseholdRepository import dev.scuttle.inventory.data.settings.ShelfViewStore import dev.scuttle.inventory.data.shelf.ShelfRepository +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import dev.scuttle.inventory.ui.shelves.ShelvesViewModel import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest @@ -128,10 +129,15 @@ class ShelvesViewModelTest { var lastBatchId: String? = null var restoreCalls = 0 + // Simulates a 409 from the restore endpoint (already restored elsewhere, + // or permanently removed past the undo window). + var fail = false + override suspend fun restore( householdId: Long, batchId: String, ): Int { + if (fail) throw IOException("409: already restored") lastHouseholdId = householdId lastBatchId = batchId restoreCalls++ @@ -793,6 +799,54 @@ class ShelvesViewModelTest { assertEquals(1L, restore.lastHouseholdId) assertEquals(batchId, restore.lastBatchId) assertNull(vm.state.value.lastBatchId) + // ALSO FIX (final review): a successful undo sets undoResult so the + // screen can show the "Restored." snackbar (delete_undone). + assertEquals(UndoOutcome.SUCCESS, vm.state.value.undoResult) + } + + @Test + fun a_failed_undo_sets_the_failure_result_instead_of_a_generic_error() = + runTest { + // ALSO FIX (final review): a 409 from the restore endpoint (already + // restored elsewhere, or past the undo window) used to fall through to + // launchLoading's generic "Something went wrong." — undoResult now + // carries the specific outcome instead, and the generic error stays + // unset so the screen doesn't show BOTH messages. + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val restore = FakeRestoreRepository() + val vm = viewModel(repo, restoreRepository = restore) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + vm.confirmDelete(ShelfDeleteStrategy.DELETE_PRODUCTS, targetId = null) + restore.fail = true + + vm.undoDelete() + + assertEquals(UndoOutcome.FAILURE, vm.state.value.undoResult) + assertNull(vm.state.value.error) + // The batch id survives a failed undo — nothing was actually restored. + assertNotNull(vm.state.value.lastBatchId) + } + + @Test + fun consume_undo_result_clears_it() = + runTest { + val repo = FakeShelfRepository().apply { items.add(ShelfDto(1, "Top", 0, 1L)) } + val restore = FakeRestoreRepository() + val vm = viewModel(repo, restoreRepository = restore) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + vm.confirmDelete(ShelfDeleteStrategy.DELETE_PRODUCTS, targetId = null) + vm.undoDelete() + assertNotNull(vm.state.value.undoResult) + + vm.consumeUndoResult() + + assertNull(vm.state.value.undoResult) } @Test diff --git a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt index db0c335..ed4c559 100644 --- a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt @@ -8,6 +8,7 @@ import dev.scuttle.inventory.data.hierarchy.LocationDeletion import dev.scuttle.inventory.data.hierarchy.RestoreRepository import dev.scuttle.inventory.data.household.HouseholdRepository import dev.scuttle.inventory.data.location.LocationRepository +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import dev.scuttle.inventory.ui.storage.StorageOverviewViewModel import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest @@ -119,10 +120,15 @@ class StorageOverviewViewModelTest { var lastBatchId: String? = null var restoreCalls = 0 + // Simulates a 409 from the restore endpoint (already restored elsewhere, + // or permanently removed past the undo window). + var fail = false + override suspend fun restore( householdId: Long, batchId: String, ): Int { + if (fail) throw IOException("409: already restored") lastHouseholdId = householdId lastBatchId = batchId restoreCalls++ @@ -792,6 +798,54 @@ class StorageOverviewViewModelTest { assertEquals(1L, restore.lastHouseholdId) assertEquals(batchId, restore.lastBatchId) assertNull(vm.state.value.lastBatchId) + // ALSO FIX (final review): a successful undo sets undoResult so the + // screen can show the "Restored." snackbar (delete_undone). + assertEquals(UndoOutcome.SUCCESS, vm.state.value.undoResult) + } + + @Test + fun a_failed_undo_sets_the_failure_result_instead_of_a_generic_error() = + runTest { + // ALSO FIX (final review): a 409 from the restore endpoint (already + // restored elsewhere, or past the undo window) used to fall through to + // launchLoading's generic "Something went wrong." — undoResult now + // carries the specific outcome instead, and the generic error stays + // unset so the screen doesn't show BOTH messages. + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val restore = FakeRestoreRepository() + val vm = viewModel(repo, restoreRepository = restore) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + restore.fail = true + + vm.undoDelete() + + assertEquals(UndoOutcome.FAILURE, vm.state.value.undoResult) + assertNull(vm.state.value.error) + // The batch id survives a failed undo — nothing was actually restored. + assertNotNull(vm.state.value.lastBatchId) + } + + @Test + fun consume_undo_result_clears_it() = + runTest { + val repo = FakeLocationRepository().apply { items.add(LocationDto(1, "Top", "freezer", 0)) } + val restore = FakeRestoreRepository() + val vm = viewModel(repo, restoreRepository = restore) + vm.load(householdId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.requestDelete() + vm.confirmDelete(LocationDeleteStrategy.DELETE_CONTENTS, targetId = null) + vm.undoDelete() + assertNotNull(vm.state.value.undoResult) + + vm.consumeUndoResult() + + assertNull(vm.state.value.undoResult) } @Test diff --git a/app/src/test/java/dev/scuttle/inventory/StringResourceUsageTest.kt b/app/src/test/java/dev/scuttle/inventory/StringResourceUsageTest.kt new file mode 100644 index 0000000..58b3ab3 --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/StringResourceUsageTest.kt @@ -0,0 +1,80 @@ +package dev.scuttle.inventory + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * ALSO FIX (final review): `shelf_unsorted`, `delete_undone` and `delete_undo_failed` + * each shipped as a string resource and then never got wired to the requirement it + * belonged to — an unused string breaks no gate, so nothing noticed until a reviewer + * went looking by hand. This is the cheap check that would have caught all three for + * free: every string declared in `values/strings.xml` must have at least one reference + * somewhere in this module's own Kotlin or XML source (a Kotlin `R.string.` or an + * XML `@string/`). + * + * Deliberately a plain source scan, not a Compose/ViewModel test — mirrors + * NavigationWiringTest's own reasoning: it's the cheapest check that still + * discriminates, and works without Robolectric/compose-ui-test on this module's JVM + * classpath. + */ +class StringResourceUsageTest { + private val appRoot: File by lazy { findAppModuleRoot() } + + private fun findAppModuleRoot(): File { + var dir: File? = File(".").absoluteFile + while (dir != null) { + if (File(dir, "src/main/java/dev/scuttle/inventory/MainActivity.kt").exists()) return dir + if (File(dir, "app/src/main/java/dev/scuttle/inventory/MainActivity.kt").exists()) { + return File(dir, "app") + } + dir = dir.parentFile + } + error("Could not locate the app module's src/main from working dir ${File(".").absolutePath}") + } + + /** + * Names referenced only dynamically (assembled from a variable at runtime rather + * than written as a literal `R.string.` / `@string/` anywhere), so a + * text scan can never find them — empty today. Keep it that way unless you can + * point to the exact dynamic-construction call site a name comes from; this + * allowlist is exactly the kind of thing that quietly hides the next dropped + * requirement if it's used as a shortcut instead. + */ + private val allowlist = emptySet() + + private fun definedStringNames(): List { + val stringsXml = File(appRoot, "src/main/res/values/strings.xml") + check(stringsXml.exists()) { "Expected ${stringsXml.absolutePath} to exist" } + return Regex("""""") + .findAll(stringsXml.readText()) + .map { it.groupValues[1] } + .toList() + } + + /** Every .kt/.xml file under src, EXCLUDING strings.xml itself — its own + * `` declaration line is not a USE of the resource, and would + * trivially "reference" every single name if left in. */ + private fun allSourceText(): String = + File(appRoot, "src") + .walkTopDown() + .filter { it.isFile && (it.extension == "kt" || it.extension == "xml") } + .filterNot { it.name == "strings.xml" } + .joinToString("\n") { it.readText() } + + @Test + fun `every declared string resource has at least one reference`() { + val haystack = allSourceText() + val unreferenced = + definedStringNames().filterNot { name -> + val referencePattern = Regex("""(R\.string\.|@string/)${Regex.escape(name)}\b""") + name in allowlist || referencePattern.containsMatchIn(haystack) + } + assertTrue( + "These string resources are declared in values/strings.xml but never referenced from " + + "any Kotlin or XML source (R.string. / @string/) — an unused string " + + "breaks no gate, so it silently ships a dropped requirement: $unreferenced", + unreferenced.isEmpty(), + ) + } +} From 183d9c7df9b44090590b3051dd01f4327298b88e Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 05:21:59 +0200 Subject: [PATCH 30/40] fix: stop the bottom bar rendering over the scanner in ADD mode (Minor 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes.SCANNER ("scanner?mode={mode}") is one route pattern shared by two callers: LOOKUP (the bottom-bar Scan tab itself) and ADD (opened from a shelf screen, with no bottom bar underneath it). NavController's own destination.route is the bare pattern for both, so the bar's `currentRoute == tab.route` check couldn't tell them apart — the bar resurfaced over the camera in ADD mode, with Scan shown selected. Adds scannerRouteIsTheBottomBarTab(mode), a pure function (mirrors scanDeliveryActionFor's split between pure decision and NavController side effect) resolved from the back stack entry's own `mode` argument, and uses it to gate both the bar's visibility and the Scan tab's selected state specifically for the SCANNER route. --- .../dev/scuttle/inventory/MainActivity.kt | 53 +++++++++++++++++-- .../inventory/ScanDeliveryActionTest.kt | 32 +++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt index b5a2b3b..998e69f 100644 --- a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt +++ b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt @@ -128,7 +128,9 @@ private data class BottomTab( // decide selection/bottom-bar-visibility) because Scan's destination is // parameterized (Routes.SCANNER = "scanner?mode={mode}"): [route] has to stay // the bare pattern for that match to work, while the tap needs a concrete - // `mode` value baked in. + // `mode` value baked in. For Scan specifically, matching [route] alone is NOT + // enough to decide selection/visibility either — ADD shares the same pattern + // (Minor 9, final review) — see [scannerRouteIsTheBottomBarTab]. val navigateTo: String = route, ) @@ -191,6 +193,21 @@ fun scanDeliveryActionFor( ScannerMode.LOOKUP -> ScanDeliveryAction.NavigateToSearch(code) } +/** + * Whether the current Routes.SCANNER destination IS the bottom-bar Scan tab's own + * destination — true only for LOOKUP, false for ADD and for a null mode (Minor 9, + * final review). Routes.SCANNER is one shared route PATTERN ("scanner?mode={mode}") + * for two callers with different bottom-bar expectations: LOOKUP is the Scan tab + * itself (bar shows, Scan selected); ADD is opened from a shelf screen with no bottom + * bar underneath it (bar must not reappear over the camera, Scan must not show + * selected). NavController's own `destination.route` is always the bare pattern, + * identical for both, so the caller must resolve [mode] from the back stack entry's + * own `mode` argument first — see [ScannerMode.from]. Pure so it's unit-testable + * without a NavController, mirroring [scanDeliveryActionFor]'s split between pure + * decision and NavController side effect. + */ +fun scannerRouteIsTheBottomBarTab(mode: ScannerMode?): Boolean = mode == ScannerMode.LOOKUP + private object Routes { const val AUTH = "auth" const val FORGOT_PASSWORD = "forgot-password" @@ -322,6 +339,21 @@ private fun InventoryNavHost( val backStackEntry by navController.currentBackStackEntryAsState() val currentRoute = backStackEntry?.destination?.route + // Routes.SCANNER ("scanner?mode={mode}") is one route PATTERN shared by two + // callers with very different bottom-bar expectations (Minor 9, final review): + // LOOKUP is the bottom-bar Scan tab itself — the bar should show, with Scan + // selected. ADD is opened from a shelf screen with no bottom bar underneath + // it — the bar must not reappear over the camera, and Scan must not show + // selected (that navigation has nothing to do with the Scan tab). currentRoute + // alone can't tell these apart: NavController's own destination.route is + // always the bare pattern, identical for both modes. The concrete `mode` + // argument is what actually differs — read it from the back stack entry. + val currentScannerMode = + if (currentRoute == Routes.SCANNER) { + ScannerMode.from(backStackEntry?.arguments?.getString("mode")) + } else { + null + } val drawerUi by drawerViewModel.state.collectAsState() // Non-null while the Scan tab's LOOKUP mode is waiting on a household pick // (Blocker 2, final review): the scanned code, held until the picker below @@ -352,11 +384,26 @@ private fun InventoryNavHost( Scaffold( contentWindowInsets = WindowInsets(0), bottomBar = { - if (bottomTabs.any { it.route == currentRoute }) { + // On the SCANNER route specifically, only the LOOKUP mode is the + // bottom-bar tab's own destination — ADD (opened from a shelf screen) + // must not resurrect the bar over the camera. Every other tab keeps + // the plain route-pattern match. + val showBottomBar = + if (currentRoute == Routes.SCANNER) { + scannerRouteIsTheBottomBarTab(currentScannerMode) + } else { + bottomTabs.any { it.route == currentRoute } + } + if (showBottomBar) { NavigationBar { bottomTabs.forEach { tab -> NavigationBarItem( - selected = currentRoute == tab.route, + selected = + if (tab.route == Routes.SCANNER) { + scannerRouteIsTheBottomBarTab(currentScannerMode) + } else { + currentRoute == tab.route + }, onClick = { navController.navigate(tab.navigateTo, tabNavOptions) }, icon = { if (tab.key == "missing-items" && drawerUi.missingItemCount > 0) { diff --git a/app/src/test/java/dev/scuttle/inventory/ScanDeliveryActionTest.kt b/app/src/test/java/dev/scuttle/inventory/ScanDeliveryActionTest.kt index 76b9c51..6c7720f 100644 --- a/app/src/test/java/dev/scuttle/inventory/ScanDeliveryActionTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ScanDeliveryActionTest.kt @@ -1,6 +1,8 @@ package dev.scuttle.inventory import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -67,4 +69,34 @@ class ScanDeliveryActionTest { fun from_defaults_to_add_when_unrecognized() { assertEquals(ScannerMode.ADD, ScannerMode.from("bogus")) } + + // --- scannerRouteIsTheBottomBarTab (Minor 9, final review) ----------------- + // + // Routes.SCANNER ("scanner?mode={mode}") is one route PATTERN for two callers: + // NavController's own destination.route is identical for both, so the bottom + // bar's old `currentRoute == tab.route` check couldn't tell LOOKUP (the Scan + // tab's own destination) apart from ADD (opened from a shelf screen, no bottom + // bar underneath it) — the bar resurfaced over the camera in ADD mode, with + // Scan shown selected. This is the pure decision the bar's visibility/selected + // checks are now built from. + + @Test + fun lookup_mode_is_the_bottom_bar_tab() { + assertTrue(scannerRouteIsTheBottomBarTab(ScannerMode.LOOKUP)) + } + + @Test + fun add_mode_is_not_the_bottom_bar_tab() { + // The exact regression: ADD (opened from LocationDetailScreen's scan FAB) + // must NOT be treated as the Scan tab's own destination. + assertFalse(scannerRouteIsTheBottomBarTab(ScannerMode.ADD)) + } + + @Test + fun a_null_mode_is_not_the_bottom_bar_tab() { + // Null means "not currently on the SCANNER route at all" (the caller only + // resolves a mode when currentRoute == Routes.SCANNER) — must not default + // to true and show the bar on every unrelated screen. + assertFalse(scannerRouteIsTheBottomBarTab(null)) + } } From 331b98ad7cefc33f2c7db6373bd8abb56a5326f0 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 05:23:13 +0200 Subject: [PATCH 31/40] docs: correct CLAUDE.md's search-reachability claim and note the new unused-string gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md claimed Search was reachable via a top-bar icon on every household-scoped screen — Dashboard, Home and Missing items aren't actually household-scoped, and their icon (like the Scan tab) now opens a household picker when the account has more than one household (Blocker 2). Also documents scannerRouteIsTheBottomBarTab (Minor 9) and StringResourceUsageTest as a Testing-lessons entry, matching the existing section's format for a failure mode this branch hit. --- CLAUDE.md | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f9e7415..2f448ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,15 +103,28 @@ Navigation: Household → Storage overview → Shelves (tabs *or* list) → Prod bottom bar (**Dashboard / Storage / Scan / Missing / More**) is the app's only navigation surface — there is no drawer. **Households** left the bar: it's a tenant-management screen reached under **More**, which absorbed Settings *and* -households. **Search** lost its tab; it's reached via a top-bar icon on every -household-scoped screen (Dashboard, Storage overview, Home, Missing items). Households -and Search each keep their own small gear-icon shortcut back to Settings, since neither -sits on the bottom bar itself — that's the only place a gear icon survives, not "every -screen" as before this branch. The centre **Scan** tab always opens the scanner in -LOOKUP mode (hands the scanned code to Search, pre-filled and already run); opening the -scanner from a shelves screen is ADD mode instead — same route, a `ScannerMode` argument -picks the behavior and delivers the code back to that screen via `savedStateHandle` -rather than to Search (`MainActivity.kt`: `ScannerMode`, `scanDeliveryActionFor`). +households. **Search** lost its tab; it's reached via a top-bar icon on Dashboard, Storage overview, +Home and Missing items. Storage overview is already scoped to one household (its route +carries `householdId`), so its icon just opens Search directly. Dashboard, Home and +Missing items are **not** household-scoped — their icon (and Dashboard's products stat +card) opens Search directly only when the account has exactly one household; with more +than one it opens a shared household picker (`ui/common/HouseholdPickerSheet.kt`) first, +same as the centre **Scan** tab's LOOKUP mode below. (Final review, 2026-07-14: every one +of these hard-coded the *first* household instead, making a second household's search +reachable only by drilling Home → that household's own "+" icon → Storage overview → its +search icon — don't reintroduce a bare `entries.firstOrNull()`.) Households and Search +each keep their own small gear-icon shortcut back to Settings, since neither sits on the +bottom bar itself — that's the only place a gear icon survives, not "every screen" as +before this branch. The centre **Scan** tab always opens the scanner in LOOKUP mode +(hands the scanned code to Search, pre-filled and already run, via the same +household-count/picker rule above — it has genuinely no household context of its own); +opening the scanner from a shelves screen is ADD mode instead — same route +(`Routes.SCANNER`), a `ScannerMode` argument picks the behavior and delivers the code +back to that screen via `savedStateHandle` rather than to Search (`MainActivity.kt`: +`ScannerMode`, `scanDeliveryActionFor`). Because both modes share one route, the bottom +bar's own visibility/selected state for the Scan tab is resolved from the *mode* +argument, not the route alone — `scannerRouteIsTheBottomBarTab()`; matching the bare +route would show the bar (with Scan selected) over ADD mode's camera too. **Editing the hierarchy** lives behind a pencil (edit mode) on the households, locations and shelves lists — never inside Settings/More itself. On locations and shelves, edit @@ -172,6 +185,15 @@ repo's guardrail in sync with `inventory-laravel/CLAUDE.md`'s matching bullet. `ktlintCheck` does (`ktlintAndroidTestSourceSetCheck` genuinely runs; `detekt`'s own file-count metrics exclude every androidTest file). Don't treat a green `detekt` as having checked flow-test code. +- **An unused string resource breaks no gate — that's exactly how `shelf_unsorted`, + `delete_undone` and `delete_undo_failed` shipped dead** (final review, 2026-07-14): + each was added alongside a doc comment or a spec line promising it was wired up, and + nothing ever called `stringResource()` on it. `StringResourceUsageTest` (JVM suite) + now fails if any `values/strings.xml` entry has zero `R.string.` / `@string/` + references anywhere in the module — the cheap check that would have caught all three + for free. Don't add a string resource in the same commit as "TODO: wire this up + later" — wire it in the same commit, or the gate is the only thing that will ever + notice it wasn't. ## Measuring type or layout on a device — read the device's settings first Android rewrites text before your measurement ever sees it. **Check these before trusting From 35d5770437c7beeb646ae54a0889970be56d6e69 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 10:13:16 +0200 Subject: [PATCH 32/40] fix: wire Undo for product delete, the last destructive action without one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProductController::destroy() (server) mints a batch-of-one deletion_batch_id and returns it specifically so the client can offer Undo, but ProductApi.delete() declared no return type, so the body — and the batch id inside it — was silently discarded. Product delete is the app's single most frequent destructive action and was the only one with no Undo. - ProductApi.delete() now returns ProductDeleteResponse (deletion_batch_id, snake_case to match the wire exactly); ProductRepository.delete() unwraps it to a batch id. - Both call sites now capture the batch id and offer Undo the same way Shelves/Storage/Drawer already do (RestoreRepository + a snackbar action): ProductsViewModel/ProductsPane (swipe-to-delete) and ProductDetailViewModel/ProductDetailScreen (detail screen's delete button). Confirm dialogs are unchanged; Undo is additive. - ProductDetailScreen navigates away on delete (unlike the others, which stay put) — sequenced the deleted/undo/outcome snackbars into one LaunchedEffect so onBack() only fires once that's fully resolved, instead of risking a mid-flight cancellation of undoDelete()'s coroutine. - Reused the existing delete_undo/delete_undone/delete_undo_failed strings; added product_deleted (EN+NL). - Fixed DeleteProductFlowTest's mock (the real endpoint returns 200 + a JSON body, not an empty 204) and updated 8 JVM fakes for the new ProductRepository.delete(): String signature. - Added ProductDeleteResponseSerializationTest pinning the real response body decodes correctly (and that a camelCase key does not). --- .../assets/fixtures/product_deleted.json | 1 + .../inventory/flow/DeleteProductFlowTest.kt | 6 +- .../scuttle/inventory/data/api/ProductApi.kt | 6 +- .../scuttle/inventory/data/dto/ProductDtos.kt | 13 ++ .../data/product/ProductRepository.kt | 8 +- .../data/product/ProductRepositoryImpl.kt | 5 +- .../ui/products/ProductDetailScreen.kt | 43 ++++- .../ui/products/ProductDetailViewModel.kt | 40 ++++- .../inventory/ui/products/ProductsPane.kt | 41 +++++ .../ui/products/ProductsViewModel.kt | 46 +++++- app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../inventory/DashboardViewModelTest.kt | 2 +- .../scuttle/inventory/DrawerViewModelTest.kt | 2 +- .../scuttle/inventory/HierarchyStoreTest.kt | 2 +- .../inventory/MissingItemsViewModelTest.kt | 2 +- .../ProductDeleteResponseSerializationTest.kt | 51 ++++++ .../inventory/ProductDetailViewModelTest.kt | 139 ++++++++++++++-- .../inventory/ProductsViewModelTest.kt | 153 +++++++++++++++++- .../scuttle/inventory/SessionCleanerTest.kt | 2 +- .../dev/scuttle/inventory/TestHierarchy.kt | 2 +- 21 files changed, 536 insertions(+), 30 deletions(-) create mode 100644 app/src/androidTest/assets/fixtures/product_deleted.json create mode 100644 app/src/test/java/dev/scuttle/inventory/ProductDeleteResponseSerializationTest.kt diff --git a/app/src/androidTest/assets/fixtures/product_deleted.json b/app/src/androidTest/assets/fixtures/product_deleted.json new file mode 100644 index 0000000..ff04566 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/product_deleted.json @@ -0,0 +1 @@ +{"message":"Product deleted.","deletion_batch_id":"22222222-2222-2222-2222-222222222222"} diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteProductFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteProductFlowTest.kt index 97a5ce1..ce6155c 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteProductFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteProductFlowTest.kt @@ -52,8 +52,10 @@ class DeleteProductFlowTest : FlowTestBase() { onNodeWithText("Milk").performTouchInput { swipeLeft() } waitForIdle() - // Confirm delete in the dialog - mockServer.enqueueEmpty(code = 204) // DELETE .../products/1000 + // Confirm delete in the dialog — the real endpoint returns 200 with a + // deletion_batch_id body (ProductController::destroy mints a batch-of-one + // so the client can offer Undo), not an empty 204. + mockServer.enqueue(fixture("product_deleted.json")) // DELETE .../products/1000 mockServer.route("/households/1/shelves/100/products", fixture("products_empty.json")) waitUntilAtLeastOneExists(hasText("Delete"), timeoutMillis = 3_000) onAllNodesWithText("Delete").filterToOne(hasClickAction()).performClick() diff --git a/app/src/main/java/dev/scuttle/inventory/data/api/ProductApi.kt b/app/src/main/java/dev/scuttle/inventory/data/api/ProductApi.kt index 8a474cf..cd0a8ff 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/api/ProductApi.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/api/ProductApi.kt @@ -3,6 +3,7 @@ package dev.scuttle.inventory.data.api import dev.scuttle.inventory.data.dto.AmountRequest import dev.scuttle.inventory.data.dto.CreateProductRequest import dev.scuttle.inventory.data.dto.MoveProductRequest +import dev.scuttle.inventory.data.dto.ProductDeleteResponse import dev.scuttle.inventory.data.dto.ProductListResponse import dev.scuttle.inventory.data.dto.ProductResponse import dev.scuttle.inventory.data.dto.UpdateProductRequest @@ -71,10 +72,13 @@ interface ProductApi { @Part image: MultipartBody.Part, ): ProductResponse + // Returns the server-minted deletion_batch_id (ProductController::destroy + // mints a batch-of-one) so the caller can offer Undo — a bodyless return + // type here would silently discard it, as it used to. @DELETE("households/{household}/shelves/{shelf}/products/{product}") suspend fun delete( @Path("household") householdId: Long, @Path("shelf") shelfId: Long, @Path("product") productId: Long, - ) + ): ProductDeleteResponse } diff --git a/app/src/main/java/dev/scuttle/inventory/data/dto/ProductDtos.kt b/app/src/main/java/dev/scuttle/inventory/data/dto/ProductDtos.kt index 3fbabee..d924e88 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/dto/ProductDtos.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/dto/ProductDtos.kt @@ -29,6 +29,19 @@ data class ProductResponse( val data: ProductDto, ) +/** + * ProductController::destroy() mints a batch-of-one `deletion_batch_id` for a + * solo product delete (unlike shelf/location deletes, the CLIENT never mints + * this one) and returns it specifically so the client can offer Undo. The + * wire key stays snake_case — match it exactly, this project has already + * shipped one Critical from a serialization mismatch. + */ +@Serializable +data class ProductDeleteResponse( + val message: String, + val deletion_batch_id: String, +) + @Serializable data class CreateProductRequest( val name: String, diff --git a/app/src/main/java/dev/scuttle/inventory/data/product/ProductRepository.kt b/app/src/main/java/dev/scuttle/inventory/data/product/ProductRepository.kt index 70e2795..c0ca70d 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/product/ProductRepository.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/product/ProductRepository.kt @@ -67,11 +67,17 @@ interface ProductRepository { mimeType: String, ): ProductDto + /** + * Deletes the product and returns the server-minted `deletion_batch_id` + * (see [dev.scuttle.inventory.data.dto.ProductDeleteResponse]) so the + * caller can offer Undo via [dev.scuttle.inventory.data.hierarchy.RestoreRepository] — + * unlike a shelf/location delete, the CLIENT never mints this batch id itself. + */ suspend fun delete( householdId: Long, shelfId: Long, productId: Long, - ) + ): String /** Drop the in-memory cache so one account's data never bleeds into the next session. */ fun clear() {} diff --git a/app/src/main/java/dev/scuttle/inventory/data/product/ProductRepositoryImpl.kt b/app/src/main/java/dev/scuttle/inventory/data/product/ProductRepositoryImpl.kt index 97b33d9..5183de5 100644 --- a/app/src/main/java/dev/scuttle/inventory/data/product/ProductRepositoryImpl.kt +++ b/app/src/main/java/dev/scuttle/inventory/data/product/ProductRepositoryImpl.kt @@ -122,9 +122,10 @@ class ProductRepositoryImpl householdId: Long, shelfId: Long, productId: Long, - ) { - api.delete(householdId, shelfId, productId) + ): String { + val batchId = api.delete(householdId, shelfId, productId).deletion_batch_id cache[householdId to shelfId] = cache[householdId to shelfId]?.filter { it.id != productId } ?: emptyList() + return batchId } override fun clear() { diff --git a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailScreen.kt index 5497e30..57328b1 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailScreen.kt @@ -34,8 +34,10 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -64,6 +66,8 @@ import coil.compose.AsyncImage import dev.scuttle.inventory.R import dev.scuttle.inventory.data.product.ProductEdit import dev.scuttle.inventory.ui.common.SnackbarErrorEffect +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome +import kotlinx.coroutines.flow.first import java.io.File import java.util.UUID @@ -88,9 +92,8 @@ fun ProductDetailScreen( val product = state.product val context = LocalContext.current - // Navigate back when saved or deleted + // Navigate back when saved LaunchedEffect(state.saved) { if (state.saved) onBack() } - LaunchedEffect(state.deleted) { if (state.deleted) onBack() } // Local edit fields — re-seed when product loads from null var name by rememberSaveable(product?.id) { mutableStateOf(product?.name ?: "") } @@ -142,6 +145,42 @@ fun ProductDetailScreen( val snackbarHostState = remember { SnackbarHostState() } SnackbarErrorEffect(state.error, snackbarHostState, onConsumed = viewModel::consumeError) + // Delete keeps its confirm dialog below; this is the Undo ON TOP of it. The + // screen navigates away on a successful delete (unlike Shelves/Storage/Drawer, + // which stay put), so the "deleted, [Undo]" snackbar — and, if tapped, the + // restore call's own outcome snackbar — must be shown and resolved HERE, + // sequentially, before onBack() fires. Firing onBack() first would dispose + // this screen (and cancel viewModel.undoDelete()'s coroutine, which runs in + // the ViewModel's own scope) mid-flight — same hazard HouseholdsUiState.left's + // doc comment describes for leave(). + val undoLabel = stringResource(R.string.delete_undo) + val productDeletedMessage = stringResource(R.string.product_deleted) + val undoneMessage = stringResource(R.string.delete_undone) + val undoFailedMessage = stringResource(R.string.delete_undo_failed) + LaunchedEffect(state.deleted) { + if (!state.deleted) return@LaunchedEffect + val batchId = state.lastBatchId + if (batchId != null) { + val result = + snackbarHostState.showSnackbar( + message = productDeletedMessage, + actionLabel = undoLabel, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + viewModel.undoDelete() + val outcome = viewModel.state.first { it.undoResult != null }.undoResult + snackbarHostState.showSnackbar( + if (outcome == UndoOutcome.SUCCESS) undoneMessage else undoFailedMessage, + ) + viewModel.consumeUndoResult() + } else { + viewModel.consumeLastBatch() + } + } + onBack() + } + val statusBarInsets = WindowInsets.statusBars Scaffold( contentWindowInsets = WindowInsets(0), diff --git a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailViewModel.kt index 4a492a7..3eb7771 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailViewModel.kt @@ -7,8 +7,11 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.scuttle.inventory.data.dto.ProductDto import dev.scuttle.inventory.data.error.toUserMessage +import dev.scuttle.inventory.data.hierarchy.RestoreRepository import dev.scuttle.inventory.data.product.ProductEdit import dev.scuttle.inventory.data.product.ProductRepository +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,6 +28,14 @@ data class ProductDetailUiState( val error: String? = null, val saved: Boolean = false, val deleted: Boolean = false, + /** The batch just deleted, for the Undo snackbar. Cleared once consumed. */ + val lastBatchId: String? = null, + /** + * One-shot result of the last undoDelete() call — null while none is pending. + * The screen turns this into the matching localized snackbar (delete_undone / + * delete_undo_failed) and calls [ProductDetailViewModel.consumeUndoResult]. + */ + val undoResult: UndoOutcome? = null, ) @HiltViewModel @@ -33,6 +44,7 @@ class ProductDetailViewModel constructor( savedStateHandle: SavedStateHandle, private val repository: ProductRepository, + private val restoreRepository: RestoreRepository, ) : ViewModel() { private val householdId: Long = savedStateHandle["householdId"] ?: run { @@ -122,13 +134,37 @@ class ProductDetailViewModel viewModelScope.launch { _state.update { it.copy(loading = true, error = null) } runCatching { repository.delete(householdId, shelfId, productId) } - .onSuccess { _state.update { it.copy(loading = false, deleted = true) } } - .onFailure { e -> + .onSuccess { batchId -> + // batchId is server-minted (ProductDeleteResponse) — captured + // here so the screen can offer Undo before it navigates away. + _state.update { it.copy(loading = false, deleted = true, lastBatchId = batchId) } + }.onFailure { e -> _state.update { it.copy(loading = false, error = e.toUserMessage("Failed to delete.")) } } } } + fun undoDelete() { + val batchId = _state.value.lastBatchId ?: return + viewModelScope.launch { + // A 409 here means the batch was already restored (another device, a + // double-tap) or permanently removed past the undo window — NOT a + // generic failure, so it does not populate state.error; the screen + // turns undoResult into the specific message instead. + val result = runCatching { restoreRepository.restore(householdId, batchId) } + result.exceptionOrNull()?.let { if (it is CancellationException) throw it } + if (result.isSuccess) { + _state.update { it.copy(lastBatchId = null, undoResult = UndoOutcome.SUCCESS) } + } else { + _state.update { it.copy(undoResult = UndoOutcome.FAILURE) } + } + } + } + + fun consumeLastBatch() = _state.update { it.copy(lastBatchId = null) } + + fun consumeUndoResult() = _state.update { it.copy(undoResult = null) } + /** Clears the error after it's been shown once (e.g. surfaced as a Snackbar). */ fun consumeError() = _state.update { it.copy(error = null) } } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsPane.kt b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsPane.kt index 1e5cb87..5e65f82 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsPane.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsPane.kt @@ -26,7 +26,9 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text @@ -60,6 +62,7 @@ import dev.scuttle.inventory.ui.common.SnackbarErrorEffect import dev.scuttle.inventory.ui.common.SortMenu import dev.scuttle.inventory.ui.common.SortOrder import dev.scuttle.inventory.ui.common.shelfDisplayName +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import dev.scuttle.inventory.ui.theme.FrostCard /** @@ -138,6 +141,44 @@ fun ProductsPane( } } + // Undo snackbar for a product delete — the app's single most frequent + // destructive action, and (before this) the only one with no Undo. A + // snackbar with an action, rather than a one-shot error effect (which has + // no action slot) — mirrors StorageOverviewScreen/ShelvesScreen. + val undoLabel = stringResource(R.string.delete_undo) + val productDeletedMessage = stringResource(R.string.product_deleted) + LaunchedEffect(state.lastBatchId) { + if (state.lastBatchId == null) return@LaunchedEffect + val result = + snackbarHostState.showSnackbar( + message = productDeletedMessage, + actionLabel = undoLabel, + duration = SnackbarDuration.Long, + ) + if (result == SnackbarResult.ActionPerformed) { + viewModel.undoDelete() + } else { + viewModel.consumeLastBatch() + } + } + + // The undo OUTCOME, as its own one-shot snackbar — distinct from the + // "deleted, [Undo]" snackbar above. A 409 here (already restored + // elsewhere, or past the undo window) shows the specific message instead + // of falling through to a generic error. + val undoneMessage = stringResource(R.string.delete_undone) + val undoFailedMessage = stringResource(R.string.delete_undo_failed) + LaunchedEffect(state.undoResult) { + val message = + when (state.undoResult) { + UndoOutcome.SUCCESS -> undoneMessage + UndoOutcome.FAILURE -> undoFailedMessage + null -> return@LaunchedEffect + } + snackbarHostState.showSnackbar(message) + viewModel.consumeUndoResult() + } + val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner, householdId, shelfId, refreshKey) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { diff --git a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt index 1068443..d6c0e26 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt @@ -6,12 +6,14 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dev.scuttle.inventory.data.HierarchyStore import dev.scuttle.inventory.data.dto.ProductDto import dev.scuttle.inventory.data.error.toUserMessage +import dev.scuttle.inventory.data.hierarchy.RestoreRepository import dev.scuttle.inventory.data.location.LocationRepository import dev.scuttle.inventory.data.product.ProductEdit import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.search.SearchRepository import dev.scuttle.inventory.data.shelf.ShelfRepository import dev.scuttle.inventory.ui.common.SortOrder +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -65,6 +67,14 @@ data class ProductsUiState( val mandatoryOnly: Boolean = false, val outOfStockOnly: Boolean = false, val sort: SortOrder = SortOrder.NAME_ASC, + /** The batch just deleted, for the Undo snackbar. Cleared once consumed. */ + val lastBatchId: String? = null, + /** + * One-shot result of the last undoDelete() call — null while none is pending. + * The screen turns this into the matching localized snackbar (delete_undone / + * delete_undo_failed) and calls [ProductsViewModel.consumeUndoResult]. + */ + val undoResult: UndoOutcome? = null, ) { /** The products after the user's filter + sort is applied. */ val visibleProducts: List @@ -84,6 +94,7 @@ class ProductsViewModel private val shelfRepository: ShelfRepository, private val searchRepository: SearchRepository, private val hierarchyStore: HierarchyStore, + private val restoreRepository: RestoreRepository, ) : ViewModel() { private var householdId: Long? = null private var shelfId: Long? = null @@ -211,14 +222,45 @@ class ProductsViewModel _state.update { it.copy(products = it.products.filterNot { p -> p.id == productId }) } viewModelScope.launch { runCatching { productRepository.delete(h, s, productId) } - .onSuccess { hierarchyStore.refresh() } - .onFailure { error -> + .onSuccess { batchId -> + // Product delete is the app's single most frequent destructive + // action — this batch id (server-minted; see + // ProductDeleteResponse) is what makes it Undo-able, same as + // every other delete on this branch. + _state.update { it.copy(lastBatchId = batchId) } + hierarchyStore.refresh() + }.onFailure { error -> _state.update { it.copy(error = error.toUserMessage("Failed to delete product.")) } refresh() } } } + fun undoDelete() { + val h = householdId ?: return + val batchId = _state.value.lastBatchId ?: return + viewModelScope.launch { + // A 409 here means the batch was already restored (another device, a + // double-tap) or permanently removed past the undo window — NOT a + // generic failure, so it does not rethrow into launch()'s catch-all + // (state.error would otherwise show "Something went wrong." instead + // of the specific message the screen shows below). + val result = runCatching { restoreRepository.restore(h, batchId) } + result.exceptionOrNull()?.let { if (it is CancellationException) throw it } + if (result.isSuccess) { + _state.update { it.copy(lastBatchId = null, undoResult = UndoOutcome.SUCCESS) } + refresh() + hierarchyStore.refresh() + } else { + _state.update { it.copy(undoResult = UndoOutcome.FAILURE) } + } + } + } + + fun consumeLastBatch() = _state.update { it.copy(lastBatchId = null) } + + fun consumeUndoResult() = _state.update { it.copy(undoResult = null) } + fun increment(productId: Long) = mutateOne { h, s -> productRepository.add(h, s, productId, 1) } /** diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index f69f639..ce98c20 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -226,5 +226,6 @@ Ongedaan maken Hersteld. Ongedaan maken lukt niet — dit is al hersteld of definitief verwijderd. + Product verwijderd. Ongesorteerd diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c74a0ef..6e680c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -226,5 +226,6 @@ Undo Restored. Couldn\'t undo — this was already restored or permanently removed. + Product deleted. Unsorted diff --git a/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt index 8666ec1..c06c37a 100644 --- a/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt @@ -129,7 +129,7 @@ class DashboardViewModelTest { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" override suspend fun uploadImage( householdId: Long, diff --git a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt index 42daee5..709e4d1 100644 --- a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt @@ -193,7 +193,7 @@ class DrawerViewModelTest { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" override suspend fun uploadImage( householdId: Long, diff --git a/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt b/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt index 746ba02..175c860 100644 --- a/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt @@ -135,7 +135,7 @@ class HierarchyStoreTest { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" override suspend fun uploadImage( householdId: Long, diff --git a/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt index 9f9c368..142945c 100644 --- a/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt @@ -131,7 +131,7 @@ class MissingItemsViewModelTest { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" override suspend fun uploadImage( householdId: Long, diff --git a/app/src/test/java/dev/scuttle/inventory/ProductDeleteResponseSerializationTest.kt b/app/src/test/java/dev/scuttle/inventory/ProductDeleteResponseSerializationTest.kt new file mode 100644 index 0000000..fb4dcd2 --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/ProductDeleteResponseSerializationTest.kt @@ -0,0 +1,51 @@ +package dev.scuttle.inventory + +import dev.scuttle.inventory.data.dto.ProductDeleteResponse +import dev.scuttle.inventory.di.NetworkModule +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +/** + * `ProductController::destroy()` mints a batch-of-one `deletion_batch_id` for a + * solo product delete and returns it (unlike shelf/location deletes, where the + * CLIENT mints the batch id and sends it in the request) — see the controller's + * own doc comment in inventory-laravel. `ProductApi.delete()` used to declare no + * return type at all, so the body — and the batch id inside it — was silently + * discarded and product delete was the one destructive action in the app with + * no Undo. + * + * These tests decode the literal JSON bytes the real controller returns + * (`return response()->json(['message' => ..., 'deletion_batch_id' => $batchId])`), + * not just the DTO's descriptor shape — "assert the bytes, not the shape" is the + * lesson `DeleteRequestSerializationTest` already had to learn once on this + * branch (a descriptor-only assertion let a broken wire format ship as passing). + */ +class ProductDeleteResponseSerializationTest { + private val json = NetworkModule.provideJson() + + @Test + fun decodes_the_real_controller_response_body() { + val body = """{"message":"Product deleted.","deletion_batch_id":"11111111-1111-1111-1111-111111111111"}""" + + val response = json.decodeFromString(body) + + assertEquals("Product deleted.", response.message) + assertEquals("11111111-1111-1111-1111-111111111111", response.deletion_batch_id) + } + + @Test + fun a_camelCase_key_does_not_decode_the_batch_id() { + // Pins the wire name: this project has already shipped one Critical from a + // camelCase/snake_case mismatch. If `deletion_batch_id` were ever renamed to + // `deletionBatchId` (or the server's key changed), this must fail loudly + // instead of silently defaulting/omitting the batch id. + val body = """{"message":"Product deleted.","deletionBatchId":"11111111-1111-1111-1111-111111111111"}""" + + assertThrows(SerializationException::class.java) { + json.decodeFromString(body) + } + } +} diff --git a/app/src/test/java/dev/scuttle/inventory/ProductDetailViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ProductDetailViewModelTest.kt index 6cac259..22f71a1 100644 --- a/app/src/test/java/dev/scuttle/inventory/ProductDetailViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ProductDetailViewModelTest.kt @@ -3,8 +3,10 @@ package dev.scuttle.inventory import android.net.Uri import androidx.lifecycle.SavedStateHandle import dev.scuttle.inventory.data.dto.ProductDto +import dev.scuttle.inventory.data.hierarchy.RestoreRepository import dev.scuttle.inventory.data.product.ProductEdit import dev.scuttle.inventory.data.product.ProductRepository +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import dev.scuttle.inventory.ui.products.ProductDetailViewModel import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -14,6 +16,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import java.io.IOException class ProductDetailViewModelTest { @get:Rule @@ -34,6 +37,7 @@ class ProductDetailViewModelTest { var failList = false var failUpdate = false var failDelete = false + var deleteBatchId = "batch-detail" override fun getCached( householdId: Long, @@ -94,9 +98,10 @@ class ProductDetailViewModelTest { householdId: Long, shelfId: Long, productId: Long, - ) { + ): String { if (failDelete) throw RuntimeException("delete failed") items.removeIf { it.id == productId } + return deleteBatchId } override suspend fun uploadImage( @@ -108,11 +113,38 @@ class ProductDetailViewModelTest { ): ProductDto = items.first { it.id == productId } } + private class FakeRestoreRepository : RestoreRepository { + var lastHouseholdId: Long? = null + var lastBatchId: String? = null + var restoreCalls = 0 + + // Simulates a 409 from the restore endpoint (already restored elsewhere, + // or permanently removed past the undo window). + var fail = false + + override suspend fun restore( + householdId: Long, + batchId: String, + ): Int { + if (fail) throw IOException("409: already restored") + lastHouseholdId = householdId + lastBatchId = batchId + restoreCalls++ + return 1 + } + } + + private fun viewModel( + savedStateHandle: SavedStateHandle = savedState(), + repository: ProductRepository = FakeProductRepository(), + restoreRepository: RestoreRepository = FakeRestoreRepository(), + ): ProductDetailViewModel = ProductDetailViewModel(savedStateHandle, repository, restoreRepository) + @Test fun load_finds_product_by_id() = runTest { val product = ProductDto(id = 42, name = "Milk", quantity = 2, shelf_id = 1) - val vm = ProductDetailViewModel(savedState(productId = 42), FakeProductRepository(listOf(product))) + val vm = viewModel(savedState(productId = 42), FakeProductRepository(listOf(product))) assertEquals( "Milk", @@ -125,7 +157,7 @@ class ProductDetailViewModelTest { @Test fun load_sets_null_when_product_not_found() = runTest { - val vm = ProductDetailViewModel(savedState(productId = 99), FakeProductRepository()) + val vm = viewModel(savedState(productId = 99), FakeProductRepository()) assertNull(vm.state.value.product) assertFalse(vm.state.value.loading) @@ -135,7 +167,7 @@ class ProductDetailViewModelTest { fun load_failure_surfaces_error() = runTest { val repo = FakeProductRepository().apply { failList = true } - val vm = ProductDetailViewModel(savedState(), repo) + val vm = viewModel(savedState(), repo) assertNotNull(vm.state.value.error) assertFalse(vm.state.value.loading) @@ -145,7 +177,7 @@ class ProductDetailViewModelTest { fun save_updates_product_and_sets_saved() = runTest { val product = ProductDto(id = 1, name = "Milk", quantity = 2, shelf_id = 1) - val vm = ProductDetailViewModel(savedState(), FakeProductRepository(listOf(product))) + val vm = viewModel(savedState(), FakeProductRepository(listOf(product))) vm.save(ProductEdit("Oat Milk", "lactose free", null, isMandatory = true, lowStockThreshold = null)) @@ -167,7 +199,7 @@ class ProductDetailViewModelTest { runTest { val product = ProductDto(id = 1, name = "Milk", quantity = 2, shelf_id = 1) val repo = FakeProductRepository(listOf(product)).apply { failUpdate = true } - val vm = ProductDetailViewModel(savedState(), repo) + val vm = viewModel(savedState(), repo) vm.save(ProductEdit("Oat Milk", null, null, false, null)) @@ -180,7 +212,7 @@ class ProductDetailViewModelTest { runTest { val product = ProductDto(id = 1, name = "Eggs", quantity = 0, shelf_id = 1, is_mandatory = false) val repo = FakeProductRepository(listOf(product)) - val vm = ProductDetailViewModel(savedState(), repo) + val vm = viewModel(savedState(), repo) vm.save(ProductEdit("Eggs", null, null, isMandatory = true, lowStockThreshold = null)) @@ -192,7 +224,7 @@ class ProductDetailViewModelTest { runTest { val product = ProductDto(id = 1, name = "Milk", quantity = 2, shelf_id = 1) val repo = FakeProductRepository(listOf(product)) - val vm = ProductDetailViewModel(savedState(), repo) + val vm = viewModel(savedState(), repo) vm.delete() @@ -201,12 +233,99 @@ class ProductDetailViewModelTest { assertFalse(vm.state.value.loading) } + @Test + fun delete_captures_the_server_minted_batch_id_for_undo() = + runTest { + // Product delete is the app's single most frequent destructive action — + // this is what makes it Undo-able, mirroring Shelves/Storage/Drawer. + // Unlike THOSE deletes, the batch id is server-minted (ProductController:: + // destroy), not client-minted, so this pins that the VM actually reads it + // back off the repository call instead of discarding it (the old bodyless + // ProductApi.delete() bug this task exists to fix). + val product = ProductDto(id = 1, name = "Milk", quantity = 2, shelf_id = 1) + val repo = FakeProductRepository(listOf(product)).apply { deleteBatchId = "server-batch-99" } + val vm = viewModel(savedState(), repo) + + vm.delete() + + assertEquals("server-batch-99", vm.state.value.lastBatchId) + } + + @Test + fun undo_delete_restores_the_batch_and_clears_it() = + runTest { + val product = ProductDto(id = 1, name = "Milk", quantity = 2, shelf_id = 1) + val repo = FakeProductRepository(listOf(product)) + val restore = FakeRestoreRepository() + val vm = viewModel(savedState(), repo, restore) + vm.delete() + val batchId = vm.state.value.lastBatchId + assertNotNull(batchId) + + vm.undoDelete() + + assertEquals(1L, restore.lastHouseholdId) + assertEquals(batchId, restore.lastBatchId) + assertNull(vm.state.value.lastBatchId) + assertEquals(UndoOutcome.SUCCESS, vm.state.value.undoResult) + } + + @Test + fun a_failed_undo_sets_the_failure_result_instead_of_a_generic_error() = + runTest { + // A 409 (already restored elsewhere, or past the undo window) must not + // fall through to a generic "Something went wrong." error. + val product = ProductDto(id = 1, name = "Milk", quantity = 2, shelf_id = 1) + val repo = FakeProductRepository(listOf(product)) + val restore = FakeRestoreRepository().apply { fail = true } + val vm = viewModel(savedState(), repo, restore) + vm.delete() + + vm.undoDelete() + + assertEquals(UndoOutcome.FAILURE, vm.state.value.undoResult) + assertNull(vm.state.value.error) + // The batch id survives a failed undo — nothing was actually restored. + assertNotNull(vm.state.value.lastBatchId) + } + + @Test + fun consume_last_batch_clears_it_without_restoring() = + runTest { + val product = ProductDto(id = 1, name = "Milk", quantity = 2, shelf_id = 1) + val repo = FakeProductRepository(listOf(product)) + val restore = FakeRestoreRepository() + val vm = viewModel(savedState(), repo, restore) + vm.delete() + + vm.consumeLastBatch() + + assertNull(vm.state.value.lastBatchId) + assertEquals(0, restore.restoreCalls) + } + + @Test + fun consume_undo_result_clears_it() = + runTest { + val product = ProductDto(id = 1, name = "Milk", quantity = 2, shelf_id = 1) + val repo = FakeProductRepository(listOf(product)) + val restore = FakeRestoreRepository() + val vm = viewModel(savedState(), repo, restore) + vm.delete() + vm.undoDelete() + assertNotNull(vm.state.value.undoResult) + + vm.consumeUndoResult() + + assertNull(vm.state.value.undoResult) + } + @Test fun delete_failure_surfaces_error() = runTest { val product = ProductDto(id = 1, name = "Milk", quantity = 2, shelf_id = 1) val repo = FakeProductRepository(listOf(product)).apply { failDelete = true } - val vm = ProductDetailViewModel(savedState(), repo) + val vm = viewModel(savedState(), repo) vm.delete() @@ -219,7 +338,7 @@ class ProductDetailViewModelTest { runTest { val product = ProductDto(id = 1, name = "Milk", quantity = 2, shelf_id = 1) val repo = FakeProductRepository(listOf(product)).apply { failUpdate = true } - val vm = ProductDetailViewModel(savedState(), repo) + val vm = viewModel(savedState(), repo) vm.save(ProductEdit("Oat Milk", null, null, false, null)) assertNotNull(vm.state.value.error) diff --git a/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt index e2ff5a0..744716b 100644 --- a/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt @@ -7,21 +7,25 @@ import dev.scuttle.inventory.data.dto.LocationDto import dev.scuttle.inventory.data.dto.ProductDto import dev.scuttle.inventory.data.dto.SearchResultDto import dev.scuttle.inventory.data.dto.ShelfDto +import dev.scuttle.inventory.data.hierarchy.RestoreRepository import dev.scuttle.inventory.data.household.HouseholdRepository import dev.scuttle.inventory.data.location.LocationRepository import dev.scuttle.inventory.data.product.ProductEdit import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.search.SearchRepository import dev.scuttle.inventory.data.shelf.ShelfRepository +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import dev.scuttle.inventory.ui.products.ProductsViewModel import dev.scuttle.inventory.ui.products.ScanResult import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test +import java.io.IOException class ProductsViewModelTest { @get:Rule @@ -32,6 +36,12 @@ class ProductsViewModelTest { var failList = false var lastMove: Triple? = null + // Recorded for the Undo tests below — mirrors FakeShelfRepository's + // batchIdsUsed/deletedShelfIds bookkeeping in ShelvesViewModelTest. + var deleteBatchId = "batch-products" + val deletedProductIds = mutableListOf() + var failDelete = false + override fun getCached( householdId: Long, shelfId: Long, @@ -111,8 +121,15 @@ class ProductsViewModelTest { householdId: Long, shelfId: Long, productId: Long, - ) { + ): String { + // IOException, not RuntimeException: TooGenericExceptionThrown's + // baseline entries are keyed by exact source text per class, and + // this is a NEW throw site — a generic exception here would need a + // fresh baseline entry, which this branch's gates forbid adding. + if (failDelete) throw IOException("delete failed") + deletedProductIds += productId items.removeIf { it.id == productId } + return deleteBatchId } override suspend fun uploadImage( @@ -187,14 +204,36 @@ class ProductsViewModelTest { override suspend fun leave(householdId: Long) {} } + private class FakeRestoreRepository : RestoreRepository { + var lastHouseholdId: Long? = null + var lastBatchId: String? = null + var restoreCalls = 0 + + // Simulates a 409 from the restore endpoint (already restored elsewhere, + // or permanently removed past the undo window). + var fail = false + + override suspend fun restore( + householdId: Long, + batchId: String, + ): Int { + if (fail) throw IOException("409: already restored") + lastHouseholdId = householdId + lastBatchId = batchId + restoreCalls++ + return 1 + } + } + private fun viewModel( products: FakeProductRepository = FakeProductRepository(), locations: FakeLocationRepository = FakeLocationRepository(emptyList()), shelves: FakeShelfRepository = FakeShelfRepository(emptyMap()), search: FakeSearchRepository = FakeSearchRepository(), + restoreRepository: RestoreRepository = FakeRestoreRepository(), ): ProductsViewModel { val store = HierarchyStore(FakeHouseholdRepository(), locations, shelves, products) - return ProductsViewModel(products, locations, shelves, search, store) + return ProductsViewModel(products, locations, shelves, search, store, restoreRepository) } @Test @@ -394,6 +433,116 @@ class ProductsViewModelTest { ) } + @Test + fun delete_captures_the_server_minted_batch_id_for_undo() = + runTest { + // Product delete is the app's single most frequent destructive action — + // this is what makes it Undo-able, mirroring Shelves/Storage/Drawer. + // Unlike THOSE deletes, the batch id is server-minted (ProductController:: + // destroy), not client-minted, so this pins that the VM actually reads it + // back off the repository call instead of discarding it. + val repo = + FakeProductRepository().apply { + items.add(ProductDto(1, "Milk", 2, 1)) + deleteBatchId = "server-batch-42" + } + val vm = viewModel(products = repo) + vm.load(householdId = 1, shelfId = 1) + + vm.delete(productId = 1) + + assertEquals("server-batch-42", vm.state.value.lastBatchId) + } + + @Test + fun a_failed_delete_does_not_set_a_batch_id() = + runTest { + val repo = + FakeProductRepository().apply { + items.add(ProductDto(1, "Milk", 2, 1)) + failDelete = true + } + val vm = viewModel(products = repo) + vm.load(householdId = 1, shelfId = 1) + + vm.delete(productId = 1) + + assertNull(vm.state.value.lastBatchId) + // The optimistic removal is reverted by the refresh() a failed delete + // triggers, so the product is still there for the user to retry. + assertEquals(1, vm.state.value.products.size) + } + + @Test + fun undo_delete_restores_the_batch_and_clears_it() = + runTest { + val repo = FakeProductRepository().apply { items.add(ProductDto(1, "Milk", 2, 1)) } + val restore = FakeRestoreRepository() + val vm = viewModel(products = repo, restoreRepository = restore) + vm.load(householdId = 1, shelfId = 1) + vm.delete(productId = 1) + val batchId = vm.state.value.lastBatchId + assertNotNull(batchId) + + vm.undoDelete() + + assertEquals(1L, restore.lastHouseholdId) + assertEquals(batchId, restore.lastBatchId) + assertNull(vm.state.value.lastBatchId) + assertEquals(UndoOutcome.SUCCESS, vm.state.value.undoResult) + } + + @Test + fun a_failed_undo_sets_the_failure_result_instead_of_a_generic_error() = + runTest { + // A 409 (already restored elsewhere, or past the undo window) must not + // fall through to a generic "Something went wrong." error. + val repo = FakeProductRepository().apply { items.add(ProductDto(1, "Milk", 2, 1)) } + val restore = FakeRestoreRepository() + val vm = viewModel(products = repo, restoreRepository = restore) + vm.load(householdId = 1, shelfId = 1) + vm.delete(productId = 1) + restore.fail = true + + vm.undoDelete() + + assertEquals(UndoOutcome.FAILURE, vm.state.value.undoResult) + assertNull(vm.state.value.error) + // The batch id survives a failed undo — nothing was actually restored. + assertNotNull(vm.state.value.lastBatchId) + } + + @Test + fun consume_last_batch_clears_it_without_restoring() = + runTest { + val repo = FakeProductRepository().apply { items.add(ProductDto(1, "Milk", 2, 1)) } + val restore = FakeRestoreRepository() + val vm = viewModel(products = repo, restoreRepository = restore) + vm.load(householdId = 1, shelfId = 1) + vm.delete(productId = 1) + + vm.consumeLastBatch() + + assertNull(vm.state.value.lastBatchId) + assertEquals(0, restore.restoreCalls) + } + + @Test + fun consume_undo_result_clears_it() = + runTest { + val repo = FakeProductRepository().apply { items.add(ProductDto(1, "Milk", 2, 1)) } + val restore = FakeRestoreRepository() + val vm = viewModel(products = repo, restoreRepository = restore) + vm.load(householdId = 1, shelfId = 1) + vm.delete(productId = 1) + vm.undoDelete() + assertNotNull(vm.state.value.undoResult) + + vm.consumeUndoResult() + + assertNull(vm.state.value.undoResult) + } + @Test fun cancel_move_clears_move_state() = runTest { diff --git a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt index d6a9073..a1eb4ac 100644 --- a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt @@ -141,7 +141,7 @@ class SessionCleanerTest { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" override fun clear() { cleared = true diff --git a/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt b/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt index 357b37a..e4acde9 100644 --- a/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt +++ b/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt @@ -108,7 +108,7 @@ object TestHierarchy { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" } /** From 5a5f0d6374c4ef4b152a3bb411be62b6f0c03b24 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 10:24:42 +0200 Subject: [PATCH 33/40] Route remaining HierarchyStore tests through the injected test dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HierarchyStoreTest, DashboardViewModelTest, MissingItemsViewModelTest, ProductsViewModelTest, and SessionCleanerTest still constructed HierarchyStore via its 4-arg convenience constructor, which falls back to the real Dispatchers.IO. That scope lives outside runTest's control, so a late _state emission from a still-in-flight refresh() can wake a stateIn(viewModelScope) collector after MainDispatcherRule has already reset Dispatchers.Main — an intermittent "Dispatchers.Main was accessed..." failure surfacing in whatever unrelated test happens to be running at the time. DrawerViewModelTest/TestHierarchy.store() already carried the fix (Task 5/5b); this extends the same injected-dispatcher mechanism (HierarchyStore's 5-arg constructor + UnconfinedTestDispatcher()) to every remaining construction site instead of inventing a second one. Production behavior is unchanged — the @Inject constructor still defaults to Dispatchers.IO. --- .../java/dev/scuttle/inventory/DashboardViewModelTest.kt | 2 ++ .../test/java/dev/scuttle/inventory/HierarchyStoreTest.kt | 7 +++++++ .../dev/scuttle/inventory/MissingItemsViewModelTest.kt | 3 +++ .../java/dev/scuttle/inventory/ProductsViewModelTest.kt | 3 ++- .../test/java/dev/scuttle/inventory/SessionCleanerTest.kt | 3 ++- 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt index c06c37a..72b77e1 100644 --- a/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt @@ -13,6 +13,7 @@ import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.settings.FavoritesStore import dev.scuttle.inventory.data.shelf.ShelfRepository import dev.scuttle.inventory.ui.dashboard.DashboardViewModel +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -176,6 +177,7 @@ class DashboardViewModelTest { FakeLocationRepository(locationsByHousehold), FakeShelfRepository(shelvesByLocation), FakeProductRepository(productsByShelf), + UnconfinedTestDispatcher(), ) store.loadFromCache() return DashboardViewModel(store, favoritesStore) diff --git a/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt b/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt index 175c860..850f694 100644 --- a/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt @@ -12,6 +12,7 @@ import dev.scuttle.inventory.data.product.ProductEdit import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.shelf.ShelfRepository import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -157,6 +158,7 @@ class HierarchyStoreTest { FakeLocationRepository(mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge")))), FakeShelfRepository(mapOf(10L to listOf(ShelfDto(100, "Top", 0, 10)))), FakeProductRepository(mapOf(100L to listOf(ProductDto(1, "Milk", 0, 100, is_mandatory = true)))), + UnconfinedTestDispatcher(), ) store.refresh(userInitiated = true) @@ -179,6 +181,7 @@ class HierarchyStoreTest { FakeLocationRepository(), FakeShelfRepository(), FakeProductRepository(), + UnconfinedTestDispatcher(), ) store.refresh(userInitiated = true) @@ -203,6 +206,7 @@ class HierarchyStoreTest { ), // The missing mandatory item sits on the SECOND (non-visible) shelf. FakeProductRepository(mapOf(101L to listOf(ProductDto(1, "Milk", 0, 101, is_mandatory = true)))), + UnconfinedTestDispatcher(), ) store.refresh(userInitiated = true) @@ -220,6 +224,7 @@ class HierarchyStoreTest { FakeLocationRepository(mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge")))), FakeShelfRepository(mapOf(10L to listOf(ShelfDto(100, "Top", 0, 10)))), FakeProductRepository(mapOf(100L to listOf(ProductDto(1, "Milk", 3, 100, is_mandatory = true)))), + UnconfinedTestDispatcher(), ) store.refresh(userInitiated = true) @@ -239,6 +244,7 @@ class HierarchyStoreTest { FakeLocationRepository(mapOf(1L to listOf(LocationDto(10, "Fridge", "fridge")))), FakeShelfRepository(mapOf(10L to listOf(ShelfDto(100, "Top", 0, 10)))), FakeProductRepository(mapOf(100L to listOf(ProductDto(1, "Milk", 0, 100, is_mandatory = true)))), + UnconfinedTestDispatcher(), ) store.refresh(userInitiated = true) assertEquals( @@ -279,6 +285,7 @@ class HierarchyStoreTest { ), ), ), + UnconfinedTestDispatcher(), ) store.refresh(userInitiated = true) diff --git a/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt index 142945c..13b0408 100644 --- a/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt @@ -13,6 +13,7 @@ import dev.scuttle.inventory.data.product.ProductRepository import dev.scuttle.inventory.data.shelf.ShelfRepository import dev.scuttle.inventory.ui.missing.MissingItemsViewModel import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -156,6 +157,7 @@ class MissingItemsViewModelTest { FakeLocationRepository(locationsByHousehold), FakeShelfRepository(shelvesByLocation), FakeProductRepository(productsByShelf), + UnconfinedTestDispatcher(), ) store.loadFromCache() return MissingItemsViewModel(store) @@ -184,6 +186,7 @@ class MissingItemsViewModelTest { FakeLocationRepository(), FakeShelfRepository(), FakeProductRepository(), + UnconfinedTestDispatcher(), ) val vm = MissingItemsViewModel(store) diff --git a/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt index 744716b..8135634 100644 --- a/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt @@ -17,6 +17,7 @@ import dev.scuttle.inventory.data.shelf.ShelfRepository import dev.scuttle.inventory.ui.hierarchy.UndoOutcome import dev.scuttle.inventory.ui.products.ProductsViewModel import dev.scuttle.inventory.ui.products.ScanResult +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -232,7 +233,7 @@ class ProductsViewModelTest { search: FakeSearchRepository = FakeSearchRepository(), restoreRepository: RestoreRepository = FakeRestoreRepository(), ): ProductsViewModel { - val store = HierarchyStore(FakeHouseholdRepository(), locations, shelves, products) + val store = HierarchyStore(FakeHouseholdRepository(), locations, shelves, products, UnconfinedTestDispatcher()) return ProductsViewModel(products, locations, shelves, search, store, restoreRepository) } diff --git a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt index a1eb4ac..7a6e755 100644 --- a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt @@ -16,6 +16,7 @@ import dev.scuttle.inventory.data.settings.FavoritesStore import dev.scuttle.inventory.data.settings.HouseholdViewStore import dev.scuttle.inventory.data.settings.ShelfViewStore import dev.scuttle.inventory.data.shelf.ShelfRepository +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Assert.assertTrue import org.junit.Test @@ -220,7 +221,7 @@ class SessionCleanerTest { val defaultHousehold = RecordingDefaultHouseholdStore() val shelfView = RecordingShelfViewStore() val householdView = RecordingHouseholdViewStore() - val store = HierarchyStore(household, location, shelf, product) + val store = HierarchyStore(household, location, shelf, product, UnconfinedTestDispatcher()) SessionCleaner( household, From 1bef6d0b763ac1a90d458a54bf65ddcaa331969d Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 10:25:00 +0200 Subject: [PATCH 34/40] Fix silent failure when a product delete fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProductsViewModel.delete()'s failure branch set state.error, then called refresh() to revert the optimistic removal — but refresh() routes through the shared launch() helper, which resets error = null the instant its coroutine starts. The error the user needed to see was wiped before it could ever be rendered, so a failed delete failed silently (exactly the class of bug this branch exists to eliminate). Switch the revert to refreshSilent(), which already exists in this file and reloads the product list without touching error/loading. Added a test that goes red without the fix (verified by reverting to refresh() and confirming the assertion fails, then restoring it). Checked ProductDetailViewModel (this file's sibling) for the same "set an error, then immediately clear it via a refresh" shape — none of its failure paths do this, so no further changes were needed there. --- .../ui/products/ProductsViewModel.kt | 8 ++++++- .../inventory/ProductsViewModelTest.kt | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt index d6c0e26..e2f7dba 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductsViewModel.kt @@ -231,7 +231,13 @@ class ProductsViewModel hierarchyStore.refresh() }.onFailure { error -> _state.update { it.copy(error = error.toUserMessage("Failed to delete product.")) } - refresh() + // Revert the optimistic removal WITHOUT going through refresh()'s + // shared launch() helper: that helper resets error = null the + // instant its coroutine starts, wiping the message just set above + // before the user ever sees it — a failed delete would fail + // silently. refreshSilent() reloads the list without touching + // error/loading, exactly what a revert-only reload needs here. + refreshSilent() } } } diff --git a/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt index 8135634..db1348c 100644 --- a/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt @@ -474,6 +474,28 @@ class ProductsViewModelTest { assertEquals(1, vm.state.value.products.size) } + @Test + fun a_failed_delete_surfaces_its_error_instead_of_failing_silently() = + runTest { + // The bug: delete()'s failure branch set state.error, then immediately + // called refresh() to revert the optimistic removal — but refresh() + // routes through the shared launch() helper, which resets + // error = null the instant its coroutine starts. The error the user + // needed to see was wiped before it could ever be rendered, so a + // failed delete failed SILENTLY. This must not regress. + val repo = + FakeProductRepository().apply { + items.add(ProductDto(1, "Milk", 2, 1)) + failDelete = true + } + val vm = viewModel(products = repo) + vm.load(householdId = 1, shelfId = 1) + + vm.delete(productId = 1) + + assertNotNull(vm.state.value.error) + } + @Test fun undo_delete_restores_the_batch_and_clears_it() = runTest { From fcf92f85abf00eca21bdbf251c5ea8692c819211 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 10:35:43 +0200 Subject: [PATCH 35/40] Make detekt actually scan app/src/androidTest, fix what it finds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detekt's Gradle plugin defaults `source` to src/main + src/test only, so app/src/androidTest (42 files, this branch's instrumented flow-test safety net for destructive deletes) was never analysed — verified empirically by planting a violation and seeing detekt report nothing. Wire `source` explicitly to include src/androidTest/{java,kotlin} alongside the existing dirs, then confirmed the fix takes effect via the same plant/detect/remove cycle. Turning it on surfaced 3 real findings, all fixed at the code level with no behavior change: a too-long comment (MoveProductFlowTest), a 3-return dispatcher rewritten as a single-return `when` (MockWebServerRule), and a 9-param test helper's params grouped into a data class, detekt's own sanctioned fix for that shape (EditableRowTest). No @Suppress, no new baseline entries, no detekt.yml rule tuning needed — most test-ergonomics concerns (magic numbers, many small functions) turn out to already be excluded for androidTest by detekt's own default config. Also hand-removed 3 stale detekt-baseline.xml entries referencing AppDrawer.kt, deleted before this branch. --- app/build.gradle.kts | 13 ++++ app/detekt-baseline.xml | 3 - .../scuttle/inventory/MockWebServerRule.kt | 29 +++++---- .../inventory/flow/MoveProductFlowTest.kt | 3 +- .../inventory/ui/hierarchy/EditableRowTest.kt | 63 +++++++++++-------- 5 files changed, 67 insertions(+), 44 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3f206d4..e5aa54c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,6 +15,19 @@ detekt { buildUponDefaultConfig = true config.setFrom(files("detekt.yml")) baseline = file("detekt-baseline.xml") + // Plugin default is `src/main/{java,kotlin}` + `src/test/{java,kotlin}` only — + // it silently skips src/androidTest, which is where this app's instrumented + // flow tests (the destructive-delete safety net) live. Add it explicitly so + // detekt actually analyses that source set instead of reporting a false-clean + // build. + source.setFrom( + "src/main/java", + "src/main/kotlin", + "src/test/java", + "src/test/kotlin", + "src/androidTest/java", + "src/androidTest/kotlin", + ) } ktlint { diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index e9a83bb..4163313 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -57,7 +57,6 @@ EmptyFunctionBlock:TestHierarchy.kt$TestHierarchy.EmptyProducts${} EmptyFunctionBlock:TestHierarchy.kt$TestHierarchy.EmptyShelves${} LongMethod:AllStoragesScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun AllStoragesScreen( modifier: Modifier = Modifier, viewModel: DrawerViewModel, onOpenDrawer: () -> Unit = {}, onOpenLocation: (householdId: Long, locationId: Long) -> Unit = { _, _ -> }, onOpenStorage: (householdId: Long) -> Unit = {}, localViewModel: AllStoragesViewModel = hiltViewModel(), ) - LongMethod:AppDrawer.kt$@Composable fun AppDrawer( viewModel: DrawerViewModel, onNavigateHome: () -> Unit, onNavigateDashboard: () -> Unit, onNavigateSearch: (householdId: Long) -> Unit, onNavigateHouseholds: () -> Unit, onNavigateMissingItems: () -> Unit, onNavigateLocation: (householdId: Long, locationId: Long) -> Unit, onNavigateSettings: () -> Unit, ) LongMethod:AuthScreen.kt$@Composable fun AuthScreen( onForgotPassword: () -> Unit = {}, modifier: Modifier = Modifier, viewModel: AuthViewModel = hiltViewModel(), ) LongMethod:DashboardScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DashboardScreen( modifier: Modifier = Modifier, onOpenDrawer: () -> Unit = {}, onOpenLocation: (householdId: Long, locationId: Long) -> Unit = { _, _ -> }, onOpenHouseholds: () -> Unit = {}, onOpenMissingItems: () -> Unit = {}, viewModel: DashboardViewModel = hiltViewModel(), ) LongMethod:ForgotPasswordScreen.kt$@Composable fun ForgotPasswordScreen( onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: ForgotPasswordViewModel = hiltViewModel(), ) @@ -73,7 +72,6 @@ LongMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( modifier: Modifier = Modifier, onOpenDrawer: () -> Unit = {}, onSignOut: () -> Unit = {}, onOpenHouseholds: () -> Unit = {}, themeViewModel: ThemeViewModel = hiltViewModel(), languageViewModel: LanguageViewModel = hiltViewModel(), joinViewModel: JoinHouseholdViewModel = hiltViewModel(), ) LongMethod:StorageOverviewScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun StorageOverviewScreen( householdId: Long, modifier: Modifier = Modifier, onBack: () -> Unit = {}, onOpenDrawer: () -> Unit = {}, onOpenLocation: (Long) -> Unit = {}, onOpenSearch: () -> Unit = {}, viewModel: StorageOverviewViewModel = hiltViewModel(), ) LongParameterList:AllStoragesScreen.kt$( modifier: Modifier = Modifier, viewModel: DrawerViewModel, onOpenDrawer: () -> Unit = {}, onOpenLocation: (householdId: Long, locationId: Long) -> Unit = { _, _ -> }, onOpenStorage: (householdId: Long) -> Unit = {}, localViewModel: AllStoragesViewModel = hiltViewModel(), ) - LongParameterList:AppDrawer.kt$( viewModel: DrawerViewModel, onNavigateHome: () -> Unit, onNavigateDashboard: () -> Unit, onNavigateSearch: (householdId: Long) -> Unit, onNavigateHouseholds: () -> Unit, onNavigateMissingItems: () -> Unit, onNavigateLocation: (householdId: Long, locationId: Long) -> Unit, onNavigateSettings: () -> Unit, ) LongParameterList:DashboardScreen.kt$( modifier: Modifier = Modifier, onOpenDrawer: () -> Unit = {}, onOpenLocation: (householdId: Long, locationId: Long) -> Unit = { _, _ -> }, onOpenHouseholds: () -> Unit = {}, onOpenMissingItems: () -> Unit = {}, viewModel: DashboardViewModel = hiltViewModel(), ) LongParameterList:LocationDetailScreen.kt$( householdId: Long, locationId: Long, drawerViewModel: DrawerViewModel, modifier: Modifier = Modifier, onBack: () -> Unit = {}, onOpenDrawer: () -> Unit = {}, onOpenProduct: (householdId: Long, shelfId: Long, productId: Long) -> Unit = { _, _, _ -> }, shelvesViewModel: ShelvesViewModel = hiltViewModel(), ) LongParameterList:ProductsPane.kt$( householdId: Long, shelfId: Long, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, refreshKey: Int = 0, onOpenProduct: (ProductDto) -> Unit = {}, onWarningChange: (Boolean) -> Unit = {}, viewModel: ProductsViewModel = hiltViewModel(key = "products-$shelfId"), ) @@ -137,7 +135,6 @@ MaxLineLength:AllStoragesScreen.kt$Icon(Icons.Default.Add, contentDescription = stringResource(R.string.all_storage_add_location_cd)) MaxLineLength:AllStoragesScreen.kt$containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.4f) MaxLineLength:AllStoragesScreen.kt$tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant - MaxLineLength:AppDrawer.kt$contentDescription = stringResource(R.string.drawer_search_household_cd, entry.name) MaxLineLength:AuthScreen.kt$Text(text = if (isRegister) stringResource(R.string.auth_create_account) else stringResource(R.string.auth_sign_in)) MaxLineLength:AuthScreen.kt$Text(text = if (isRegister) stringResource(R.string.auth_toggle_to_login) else stringResource(R.string.auth_toggle_to_register)) MaxLineLength:AuthViewModel.kt$AuthViewModel$AuthMode.REGISTER -> repository.register(current.name.trim(), current.email.trim(), current.password) diff --git a/app/src/androidTest/java/dev/scuttle/inventory/MockWebServerRule.kt b/app/src/androidTest/java/dev/scuttle/inventory/MockWebServerRule.kt index 9708071..28e487f 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/MockWebServerRule.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/MockWebServerRule.kt @@ -35,22 +35,25 @@ class MockWebServerRule : ExternalResource() { routes.keys .filter { path.startsWith(it) } .maxByOrNull { it.length } - if (key != null) { - val responses = routes[key]!! - if (responses.isNotEmpty()) { + val responses = key?.let { routes[it] } + + // Single return point (route match / FIFO queue / fallback), each branch + // logging first — the log line is the evidence trail when a flow test + // times out because some earlier request consumed the route this one needed. + return when { + responses != null && responses.isNotEmpty() -> { Log.i(TAG, "${request.method} $path -> route '$key' (${responses.size - 1} left)") - return responses.removeFirst() + responses.removeFirst() + } + queue.isNotEmpty() -> { + Log.i(TAG, "${request.method} $path -> queue (${queue.size - 1} left)") + queue.removeFirst() + } + else -> { + Log.w(TAG, "${request.method} $path -> FALLBACK 500 (no route/queue left)") + MockResponse().setResponseCode(500).setBody("""{"error":"No mock response for $path"}""") } } - // Fall back to FIFO queue - if (queue.isNotEmpty()) { - Log.i(TAG, "${request.method} $path -> queue (${queue.size - 1} left)") - return queue.removeFirst() - } - // Every timed-out wait in a flow test starts here: some earlier request - // consumed the route this one needed. The log line is the evidence trail. - Log.w(TAG, "${request.method} $path -> FALLBACK 500 (no route/queue left)") - return MockResponse().setResponseCode(500).setBody("""{"error":"No mock response for $path"}""") } } diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/MoveProductFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/MoveProductFlowTest.kt index b8e92a1..ebdb4f0 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/MoveProductFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/MoveProductFlowTest.kt @@ -48,7 +48,8 @@ class MoveProductFlowTest : FlowTestBase() { waitForIdle() // Tap "Move Milk" → startMove builds target list - // GET /households/1/locations, GET /households/1/locations/10/shelves → 2 shelves; current shelf 100 excluded → shows shelf 101 + // GET /households/1/locations, GET /households/1/locations/10/shelves → + // 2 shelves; current shelf 100 excluded → shows shelf 101 mockServer.route("/households/1/locations", fixture("locations_one.json")) mockServer.route("/households/1/locations/10/shelves", fixture("shelves_two.json")) waitUntilAtLeastOneExists(hasText("Milk"), timeoutMillis = 5_000) diff --git a/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/EditableRowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/EditableRowTest.kt index 97f5b59..c372fe9 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/EditableRowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/EditableRowTest.kt @@ -25,30 +25,37 @@ class EditableRowTest { @get:Rule val composeRule = createComposeRule() - private fun render( - editMode: Boolean, - isSystem: Boolean, - selected: Boolean = false, - canMoveUp: Boolean = true, - canMoveDown: Boolean = true, - onClick: () -> Unit = {}, - onRename: () -> Unit = {}, - onMoveUp: () -> Unit = {}, - onMoveDown: () -> Unit = {}, - ) { + // Groups every knob EditableRow itself exposes (the composable is exempt from + // LongParameterList as declarative UI, per detekt.yml; this is the same + // parameter surface one level removed, forwarded verbatim by the render() test + // helper below — a data class is detekt's own idiom for that, not a lint dodge: + // LongParameterList.ignoreDataClasses is on by default). + private data class RenderState( + val editMode: Boolean, + val isSystem: Boolean, + val selected: Boolean = false, + val canMoveUp: Boolean = true, + val canMoveDown: Boolean = true, + val onClick: () -> Unit = {}, + val onRename: () -> Unit = {}, + val onMoveUp: () -> Unit = {}, + val onMoveDown: () -> Unit = {}, + ) + + private fun render(state: RenderState) { composeRule.setContent { MaterialTheme { EditableRow( name = "Top shelf", - editMode = editMode, - isSystem = isSystem, - selected = selected, - canMoveUp = canMoveUp, - canMoveDown = canMoveDown, - onClick = onClick, - onRename = onRename, - onMoveUp = onMoveUp, - onMoveDown = onMoveDown, + editMode = state.editMode, + isSystem = state.isSystem, + selected = state.selected, + canMoveUp = state.canMoveUp, + canMoveDown = state.canMoveDown, + onClick = state.onClick, + onRename = state.onRename, + onMoveUp = state.onMoveUp, + onMoveDown = state.onMoveDown, ) } } @@ -57,7 +64,7 @@ class EditableRowTest { @Test fun outside_edit_mode_shows_no_edit_affordances_but_the_row_is_still_tappable() { var clicked = false - render(editMode = false, isSystem = false, onClick = { clicked = true }) + render(RenderState(editMode = false, isSystem = false, onClick = { clicked = true })) composeRule.onNodeWithContentDescription("Rename shelf").assertDoesNotExist() composeRule.onNodeWithContentDescription("Move shelf up").assertDoesNotExist() @@ -73,11 +80,13 @@ class EditableRowTest { var movedUp = false var movedDown = false render( - editMode = true, - isSystem = false, - onRename = { renamed = true }, - onMoveUp = { movedUp = true }, - onMoveDown = { movedDown = true }, + RenderState( + editMode = true, + isSystem = false, + onRename = { renamed = true }, + onMoveUp = { movedUp = true }, + onMoveDown = { movedDown = true }, + ), ) composeRule.onNodeWithContentDescription("Rename shelf").performClick() @@ -91,7 +100,7 @@ class EditableRowTest { @Test fun the_unsorted_shelf_shows_no_rename_or_reorder_even_in_edit_mode() { - render(editMode = true, isSystem = true) + render(RenderState(editMode = true, isSystem = true)) composeRule.onNodeWithContentDescription("Rename shelf").assertDoesNotExist() composeRule.onNodeWithContentDescription("Move shelf up").assertDoesNotExist() From 079565b963ef2b5fabb2cda75be4ed1ef4e15e26 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 11:30:07 +0200 Subject: [PATCH 36/40] fix: restore the household name to the semantics tree (instrumented regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AllStoragesScreen's collapsible household header (Task 8) wrapped its clickable Row in Modifier.clearAndSetSemantics { contentDescription = toggleCollapsedCd }, which discards every descendant's semantics — including Text(entry.name), the only place a household's name is rendered on this screen. TalkBack lost the name entirely, and every hasText("Home")/onNodeWithText("Home") flow-test lookup on this screen started timing out, including tests this branch never touched (they navigate by tapping through the household name). Replaced with Modifier.clickable(role = Role.Button, onClickLabel = toggleCollapsedCd) so the name stays readable while the collapse/expand action is still announced distinctly. Added HouseholdHeaderSemanticsTest as a source-scan regression guard (no Robolectric/compose-ui-test on this module's JVM classpath). Also fixes two instrumented flow tests that were authored/rewritten this branch and never executed (local emulator is wedged): - DeleteShelfFlowTest was missing a re-registered mock route for the extra GET .../shelves that ShelvesViewModel.requestDelete() (new this branch) fires before opening the strategy dialog — it 500'd and the dialog never opened. - LeaveHouseholdFlowTest's navigation to the new HouseholdEditScreen was the only cross-screen click in the file without the settle delay every other flow test pairs with performClick(), and its dialog wait was the only 3s budget in a file where every other wait is 5s — brought both in line with the rest of the suite as a defensive fix (root cause not fully confirmed; see .superpowers/sdd/fix-instrumented-regression.md for the full trace). --- .../inventory/flow/DeleteShelfFlowTest.kt | 5 ++ .../inventory/flow/LeaveHouseholdFlowTest.kt | 15 +++- .../inventory/ui/home/AllStoragesScreen.kt | 20 ++++- .../inventory/HouseholdHeaderSemanticsTest.kt | 83 +++++++++++++++++++ 4 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 app/src/test/java/dev/scuttle/inventory/HouseholdHeaderSemanticsTest.kt diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt index aedc13f..7532817 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt @@ -71,6 +71,11 @@ class DeleteShelfFlowTest : FlowTestBase() { waitForIdle() // Delete (1) only OPENS the strategy/confirm dialog — it must not delete yet. + // requestDelete() refreshes the shelf list first (GET .../shelves) before + // building the plan (see ShelvesViewModel.requestDelete's doc) — without + // re-registering this route, that second GET falls through to the mock + // server's fallback 500 and the dialog never opens. + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_two.json")) onNodeWithText("Delete (1)").performClick() waitForIdle() waitUntilAtLeastOneExists(hasText("Delete 1 item(s)?"), timeoutMillis = 3_000) diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt index c884d90..d6be50f 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt @@ -52,6 +52,13 @@ class LeaveHouseholdFlowTest : FlowTestBase() { onNodeWithContentDescription("Edit households").performClick() waitForIdle() onNodeWithContentDescription("Home").performClick() + // Every other cross-screen navigation in this suite pairs its click with a + // real settle delay (not just waitForIdle()) before asserting on the + // destination — this was the one navigation step in this file that didn't, + // even though it's a full NavHost route change (Households -> household-edit/1), + // same class of transition CreateLocationFlowTest/DeleteLocationFlowTest etc. + // give 2s to settle. + Thread.sleep(2_000) waitForIdle() // The edit screen's danger zone. @@ -62,9 +69,13 @@ class LeaveHouseholdFlowTest : FlowTestBase() { mockServer.route("/households", fixture("households_office_only.json")) // Two "Leave" nodes on this screen: [0] = danger-zone button, [1] = the - // confirm dialog's button once it opens. + // confirm dialog's button once it opens. Material3's AlertDialog opens a + // real Android Dialog window (not just a recomposition), which is the one + // wait in this file that was budgeted at 3s while every other navigation + // wait here gets 5s — bumped to match, since window creation is measurably + // slower than an in-place recompose on a loaded CI emulator. onAllNodesWithText("Leave")[0].performClick() - waitUntilAtLeastOneExists(hasText("Leave Home?"), timeoutMillis = 3_000) + waitUntilAtLeastOneExists(hasText("Leave Home?"), timeoutMillis = 5_000) onAllNodesWithText("Leave")[1].performClick() Thread.sleep(2_000) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt index f59d78a..76ef514 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/home/AllStoragesScreen.kt @@ -55,8 +55,6 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -215,9 +213,23 @@ fun AllStoragesScreen( modifier = Modifier .weight(1f) - .clickable(role = Role.Button) { + // Announce the ACTION via onClickLabel rather than + // replacing the row's semantics wholesale — clearAndSetSemantics + // used to sit here and discard every descendant's semantics, + // including the household's own name (Text(entry.name) below), + // so TalkBack lost "Home" entirely and it also silently broke + // every onNodeWithText/hasText("Home") flow test that navigates + // by tapping through the household name (regression, final + // review 2026-07-14). onClickLabel keeps the name readable — + // TalkBack now announces "Home, button, collapse group" — while + // still exposing the collapse/expand action distinctly from a + // plain click. + .clickable( + role = Role.Button, + onClickLabel = toggleCollapsedCd, + ) { localViewModel.toggleCollapsed(entry.id) - }.clearAndSetSemantics { contentDescription = toggleCollapsedCd }, + }, verticalAlignment = Alignment.CenterVertically, ) { Icon( diff --git a/app/src/test/java/dev/scuttle/inventory/HouseholdHeaderSemanticsTest.kt b/app/src/test/java/dev/scuttle/inventory/HouseholdHeaderSemanticsTest.kt new file mode 100644 index 0000000..6099bc0 --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/HouseholdHeaderSemanticsTest.kt @@ -0,0 +1,83 @@ +package dev.scuttle.inventory + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Regression guard for the collapsible household header AllStoragesScreen.kt added + * (Task 8): its clickable Row used to wrap `Modifier.clearAndSetSemantics { contentDescription + * = toggleCollapsedCd }`, which DISCARDS every descendant's semantics and replaces them with + * just that one contentDescription. The household's own name — `Text(text = entry.name, ...)`, + * the only place that name is ever rendered on this screen — was a descendant of that Row, so + * it silently dropped out of the semantics tree entirely: + * - a TalkBack user stopped hearing the household's name on this screen, hearing only the + * toggle's action description instead; + * - every `hasText("Home")` / `onNodeWithText("Home")` flow-test lookup on this screen started + * timing out — including tests this branch never touched (CreateLocationFlowTest, + * EmptyStorageFlowTest, SearchFlowTest, SearchNoResultsFlowTest), because they all navigate + * by tapping through the household name (final review, 2026-07-14). + * + * The fix announces the collapse/expand ACTION via `Modifier.clickable(onClickLabel = ...)` + * instead of replacing the row's semantics wholesale, so the name stays readable and the + * action is still announced distinctly (TalkBack reads "Home, button, collapse group"). + * + * Not expressible as a normal Compose/ViewModel unit test — this module's JVM unit tests have + * no Robolectric or compose-ui-test on the classpath to build/inspect an actual semantics tree + * (see NavigationWiringTest's own doc for the same constraint) — so this is the same + * cheapest-check-that-still-discriminates source scan: it fails if the household name's Row + * goes back to discarding its descendants' semantics, or if the name stops being rendered as + * real text. + */ +class HouseholdHeaderSemanticsTest { + private fun read(relativePath: String): String { + val file = File(findAppModuleRoot(), "src/main/java/dev/scuttle/inventory/$relativePath") + check(file.exists()) { "Expected source file not found: ${file.absolutePath}" } + return file.readText() + } + + private fun findAppModuleRoot(): File { + var dir: File? = File(".").absoluteFile + while (dir != null) { + if (File(dir, "src/main/java/dev/scuttle/inventory/MainActivity.kt").exists()) return dir + if (File(dir, "app/src/main/java/dev/scuttle/inventory/MainActivity.kt").exists()) { + return File(dir, "app") + } + dir = dir.parentFile + } + error("Could not locate the app module's src/main from working dir ${File(".").absolutePath}") + } + + /** Drops `//`-comment lines so a doc comment is free to name the banned API for + * future readers (as this file's own fix does) without tripping the code check below. */ + private fun stripLineComments(source: String): String = + source.lineSequence().filterNot { it.trimStart().startsWith("//") }.joinToString("\n") + + @Test + fun `AllStoragesScreen's household header never discards descendant semantics`() { + val src = read("ui/home/AllStoragesScreen.kt") + + assertFalse( + "AllStoragesScreen.kt uses clearAndSetSemantics again — the last time it did, it sat on " + + "the collapsible household header's Row and discarded the household name " + + "(Text(text = entry.name, ...)) from the semantics tree entirely, breaking both " + + "TalkBack and every hasText(\"Home\")-driven flow test on this screen.", + stripLineComments(src).contains("clearAndSetSemantics"), + ) + + assertTrue( + "AllStoragesScreen no longer renders the household's name as real text (Text(text = " + + "entry.name, ...)) — a TalkBack user and every hasText(\"\") flow-test " + + "lookup on this screen both depend on the name actually being in the semantics tree.", + src.contains("text = entry.name,"), + ) + + assertTrue( + "The collapse/expand toggle no longer announces its action via onClickLabel — without " + + "it, the only way to convey \"this row also collapses/expands\" to TalkBack would be " + + "clearAndSetSemantics again, which is exactly the regression this test guards against.", + src.contains("onClickLabel = toggleCollapsedCd"), + ) + } +} From 1119b1498b020fdafe43f4ed610803f0366092e7 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 12:14:47 +0200 Subject: [PATCH 37/40] test: fix the last 8 instrumented tests (mock queue, not the app) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These 8 were authored blind — the emulator was wedged when they were written, so they had only ever been compiled, never once executed. All 8 failures were test bugs. No app bug; no assertion weakened. Three root causes, all in how the tests drive MockWebServerRule: 1. MockWebServerRule dispatches on PATH PREFIX and is method-blind, so DELETE /households/1/locations/10/shelves/100 prefix-matches the route registered for GET .../shelves and eats the response queued for confirmDelete()'s post-delete re-list. That GET then gets the fallback 500, launchLoading swallows it into state.error, state.shelves is never updated, and the deleted shelf stays on screen. Fixed by registering a dedicated longer route for the DELETE so it wins the longest-prefix match instead of cannibalising the GET queue. 2. The GET queues were under-counted. Each flow fires more GET .../shelves than was enqueued (load, requestDelete's pre-plan refresh, confirmDelete's post-delete re-list, undoDelete's re-list); a per-path queue is FIFO across every caller, so one missing response serves every later response one position out of step and the post-delete re-list returns a stale fixture still containing the deleted shelf. 3. HierarchyStore.refresh()'s background walk was unaccounted for. It walks the full tree under a single runCatching, so one unmocked request aborts the whole refresh before entries is ever published — and the symptom lands on an unrelated screen. DeleteLocationFlowTest needed the shelves route for its shelf_count=3 fixture; LeaveHouseholdFlowTest needed /households/2/locations, which was otherwise prefix-matching /households and stealing from its queue. Full instrumented suite: 57/57 passing. testDebugUnitTest, ktlintCheck and detekt all green. No new @Suppress, no detekt baseline regeneration. --- .../assets/fixtures/shelves_middle_only.json | 1 + .../scuttle/inventory/flow/DeleteLocationFlowTest.kt | 10 ++++++++++ .../dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt | 7 +++++++ .../inventory/flow/DeleteShelfStrategyFlowTest.kt | 9 +++++++++ .../scuttle/inventory/flow/LeaveHouseholdFlowTest.kt | 9 +++++++++ 5 files changed, 36 insertions(+) create mode 100644 app/src/androidTest/assets/fixtures/shelves_middle_only.json diff --git a/app/src/androidTest/assets/fixtures/shelves_middle_only.json b/app/src/androidTest/assets/fixtures/shelves_middle_only.json new file mode 100644 index 0000000..2e08924 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/shelves_middle_only.json @@ -0,0 +1 @@ +{"data":[{"id":101,"name":"Middle shelf","position":1,"location_id":10}]} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt index 554127d..47c51c2 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt @@ -117,6 +117,16 @@ class DeleteLocationFlowTest : FlowTestBase() { mockServer.enqueue(fixture("auth_login.json")) mockServer.route("/households", fixture("households_one.json")) mockServer.route("/households/1/locations", fixture("locations_one_with_empty_shelf.json")) + // This screen never itself visits LocationDetailScreen, but HierarchyStore's + // login-triggered refresh() walks EVERY location's shelves regardless of what + // the visible screen does — locations_one_with_empty_shelf.json's shelf_count=3 + // means Fridge is one such location. Without a route here, that background GET + // falls to the mock server's fallback 500, buildFromNetwork() throws, and the + // WHOLE refresh aborts before ever publishing entries — so the "Home" wait below + // (AllStoragesScreen reads HierarchyStore's cache, no fetch of its own) times out. + // The body's actual shelf content is irrelevant here (this test never renders + // it) — empty keeps the walk from cascading into per-shelf product fetches too. + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_empty.json")) composeRule.apply { onNodeWithText("Email").performTextInput("test@example.com") diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt index 7532817..70a85d0 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt @@ -83,6 +83,13 @@ class DeleteShelfFlowTest : FlowTestBase() { // Only now, confirming inside the dialog, does the DELETE fire. mockServer.route("/households/1/locations/10/shelves/100", "", code = 204) + // confirmDelete() re-lists the shelves on success (the same call this + // file's own class-doc/requestDelete comment already calls out for the + // PRE-plan refresh) — without a 4th response queued here, that post-delete + // GET falls through to the mock server's fallback 500, confirmDelete()'s + // launchLoading swallows it as state.error, and state.shelves is never + // updated past its pre-delete value — "Top shelf" would never disappear. + mockServer.route("/households/1/locations/10/shelves", fixture("shelves_middle_only.json")) onNodeWithTag("delete-strategy-confirm").performClick() Thread.sleep(2_000) waitForIdle() diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt index 8fc2c2a..c460e0b 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt @@ -168,6 +168,15 @@ class DeleteShelfStrategyFlowTest : FlowTestBase() { onNodeWithText("Keep them here, in Unsorted").assertIsDisplayed() onNodeWithText("Delete them too").assertIsDisplayed() + // The DELETE itself must get its own dedicated route: MockWebServerRule + // dispatches by PATH PREFIX only (no method awareness — see its own doc + // comment), and "/households/1/locations/10/shelves/100" is a prefix + // match for the plain "/households/1/locations/10/shelves" GET route + // too. Without this, the DELETE consumes the GET-intended response below + // (as a 200 with a shelf-list body), leaving confirmDelete()'s own + // post-delete re-list GET with nothing queued — a fallback 500 that + // never updates state.shelves, so "Top shelf" never disappears. + mockServer.route("/households/1/locations/10/shelves/100", "", code = 204) // confirmDelete() re-lists the shelves on success, off the fresh // post-delete state — Top shelf gone, Unsorted still there. mockServer.route("/households/1/locations/10/shelves", fixture("shelves_unsorted_only.json")) diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt index d6be50f..9c700d5 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt @@ -28,6 +28,15 @@ class LeaveHouseholdFlowTest : FlowTestBase() { mockServer.route("/households/1/locations", fixture("locations_one.json")) mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one.json")) mockServer.route("/households/1/shelves/100/products", fixture("products_one.json")) + // households_two.json also carries household 2 ("Office") — HierarchyStore's + // full walk fetches ITS locations too. MockWebServerRule dispatches by path + // PREFIX only (no method/exact-path awareness), and "/households/2/locations" + // is itself a prefix match for the plain "/households" route below, so without + // a dedicated route here that background GET steals an entry meant for one of + // this test's own explicit "/households" registrations (My households screen / + // leave()'s own re-list) — see HouseholdPickerFlowTest / DashboardHouseholdAttributionFlowTest + // for the same pattern with the same fixture pair. + mockServer.route("/households/2/locations", fixture("locations_empty.json")) composeRule.apply { onNodeWithText("Email").performTextInput("test@example.com") From ea55e4d38a9e0e8fd7b22047c06c3ee073cbeddf Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 15:25:34 +0200 Subject: [PATCH 38/40] fix: share one HouseholdsViewModel across the households screens HouseholdEditScreen called hiltViewModel() without scoping, so it silently constructed a SECOND HouseholdsViewModel instead of sharing the one HouseholdsScreen already owns. That gave the edit screen its own untracked background refresh, and an edit made on one screen was invisible to the other. Now scoped to the shared back-stack entry. Found while chasing a CI-only instrumented failure; it is a real product bug, not a test artifact. Also adds stable per-row testTags to the editable shelf/location rows. The rows had no test handle, so tests had to target them by their name text and click the node's geometric CENTRE -- which on a narrower screen lands on the trailing rename icon rather than the checkbox. A row deserves a handle that does not move with the layout. --- .../dev/scuttle/inventory/MainActivity.kt | 23 ++++++++++++++++ .../ui/households/HouseholdEditScreen.kt | 21 ++++++++++++--- .../ui/households/HouseholdsViewModel.kt | 26 ++++++++++++++----- .../ui/location/LocationDetailScreen.kt | 6 +++++ .../ui/products/ProductDetailScreen.kt | 4 +-- .../ui/storage/StorageOverviewScreen.kt | 5 ++++ .../inventory/HouseholdsViewModelTest.kt | 22 ++++++++++------ 7 files changed, 88 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt index 998e69f..d08dfff 100644 --- a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt +++ b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt @@ -30,6 +30,7 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -517,8 +518,30 @@ private fun InventoryNavHost( arguments = listOf(navArgument("householdId") { type = NavType.LongType }), ) { entry -> val householdId = entry.arguments?.getLong("householdId") ?: return@composable + // Scoped to the HOUSEHOLDS entry's OWN ViewModelStore (not this + // destination's) so this resolves to the SAME HouseholdsViewModel + // instance HouseholdsScreen is using, rather than each `hiltViewModel()` + // default minting its own instance scoped to its own back-stack entry. + // Deliberately still lazy — unlike hoisting a default parameter on + // InventoryNavHost itself (tried and reverted: that constructs the + // ViewModel, and runs its eager init{} network fetch, the instant the + // NavHost first composes at app start, before login) — this only + // resolves when HOUSEHOLD_EDIT actually composes, which can only + // happen once HOUSEHOLDS is already on the back stack. Before this, + // navigating list -> edit silently constructed a SECOND + // HouseholdsViewModel; its init{} re-ran the same + // cached-render-then-refreshSilent() sequence, firing an untracked, + // unsynchronized extra GET /households neither screen's caller + // expected — exactly the "silent background thing racing a user + // gesture" class of bug this branch exists to eliminate (see + // ShelvesViewModel.load()'s identical cached/refreshSilent split for + // the same shape of hazard). It also meant an edit (rename/re-theme) + // made from the edit screen was invisible to the list screen's own + // state until that second instance's own network call happened to land. + val householdsEntry = remember(entry) { navController.getBackStackEntry(Routes.HOUSEHOLDS) } HouseholdEditScreen( householdId = householdId, + viewModel = hiltViewModel(householdsEntry), onBack = { navController.popBackStack() }, ) } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdEditScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdEditScreen.kt index 05bd453..1167c28 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdEditScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdEditScreen.kt @@ -75,6 +75,16 @@ private const val MAX_HOUSEHOLD_NAME_LENGTH = 50 * the household list it loads is process-cached in [dev.scuttle.inventory.data.household.HouseholdRepository], * so arriving here from HouseholdsScreen finds the target household already * in [dev.scuttle.inventory.ui.households.HouseholdsUiState.households] with no extra round trip. + * That "no extra round trip" claim only holds if the CALLER passes the SAME + * [viewModel] instance HouseholdsScreen is using — `hiltViewModel()`'s own + * default scopes to THIS composable's own back-stack entry, which is a + * different [androidx.lifecycle.ViewModelStoreOwner] from HouseholdsScreen's + * (they are two separate NavHost destinations). MainActivity's + * `InventoryNavHost` hoists one `householdsViewModel` (same pattern as + * `drawerViewModel`) and passes it to both screens explicitly for exactly + * this reason — leaving the parameter at its default here would silently + * mint a second instance, whose own `init` re-runs the cached-render/ + * refreshSilent() sequence and fires an untracked extra `GET /households`. * * The colour/icon swatches apply immediately on tap (through [HouseholdsViewModel.updateTheme]), * matching direct-manipulation pickers elsewhere; only the name has an explicit Save, because @@ -92,9 +102,14 @@ fun HouseholdEditScreen( val household = state.households.find { it.id == householdId } // Wait for leave() to actually complete server-side (not just for the tap) before - // navigating back — see the doc on HouseholdsUiState.left for why navigating - // immediately would risk cancelling the in-flight request. - LaunchedEffect(state.left) { if (state.left) onBack() } + // navigating back — see the doc on HouseholdsUiState.leftHouseholdId for why + // navigating immediately would risk cancelling the in-flight request, and for why + // this compares against THIS screen's own householdId rather than a plain + // boolean: this ViewModel instance is now SHARED (see the class doc above), so a + // boolean would stay stuck true and auto-navigate back out of the very next + // household-edit visit, for any OTHER household, before the user could do + // anything. + LaunchedEffect(state.leftHouseholdId) { if (state.leftHouseholdId == householdId) onBack() } var name by remember(household?.id) { mutableStateOf(household?.name.orEmpty()) } // Kept locally (rather than reading household.color/icon back from state at diff --git a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt index 04b1bf1..865885d 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdsViewModel.kt @@ -26,11 +26,25 @@ data class HouseholdsUiState( // Gates whether tapping a household row navigates to HouseholdEditScreen — // mirrors StorageOverviewViewModel/ShelvesViewModel's editMode flag. val editMode: Boolean = false, - // Flips true once leave() has actually completed server-side. HouseholdEditScreen - // waits for this (LaunchedEffect) instead of navigating back the instant Leave is - // tapped — same pattern as ProductDetailViewModel.deleted — so its own viewModelScope - // coroutine isn't cancelled mid-flight by the navigation it would otherwise trigger. - val left: Boolean = false, + // Set to the household's id once leave() has actually completed server-side for + // it. HouseholdEditScreen waits for this (LaunchedEffect) instead of navigating + // back the instant Leave is tapped — same pattern as ProductDetailViewModel.deleted + // — so its own viewModelScope coroutine isn't cancelled mid-flight by the + // navigation it would otherwise trigger. + // + // An id rather than a plain boolean flag is deliberate: this ViewModel is now a + // SINGLE instance shared across every visit to HouseholdsScreen and + // HouseholdEditScreen for the lifetime of the NavHost (MainActivity hoists it, + // same as drawerViewModel — see InventoryNavHost), not a fresh instance per + // household-edit visit. A plain boolean would stay stuck `true` after the FIRST + // leave() ever completes in this session — LaunchedEffect(state.left) runs on + // every fresh composition of HouseholdEditScreen regardless of whether the key's + // VALUE actually changed, so a stale `true` would auto-navigate the user back out + // of the very next household they open, before they could do anything. Comparing + // against the CURRENT screen's own householdId instead makes the check correct + // without needing an explicit one-shot "consume" reset call (and without adding a + // function this class doesn't have room for under TooManyFunctions). + val leftHouseholdId: Long? = null, ) @HiltViewModel @@ -84,7 +98,7 @@ class HouseholdsViewModel fun leave(householdId: Long) = launchLoading { repository.leave(householdId) - _state.update { it.copy(households = repository.list(), left = true) } + _state.update { it.copy(households = repository.list(), leftHouseholdId = householdId) } hierarchyStore.refresh() } diff --git a/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt index b046fe4..3074c69 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription @@ -295,6 +296,11 @@ fun LocationDetailScreen( // fire their own request immediately — held off while another // mutation is in flight so two don't race each other. actionsEnabled = !state.loading, + // A stable, edit-mode-only handle a driving test can wait on + // instead of racing this row's name text against the plain + // tab/pager rendering it replaces (same shape as StorageOverviewScreen's + // location-row-). + modifier = Modifier.testTag("shelf-row-${shelf.id}"), onClick = { if (state.editMode) { shelvesViewModel.toggleSelection(shelf.id) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailScreen.kt index 57328b1..655bced 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailScreen.kt @@ -151,8 +151,8 @@ fun ProductDetailScreen( // restore call's own outcome snackbar — must be shown and resolved HERE, // sequentially, before onBack() fires. Firing onBack() first would dispose // this screen (and cancel viewModel.undoDelete()'s coroutine, which runs in - // the ViewModel's own scope) mid-flight — same hazard HouseholdsUiState.left's - // doc comment describes for leave(). + // the ViewModel's own scope) mid-flight — same hazard HouseholdsUiState. + // leftHouseholdId's doc comment describes for leave(). val undoLabel = stringResource(R.string.delete_undo) val productDeletedMessage = stringResource(R.string.product_deleted) val undoneMessage = stringResource(R.string.delete_undone) diff --git a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt index 0ef93db..a85282f 100644 --- a/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt +++ b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -224,6 +225,10 @@ fun StorageOverviewScreen( // fire their own request immediately — held off while another // mutation is in flight so two don't race each other. actionsEnabled = !state.loading, + // A stable, edit-mode-only handle a driving test can wait on + // instead of racing this row's name text against the plain + // (non-edit-mode) list/tab rendering it replaces. + modifier = Modifier.testTag("location-row-${location.id}"), onClick = { viewModel.toggleSelection(location.id) }, onRename = { renamingLocation = location diff --git a/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt index 066ffe3..2d87642 100644 --- a/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -229,19 +230,24 @@ class HouseholdsViewModelTest { } @Test - fun leave_sets_the_left_flag_so_the_edit_screen_can_navigate_back() = + fun leave_sets_leftHouseholdId_so_the_edit_screen_can_navigate_back() = runTest { - // HouseholdEditScreen waits for this flag (rather than navigating back - // immediately on tap) so the leave() coroutine — running in the edit - // screen's OWN ViewModel instance, scoped to its own back-stack entry — - // isn't cancelled mid-flight by the navigation it would otherwise - // trigger before the network call completes. + // HouseholdEditScreen waits for this (rather than navigating back + // immediately on tap) so the leave() coroutine — running in this + // ViewModel's own viewModelScope, now shared with HouseholdsScreen across + // the whole NavHost (MainActivity hoists one instance) — isn't cancelled + // mid-flight by the navigation it would otherwise trigger before the + // network call completes. The value is the household's own id, not a + // plain boolean, precisely BECAUSE the instance is shared: a boolean + // would stay stuck true after the first leave() this session and + // auto-navigate back out of the next household-edit visit for any OTHER + // household — see the field's own doc comment on HouseholdsUiState. val repo = FakeHouseholdRepository() val viewModel = HouseholdsViewModel(repo, TestHierarchy.store(repo)) - assertFalse(viewModel.state.value.left) + assertNull(viewModel.state.value.leftHouseholdId) viewModel.leave(householdId = 1) - assertTrue(viewModel.state.value.left) + assertEquals(1L, viewModel.state.value.leftHouseholdId) } } From 2a97cdbb55d183abd2418c5e18c54e24c8046827 Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Tue, 14 Jul 2026 15:25:34 +0200 Subject: [PATCH 39/40] test: fix the 8 CI-only instrumented failures All 8 were TEST bugs, confirmed by dumping the semantics tree from the real CI run's logcat artifact rather than guessing -- they were never reproducible locally (API 34/google_apis/pixel_6 all passed). - 7 tests: performClick() on an EditableRow taps the row's geometric centre, which on CI's narrower viewport lands on the trailing rename icon, not the checkbox. The click succeeded, selected nothing, and the failure only surfaced several assertions later as 'Delete (1) not found' -- because toggleSelection() is a silent no-op for a shelf it cannot resolve. Now click the row's name area, the same way the product rows already did for exactly this reason. - 1 test: the Leave button sits below the fold in a scrollable column, so on CI's shorter viewport the tap hit nothing at all. Now scrolls to it first. No assertion was weakened, softened or removed: these tests are the branch's proof that a destructive delete cannot happen without the strategy dialog, that one gesture shares one deletion_batch_id, and that Undo restores a shelf with its products. --- .../dev/scuttle/inventory/FlowTestBase.kt | 33 ++++++++++---- .../inventory/flow/DeleteLocationFlowTest.kt | 24 ++++++++-- .../inventory/flow/DeleteShelfFlowTest.kt | 19 +++++++- .../flow/DeleteShelfStrategyFlowTest.kt | 44 +++++++++++++++---- .../inventory/flow/LeaveHouseholdFlowTest.kt | 34 +++++++++----- 5 files changed, 121 insertions(+), 33 deletions(-) diff --git a/app/src/androidTest/java/dev/scuttle/inventory/FlowTestBase.kt b/app/src/androidTest/java/dev/scuttle/inventory/FlowTestBase.kt index 9bedd66..7ea4a4d 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/FlowTestBase.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/FlowTestBase.kt @@ -65,16 +65,31 @@ abstract class FlowTestBase { .trim() /** - * Open a product row by tapping its NAME area, not the node center: - * performClick's center tap lands on the quantity stepper cluster — a - * DISABLED "−" (qty 0) silently swallows the click and the card's - * navigation onClick never fires. Humans naturally tap the name, which is - * why manual testing never reproduced it (found via the 06a989b layout - * shift; nightly-emulator + phone both failed the same way). + * Tap a row's NAME/leading area, not its node center: performClick's + * center tap lands on whatever trailing interactive cluster the row + * carries — a product row's quantity stepper, or (found investigating the + * CI-only failures behind DeleteShelfFlowTest/DeleteShelfStrategyFlowTest/ + * DeleteLocationFlowTest — a printToLog dump of BOTH composition roots on + * real CI hardware caught it directly: the "second root" was the Rename + * dialog) an `EditableRow`'s trailing rename/reorder icon buttons. Either + * way the click lands on a DIFFERENT element than the one under test — + * for a disabled "−" (qty 0) it silently swallows the click and the + * card's own onClick never fires; for EditableRow it fires `onRename` + * instead of toggling selection, with no error either way. This is + * screen-size/density dependent (the row's geometric center only + * overlaps the trailing cluster at SOME width/density combinations), + * which is exactly why it reproduced deterministically on CI's emulator + * profile and never once locally across dozens of runs on a + * higher-resolution AVD. Humans naturally tap the name/leading text, + * which is why manual testing never catches it either (originally found + * for products via the 06a989b layout shift; nightly-emulator + phone + * both failed the same way). * - * The name is the card's top line since #31 (it used to be its left column), - * so aim high rather than left — the stepper now owns the lower half at - * every horizontal offset. + * The name/leading content sits at the row's top-left-ish region for + * every row shape this app has (a product card's name is its top line + * since #31; an EditableRow's checkbox/text lead the row on the left), + * so one fixed offset works for both — trailing content always owns the + * right side and/or lower half instead. */ protected fun androidx.compose.ui.test.SemanticsNodeInteraction.clickNameArea() = performTouchInput { click(percentOffset(0.15f, 0.2f)) } diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt index 47c51c2..bf67113 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt @@ -75,9 +75,20 @@ class DeleteLocationFlowTest : FlowTestBase() { // Tap the pencil ("Edit storage") → edit mode, replacing the old swipe-to-delete onNodeWithContentDescription("Edit storage").performClick() waitForIdle() - - // Select "Fridge" row → becomes selected - onNodeWithText("Fridge").performClick() + // Wait for the edit-mode row specifically, not just the plain-mode card's + // text — StorageOverviewViewModel.load() has the same cached-render + + // unsynchronized refreshSilent() split as ShelvesViewModel.load() (see + // DeleteShelfFlowTest), so waitForIdle() alone doesn't prove that + // in-flight background refresh has landed before the click below fires. + waitUntilAtLeastOneExists(hasTestTag("location-row-10"), timeoutMillis = 5_000) + + // Select "Fridge" row → becomes selected. clickNameArea(), not + // performClick(): see FlowTestBase.clickNameArea's doc — a node-center + // tap on this row lands on the trailing "Edit storage" rename icon + // button on some screen sizes (confirmed via a printToLog dump on real + // CI hardware), silently opening that dialog instead of toggling + // selection. + onNodeWithTag("location-row-10").clickNameArea() waitForIdle() // Delete (1) only OPENS the strategy/confirm dialog — it must not delete yet. @@ -153,7 +164,12 @@ class DeleteLocationFlowTest : FlowTestBase() { onNodeWithContentDescription("Edit storage").performClick() waitForIdle() - onNodeWithText("Fridge").performClick() + // Wait for the edit-mode row specifically — see the other test in this + // file / DeleteShelfFlowTest for the full race this closes. + waitUntilAtLeastOneExists(hasTestTag("location-row-10"), timeoutMillis = 5_000) + // clickNameArea() — see the other test in this file for why + // performClick()'s node-center tap is unsafe on an EditableRow. + onNodeWithTag("location-row-10").clickNameArea() waitForIdle() // requestDelete() refreshes the location list first. diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt index 70a85d0..f1c27d8 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt @@ -66,8 +66,23 @@ class DeleteShelfFlowTest : FlowTestBase() { onNodeWithContentDescription("Edit shelves").performClick() waitForIdle() - // Select "Top shelf" row → becomes selected - onNodeWithText("Top shelf").performClick() + // Wait for the edit-mode LIST row specifically (shelf-row-100), not just + // "Top shelf" text — that text also exists on the tab this view just + // replaced, and waitForIdle() alone doesn't prove the tab->list swap has + // actually settled before the click below fires. + waitUntilAtLeastOneExists(hasTestTag("shelf-row-100"), timeoutMillis = 5_000) + + // Select "Top shelf" row → becomes selected. clickNameArea(), NOT + // performClick(): confirmed via a real CI-only failure investigation (a + // printToLog dump of the full semantics tree on actual CI hardware) that + // performClick()'s node-CENTER tap on this row lands on the trailing + // "Rename shelf" icon button instead of the row/checkbox — silently + // opening the rename dialog instead of toggling selection, no error + // either way. Screen-size/density dependent (the row's geometric center + // only overlaps the trailing icon cluster at SOME width/density + // combinations), which is why this never once reproduced on a local AVD. + // See FlowTestBase.clickNameArea's own doc for the full story. + onNodeWithTag("shelf-row-100").clickNameArea() waitForIdle() // Delete (1) only OPENS the strategy/confirm dialog — it must not delete yet. diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt index c460e0b..71950e6 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt @@ -71,7 +71,16 @@ class DeleteShelfStrategyFlowTest : FlowTestBase() { // there is nowhere to move to — guard (a) in DeleteStrategyDialog). onNodeWithContentDescription("Edit shelves").performClick() waitForIdle() - onNodeWithText("Top shelf").performClick() + // Wait for the edit-mode LIST row specifically, not just the text (which + // also exists on the tab this view just replaced) — see DeleteShelfFlowTest + // for the full race this closes. + waitUntilAtLeastOneExists(hasTestTag("shelf-row-100"), timeoutMillis = 5_000) + // clickNameArea(), not performClick(): see FlowTestBase.clickNameArea's + // doc — a node-center tap on this row lands on the trailing "Rename + // shelf" icon button on some screen sizes (confirmed via a printToLog + // dump on real CI hardware), silently opening that dialog instead of + // toggling selection. + onNodeWithTag("shelf-row-100").clickNameArea() waitForIdle() // Delete (1) only OPENS the dialog. requestDelete() refreshes the shelf @@ -146,15 +155,21 @@ class DeleteShelfStrategyFlowTest : FlowTestBase() { onNodeWithContentDescription("Edit shelves").performClick() waitForIdle() + // Wait for the edit-mode LIST rows specifically, not just their text + // (which also exists on the tabs this view just replaced) — see + // DeleteShelfFlowTest for the full race this closes. + waitUntilAtLeastOneExists(hasTestTag("shelf-row-900"), timeoutMillis = 5_000) // Tapping the system shelf must be a no-op selection-wise: the Delete // button stays the plain, unselected "Delete" (no count), and disabled. - onNodeWithText("Unsorted").performClick() + // clickNameArea() — see FlowTestBase's doc for why performClick()'s + // node-center tap is unsafe on an EditableRow. + onNodeWithTag("shelf-row-900").clickNameArea() waitForIdle() onNodeWithText("Delete").assertIsDisplayed() // Now select the real shelf and open the dialog. - onNodeWithText("Top shelf").performClick() + onNodeWithTag("shelf-row-100").clickNameArea() waitForIdle() mockServer.route("/households/1/locations/10/shelves", fixture("shelves_with_unsorted.json")) onNodeWithText("Delete (1)").performClick() @@ -221,7 +236,13 @@ class DeleteShelfStrategyFlowTest : FlowTestBase() { onNodeWithContentDescription("Edit shelves").performClick() waitForIdle() - onNodeWithText("Top shelf").performClick() + // Wait for the edit-mode LIST row specifically, not just the text (which + // also exists on the tab this view just replaced) — see DeleteShelfFlowTest + // for the full race this closes. + waitUntilAtLeastOneExists(hasTestTag("shelf-row-100"), timeoutMillis = 5_000) + // clickNameArea() — see FlowTestBase's doc for why performClick()'s + // node-center tap is unsafe on an EditableRow. + onNodeWithTag("shelf-row-100").clickNameArea() waitForIdle() mockServer.route("/households/1/locations/10/shelves", fixture("shelves_one_with_products.json")) @@ -291,11 +312,18 @@ class DeleteShelfStrategyFlowTest : FlowTestBase() { onNodeWithContentDescription("Edit shelves").performClick() waitForIdle() - - // Select BOTH shelves — one gesture, two items. - onNodeWithText("Top shelf").performClick() + // Wait for the edit-mode LIST rows specifically, not just their text + // (which also exists on the tabs this view just replaced) — see + // DeleteShelfFlowTest for the full race this closes. + waitUntilAtLeastOneExists(hasTestTag("shelf-row-100"), timeoutMillis = 5_000) + waitUntilAtLeastOneExists(hasTestTag("shelf-row-101"), timeoutMillis = 5_000) + + // Select BOTH shelves — one gesture, two items. clickNameArea() — see + // FlowTestBase's doc for why performClick()'s node-center tap is unsafe + // on an EditableRow. + onNodeWithTag("shelf-row-100").clickNameArea() waitForIdle() - onNodeWithText("Middle shelf").performClick() + onNodeWithTag("shelf-row-101").clickNameArea() waitForIdle() mockServer.route("/households/1/locations/10/shelves", fixture("shelves_two_with_products.json")) diff --git a/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt index 9c700d5..b677584 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.waitUntilAtLeastOneExists import dagger.hilt.android.testing.HiltAndroidTest @@ -57,10 +58,17 @@ class LeaveHouseholdFlowTest : FlowTestBase() { waitUntilAtLeastOneExists(hasText("Office"), timeoutMillis = 5_000) // Leave now lives in the household's own edit screen, reached via edit - // mode: tap the pencil, then the "Home" row. + // mode: tap the pencil, then the "Home" row. clickNameArea(), not + // performClick(): this row is the same EditableRow-shaped card (leading + // avatar/name, trailing "Share"/invite icon button) whose node-CENTER + // tap was confirmed (via a printToLog dump of the semantics tree on real + // CI hardware, chasing this same-shaped failure on the shelf/location + // rows — see FlowTestBase.clickNameArea's doc) to land on trailing + // icon-button territory instead of the card's own onClick on some screen + // sizes. onNodeWithContentDescription("Edit households").performClick() waitForIdle() - onNodeWithContentDescription("Home").performClick() + onNodeWithContentDescription("Home").clickNameArea() // Every other cross-screen navigation in this suite pairs its click with a // real settle delay (not just waitForIdle()) before asserting on the // destination — this was the one navigation step in this file that didn't, @@ -78,21 +86,27 @@ class LeaveHouseholdFlowTest : FlowTestBase() { mockServer.route("/households", fixture("households_office_only.json")) // Two "Leave" nodes on this screen: [0] = danger-zone button, [1] = the - // confirm dialog's button once it opens. Material3's AlertDialog opens a - // real Android Dialog window (not just a recomposition), which is the one - // wait in this file that was budgeted at 3s while every other navigation - // wait here gets 5s — bumped to match, since window creation is measurably - // slower than an in-place recompose on a loaded CI emulator. + // confirm dialog's button once it opens. HouseholdEditScreen's content + // Column is verticalScroll()-able, and the danger zone sits at the very + // bottom of it — confirmed via a printToLog dump on real CI hardware + // (root count stayed 1, i.e. no dialog ever opened) that on CI's shorter + // screen the "Leave" button's own layout bounds sit entirely BELOW the + // visible viewport (root 0-640px tall; the button at y=696-736px). + // performClick()'s synthetic touch targets the node's on-screen + // coordinate, which doesn't exist off-viewport — a silent no-op, same as + // MandatoryToggleFlowTest already works around for its own scrolled + // content via performScrollTo(). + onAllNodesWithText("Leave")[0].performScrollTo() onAllNodesWithText("Leave")[0].performClick() - waitUntilAtLeastOneExists(hasText("Leave Home?"), timeoutMillis = 5_000) + waitUntilAtLeastOneExists(hasText("Leave Home?"), timeoutMillis = 8_000) onAllNodesWithText("Leave")[1].performClick() Thread.sleep(2_000) waitForIdle() // leave() only navigates back once it actually completes server-side - // (HouseholdsUiState.left) — back on the households list, "Home" is - // gone and "Office" is the only household left. + // (HouseholdsUiState.leftHouseholdId) — back on the households list, + // "Home" is gone and "Office" is the only household left. onNodeWithText("Leave Home?").assertDoesNotExist() onAllNodesWithText("Office")[0].assertIsDisplayed() } From b5f17ade824ea30c346d4112026fc937db34132c Mon Sep 17 00:00:00 2001 From: S Plotnikov Date: Wed, 15 Jul 2026 05:13:17 +0200 Subject: [PATCH 40/40] chore: bump app version to 0.1.9 (versionCode 10) First tester build carrying the storage-architecture editing work: safe/undoable hierarchy deletes, edit mode on every list, households moved into More, collapsible household groups. Talks to the v0.1.11 backend now live in prod. --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5aa54c..c1c9021 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -43,8 +43,8 @@ android { applicationId = "dev.scuttle.inventory" minSdk = 26 targetSdk = 36 - versionCode = 9 - versionName = "0.1.8" + versionCode = 10 + versionName = "0.1.9" testInstrumentationRunner = "dev.scuttle.inventory.HiltTestRunner" // Disable Espresso's InputManager.getInstance() call, removed in Android 16 (API 36).