diff --git a/CLAUDE.md b/CLAUDE.md index 2e3ffee..2f448ac 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,51 @@ 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 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 +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 +144,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 +162,39 @@ 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. +- **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 any on-device font/layout number, and state their values alongside the result:** @@ -102,13 +226,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). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3f206d4..c1c9021 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 { @@ -30,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). diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index fdfcc01..4163313 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? = 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? = 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 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(), ) @@ -49,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(), ) @@ -65,11 +72,10 @@ 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"), ) - 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, 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 @@ -129,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) @@ -197,12 +202,16 @@ 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("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/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/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/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_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/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/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/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/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/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteLocationFlowTest.kt index 764819d..bf67113 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,32 @@ 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.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 + * 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 +59,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,24 +69,140 @@ 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() + // 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. + // 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() } } + + /** + * 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")) + // 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") + 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() + // 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. + 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/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/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfFlowTest.kt index 1c8665b..f1c27d8 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,53 @@ 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 - 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 /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. + // 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) + onNodeWithText("Top shelf").assertIsDisplayed() + + // 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 new file mode 100644 index 0000000..71950e6 --- /dev/null +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/DeleteShelfStrategyFlowTest.kt @@ -0,0 +1,367 @@ +@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() + // 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 + // 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() + // 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. + // 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. + onNodeWithTag("shelf-row-100").clickNameArea() + 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() + + // 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")) + 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() + // 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")) + 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() + // 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() + onNodeWithTag("shelf-row-101").clickNameArea() + 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/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/SearchHouseholdPickerFlowTest.kt b/app/src/androidTest/java/dev/scuttle/inventory/flow/HouseholdPickerFlowTest.kt similarity index 50% rename from app/src/androidTest/java/dev/scuttle/inventory/flow/SearchHouseholdPickerFlowTest.kt rename to app/src/androidTest/java/dev/scuttle/inventory/flow/HouseholdPickerFlowTest.kt index 8e2ef6a..eae5d36 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/SearchHouseholdPickerFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/HouseholdPickerFlowTest.kt @@ -5,11 +5,10 @@ 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 @@ -21,20 +20,28 @@ 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 + * 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 SearchHouseholdPickerFlowTest : FlowTestBase() { +class HouseholdPickerFlowTest : FlowTestBase() { @Test - fun picking_second_household_searches_that_households_endpoint() { + 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")) @@ -42,7 +49,7 @@ class SearchHouseholdPickerFlowTest : FlowTestBase() { 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). + // households (required for the picker to show at all). mockServer.route("/households/2/locations", fixture("locations_empty.json")) composeRule.apply { @@ -53,33 +60,21 @@ class SearchHouseholdPickerFlowTest : FlowTestBase() { 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. + // "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")) - // 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() + // 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() @@ -96,7 +91,7 @@ class SearchHouseholdPickerFlowTest : FlowTestBase() { 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). + // endpoint, not household 1's (the exact bug this branch's final review caught). onNodeWithText("Fridge › Top shelf").assertDoesNotExist() } } 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 5ef40dd..b677584 100644 --- a/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt +++ b/app/src/androidTest/java/dev/scuttle/inventory/flow/LeaveHouseholdFlowTest.kt @@ -8,9 +8,11 @@ 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 import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.waitUntilAtLeastOneExists import dagger.hilt.android.testing.HiltAndroidTest @@ -27,6 +29,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") @@ -36,27 +47,66 @@ 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) - // 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. 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").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, + // 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. + 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. 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() - 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() + waitUntilAtLeastOneExists(hasText("Leave Home?"), timeoutMillis = 8_000) + 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.leftHouseholdId) — 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/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/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/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" } + } +} 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/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/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..8d595f8 --- /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), + 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), + 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), + 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), + 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/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..c372fe9 --- /dev/null +++ b/app/src/androidTest/java/dev/scuttle/inventory/ui/hierarchy/EditableRowTest.kt @@ -0,0 +1,109 @@ +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() + + // 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 = 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, + ) + } + } + } + + @Test + fun outside_edit_mode_shows_no_edit_affordances_but_the_row_is_still_tappable() { + var clicked = false + render(RenderState(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( + RenderState( + 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(RenderState(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/MainActivity.kt b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt index cb489b3..d08dfff 100644 --- a/app/src/main/java/dev/scuttle/inventory/MainActivity.kt +++ b/app/src/main/java/dev/scuttle/inventory/MainActivity.kt @@ -7,43 +7,35 @@ 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 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 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 @@ -59,8 +51,11 @@ 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 import dev.scuttle.inventory.ui.households.HouseholdsScreen import dev.scuttle.inventory.ui.invite.InviteScreen import dev.scuttle.inventory.ui.location.LocationDetailScreen @@ -128,26 +123,123 @@ 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. 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, ) +/** + * 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) + } + +/** + * 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" 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}" 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" + 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, @@ -248,59 +340,72 @@ 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() - var showHouseholdPicker by rememberSaveable { mutableStateOf(false) } - val householdPickerSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + // 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), 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. 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), - 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( 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, - // 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 - } + selected = + if (tab.route == Routes.SCANNER) { + scannerRouteIsTheBottomBarTab(currentScannerMode) } else { - navController.navigate(tab.route, tabNavOptions) - } - }, + currentRoute == tab.route + }, + onClick = { navController.navigate(tab.navigateTo, tabNavOptions) }, icon = { if (tab.key == "missing-items" && drawerUi.missingItemCount > 0) { BadgedBox(badge = { Badge { Text("${drawerUi.missingItemCount}") } }) { @@ -325,43 +430,23 @@ 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}"), - ) + 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, @@ -382,19 +467,20 @@ private fun InventoryNavHost( composable(Routes.HOME) { AllStoragesScreen( viewModel = drawerViewModel, - onOpenSettings = onOpenSettings, onOpenLocation = { hhId, locId -> navController.navigate(Routes.location(hhId, locId)) }, onOpenStorage = { hhId -> navController.navigate(Routes.storage(hhId)) }, + onOpenSearch = { hhId -> + navController.navigate(Routes.search(hhId)) { launchSingleTop = true } + }, ) } composable(Routes.DASHBOARD) { DashboardScreen( - onOpenSettings = onOpenSettings, onOpenLocation = { hhId, locId -> navController.navigate(Routes.location(hhId, locId)) }, @@ -423,6 +509,40 @@ 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 + // 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() }, ) } @@ -441,11 +561,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 -> @@ -492,7 +621,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 }, ) @@ -501,18 +630,63 @@ private fun InventoryNavHost( composable(Routes.MISSING_ITEMS) { MissingItemsScreen( onBack = { navController.popBackStack() }, - onOpenSettings = onOpenSettings, onOpenLocation = { hhId, locId -> navController.navigate(Routes.location(hhId, locId)) }, + households = drawerUi.entries.map { HouseholdOption(it.id, it.name) }, + 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 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 + } + } + } + } }, onBack = { navController.popBackStack() }, ) 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/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..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 @@ -1,11 +1,15 @@ 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 +25,25 @@ interface LocationApi { @Body body: CreateLocationRequest, ): LocationResponse - @DELETE("households/{household}/locations/{location}") + @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 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/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/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..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 @@ -1,11 +1,15 @@ 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 +27,28 @@ interface ShelfApi { @Body body: CreateShelfRequest, ): ShelfResponse - @DELETE("households/{household}/locations/{location}/shelves/{shelf}") + @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, ) } 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..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,8 @@ 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 import javax.inject.Singleton @@ -19,6 +21,12 @@ 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 class SessionCleaner @@ -31,6 +39,8 @@ class SessionCleaner private val hierarchyStore: HierarchyStore, private val favoritesStore: FavoritesStore, private val defaultHouseholdStore: DefaultHouseholdStore, + private val shelfViewStore: ShelfViewStore, + private val householdViewStore: HouseholdViewStore, ) { fun clear() { householdRepository.clear() @@ -40,5 +50,7 @@ class SessionCleaner hierarchyStore.clear() favoritesStore.clear() defaultHouseholdStore.clear() + shelfViewStore.clear() + householdViewStore.clear() } } 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..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,12 +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 (same pitfall as UpdateProductRequest). Explicit -// null = clear back to the derived default. +// 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 UpdateHouseholdThemeRequest( +data class UpdateHouseholdRequest( + 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 1f635dd..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 @@ -7,6 +7,26 @@ 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. + // + // 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. Same cache-staleness caveat as + // shelf_count above — refresh before trusting this after a shelf mutation. + val product_count: Int = 0, ) @Serializable @@ -24,3 +44,28 @@ data class CreateLocationRequest( val name: String, val type: String, ) + +@Serializable +data class UpdateLocationRequest( + val name: String, + val type: String, +) + +// `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? = null, + val target_location_id: Long? = null, + val deletion_batch_id: String, +) 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/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/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..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 @@ -8,6 +8,19 @@ 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. + // + // 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, ) @Serializable @@ -24,3 +37,23 @@ data class ShelfResponse( data class CreateShelfRequest( val name: String, ) + +@Serializable +data class UpdateShelfRequest( + val name: String, +) + +// `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? = null, + val target_shelf_id: Long? = null, + 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..23dd429 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/data/hierarchy/DeleteStrategy.kt @@ -0,0 +1,45 @@ +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"), +} + +/** + * 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/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..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 @@ -1,6 +1,7 @@ package dev.scuttle.inventory.data.location import dev.scuttle.inventory.data.dto.LocationDto +import dev.scuttle.inventory.data.hierarchy.LocationDeletion interface LocationRepository { fun getCached(householdId: Long): List? @@ -13,10 +14,28 @@ interface LocationRepository { type: String, ): LocationDto - suspend fun delete( + /** + * 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, + deletion: LocationDeletion, + ): 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..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 @@ -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.LocationDeletion import javax.inject.Inject class LocationRepositoryImpl @@ -26,11 +30,47 @@ class LocationRepositoryImpl cache[householdId] = (cache[householdId] ?: emptyList()) + created } - override suspend fun delete( + 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 -> + // 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( + householdId: Long, + ids: List, + ): List = + api.reorder(householdId, ReorderRequest(ids)).data.also { + cache[householdId] = it + } + + override suspend fun deleteWithStrategy( + householdId: Long, + locationId: Long, + deletion: LocationDeletion, ) { - api.delete(householdId, locationId) + api.delete( + householdId, + locationId, + DeleteLocationRequest( + 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/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/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/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/data/shelf/ShelfRepository.kt b/app/src/main/java/dev/scuttle/inventory/data/shelf/ShelfRepository.kt index bb14c1f..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 @@ -1,6 +1,7 @@ package dev.scuttle.inventory.data.shelf import dev.scuttle.inventory.data.dto.ShelfDto +import dev.scuttle.inventory.data.hierarchy.ShelfDeletion interface ShelfRepository { fun getCached( @@ -19,11 +20,30 @@ interface ShelfRepository { name: String, ): ShelfDto - suspend fun delete( + /** + * 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, + deletion: ShelfDeletion, + ): 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..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 @@ -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.ShelfDeletion import javax.inject.Inject class ShelfRepositoryImpl @@ -32,12 +36,48 @@ class ShelfRepositoryImpl cache[key] = (cache[key] ?: emptyList()) + created } - override suspend fun delete( + 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 + // 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( + 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, + deletion: ShelfDeletion, ) { - api.delete(householdId, locationId, shelfId) + api.delete( + householdId, + locationId, + shelfId, + DeleteShelfRequest( + strategy = deletion.strategy?.wire, + target_shelf_id = deletion.targetShelfId, + deletion_batch_id = deletion.batchId, + ), + ) val key = householdId to locationId cache[key] = cache[key]?.filter { it.id != shelfId } ?: emptyList() } 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/di/StorageModule.kt b/app/src/main/java/dev/scuttle/inventory/di/StorageModule.kt index 3259567..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,11 +9,15 @@ 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 +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 +56,18 @@ object StorageModule { @ApplicationContext context: Context, ): LanguageStore = SharedPrefsLanguageStore(context) + @Provides + @Singleton + fun provideShelfViewStore( + @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/app/DrawerViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/app/DrawerViewModel.kt index e3a4451..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 @@ -6,15 +6,23 @@ 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 dev.scuttle.inventory.ui.hierarchy.UndoOutcome 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 +35,36 @@ 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, + /** + * 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, +) + +/** + * 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, + val undoResult: UndoOutcome? = null, ) @HiltViewModel @@ -35,22 +73,36 @@ 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, + undoResult = del.undoResult, + ) + }.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 +112,130 @@ 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 { + // 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, undoResult = UndoOutcome.SUCCESS) } + store.refresh() + }.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/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/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/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 fb9f45b..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 @@ -17,7 +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.Settings +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 @@ -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,7 +49,10 @@ 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.common.shelfDisplayName import dev.scuttle.inventory.ui.theme.FrostCard import dev.scuttle.inventory.ui.theme.HouseholdAvatar @@ -61,7 +67,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 = {}, @@ -77,6 +82,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 = {}, @@ -105,12 +126,16 @@ 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 = openSearch) { + Icon(Icons.Default.Search, contentDescription = stringResource(R.string.nav_search)) + } 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)) - } }, ) }, @@ -157,7 +182,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, ) } @@ -227,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) }, ) @@ -238,6 +263,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/hierarchy/DeletePlan.kt b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeletePlan.kt new file mode 100644 index 0000000..455a2dd --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeletePlan.kt @@ -0,0 +1,37 @@ +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, +) { + /** An empty container is safe to delete outright — a plain confirm will do. */ + val needsStrategy: Boolean get() = contentCount > 0 +} + +// 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 new file mode 100644 index 0000000..6d8bf7c --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/DeleteStrategyDialog.kt @@ -0,0 +1,219 @@ +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]), 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, + 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 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 + // 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)) + } + } +} + +@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, + requiresTarget = true, + ), + StrategyOption( + strategy = ShelfDeleteStrategy.UNSORT_PRODUCTS, + labelRes = R.string.delete_strategy_unsort, + requiresTarget = false, + ), + StrategyOption( + strategy = ShelfDeleteStrategy.DELETE_PRODUCTS, + labelRes = 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, + requiresTarget = true, + ), + StrategyOption( + strategy = LocationDeleteStrategy.DELETE_CONTENTS, + labelRes = R.string.delete_strategy_delete, + requiresTarget = false, + ), + ) 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..a84bfcf --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/hierarchy/EditableRow.kt @@ -0,0 +1,102 @@ +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 +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 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 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( + name: String, + editMode: Boolean, + isSystem: Boolean, + selected: Boolean, + canMoveUp: Boolean, + canMoveDown: Boolean, + onClick: () -> Unit, + onRename: () -> Unit, + 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, + // 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( + 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, enabled = actionsEnabled) { + Icon(Icons.Default.Edit, contentDescription = stringResource(renameLabelRes)) + } + IconButton(onClick = onMoveUp, enabled = canMoveUp && actionsEnabled) { + Icon( + Icons.Default.KeyboardArrowUp, + contentDescription = stringResource(moveUpLabelRes), + ) + } + IconButton(onClick = onMoveDown, enabled = canMoveDown && actionsEnabled) { + Icon( + Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(moveDownLabelRes), + ) + } + } + } + } +} 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 3424e5d..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 @@ -1,8 +1,8 @@ package dev.scuttle.inventory.ui.home -import androidx.compose.foundation.background +import androidx.compose.animation.core.animateFloatAsState +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.Row import androidx.compose.foundation.layout.Spacer @@ -13,18 +13,18 @@ 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.Settings +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.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,36 +32,43 @@ 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 +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.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.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 +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 @OptIn(ExperimentalMaterial3Api::class) @@ -69,20 +76,45 @@ 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 = {}, + onOpenSearch: (householdId: Long) -> Unit = {}, localViewModel: AllStoragesViewModel = hiltViewModel(), ) { 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) + // 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), @@ -93,11 +125,29 @@ 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 { + // Search lost its bottom-nav tab (Task 7) but keeps a top-bar + // 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() }) { + 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)) + } } }, ) @@ -131,68 +181,98 @@ 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}", ) - IconButton(onClick = { onOpenStorage(entry.id) }) { - Icon( - Icons.Default.Add, - contentDescription = stringResource(R.string.all_storage_add_location_cd), - ) + 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) + // 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) + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + 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 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 = @@ -224,31 +304,48 @@ fun AllStoragesScreen( } } } - 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}"), + modifier = + Modifier.fillMaxWidth().testTag("home-location-${location.name}"), colors = CardDefaults.cardColors( containerColor = @@ -261,7 +358,8 @@ fun AllStoragesScreen( } else { FrostCard( onClick = { onOpenLocation(entry.id, location.id) }, - modifier = Modifier.fillMaxWidth().testTag("home-location-${location.name}"), + modifier = + Modifier.fillMaxWidth().testTag("home-location-${location.name}"), content = { rowContent() }, ) } @@ -275,22 +373,64 @@ 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() + } + } + + // 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) }, + onDismiss = { showHouseholdPicker = false }, + onPick = { householdId -> + showHouseholdPicker = false + onOpenSearch(householdId) }, ) } 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/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..1167c28 --- /dev/null +++ b/app/src/main/java/dev/scuttle/inventory/ui/households/HouseholdEditScreen.kt @@ -0,0 +1,319 @@ +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. + * 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 + * 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.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 + // 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 c51cc39..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 @@ -23,6 +23,28 @@ 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, + // 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 @@ -76,17 +98,32 @@ class HouseholdsViewModel fun leave(householdId: Long) = launchLoading { repository.leave(householdId) - _state.update { it.copy(households = repository.list()) } + _state.update { it.copy(households = repository.list(), leftHouseholdId = householdId) } 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.updateTheme(householdId, color, 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 +131,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/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/location/LocationDetailScreen.kt index 1508400..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 @@ -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 @@ -62,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 @@ -69,12 +76,22 @@ 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.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 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 +112,27 @@ 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 } + + // 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) } + 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 +142,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 +155,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)) + Text(locationName ?: 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 +176,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 +218,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 +270,108 @@ 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 = shelfDisplayName(shelf), + 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, + // 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, + // 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) + } 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) { - stringResource(R.string.location_shelf_missing_cd, shelf.name) + if (tabHasWarning) { + stringResource(R.string.location_shelf_missing_cd, shelfDisplayName(shelf)) } 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( + shelfDisplayName(shelf), + 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 +395,83 @@ 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() + } + } + + // 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 }, + 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/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/missing/MissingItemsScreen.kt index 91cc4d0..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 @@ -12,7 +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.Settings +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -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,17 +35,33 @@ 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) @Composable fun MissingItemsScreen( onBack: () -> Unit, - onOpenSettings: () -> 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 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( @@ -60,6 +79,13 @@ fun MissingItemsScreen( } }, actions = { + // Search lost its bottom-nav tab (Task 7) but keeps a top-bar 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.items.isNotEmpty()) { Icon( imageVector = Icons.Default.Warning, @@ -68,9 +94,6 @@ fun MissingItemsScreen( modifier = Modifier.padding(end = 16.dp), ) } - IconButton(onClick = onOpenSettings) { - Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.action_settings)) - } }, ) }, @@ -143,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/java/dev/scuttle/inventory/ui/products/ProductDetailScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/products/ProductDetailScreen.kt index 5497e30..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 @@ -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. + // 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) + 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 31d077f..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 @@ -59,6 +61,8 @@ 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.hierarchy.UndoOutcome import dev.scuttle.inventory.ui.theme.FrostCard /** @@ -137,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) { @@ -395,7 +437,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..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 @@ -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 @@ -22,9 +24,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 { @@ -56,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 @@ -75,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 @@ -202,14 +222,51 @@ 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() + // 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() } } } + 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) } /** @@ -251,7 +308,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/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/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt b/app/src/main/java/dev/scuttle/inventory/ui/shelves/ShelvesViewModel.kt index 71f85ec..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 @@ -3,17 +3,30 @@ 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 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 import kotlinx.coroutines.flow.update 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, @@ -22,22 +35,51 @@ 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, + /** + * 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, +// 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). +// (TooManyFunctions is baselined in detekt-baseline.xml, same as AuthViewModel.) @HiltViewModel 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 +93,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()) } @@ -59,14 +101,15 @@ 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 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 +120,299 @@ 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 exitDeleteMode() = _state.update { it.copy(deleteMode = false, selectedShelves = emptySet()) } + 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 toggleShelfSelection(shelfId: Long) = + fun exitEditMode() = + _state.update { + it.copy( + editMode = false, + listView = viewBeforeEdit, + selected = emptySet(), + pendingDelete = null, + moveTargets = emptyList(), + ) + } + + fun toggleSelection(shelfId: Long) = _state.update { state -> - val updated = - if (shelfId in state.selectedShelves) { - state.selectedShelves - shelfId - } else { - state.selectedShelves + shelfId + // 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 + val l = locationId + if (h == null || l == null) return + val shelf = _state.value.shelves.firstOrNull { it.id == shelfId } + // 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 + 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)) } + + // 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 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)) } - state.copy(selectedShelves = updated) + 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 } + } } + } - fun deleteSelected() { - val h = householdId ?: return - val l = locationId ?: return - val ids = _state.value.selectedShelves.toList() + /** + * 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 h = householdId + val l = locationId + if (h == null || l == null) return + val selectedIds = _state.value.selected + if (selectedIds.isEmpty()) return + + launchLoading { + val shelves = orderShelves(repository.list(h, l)) + _state.update { it.copy(shelves = shelves) } + + 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) }, + ) + } + } + } + + fun confirmDelete( + strategy: ShelfDeleteStrategy?, + targetId: Long?, + ) { + val h = householdId + val l = locationId + if (h == null || l == null) return + 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 + // 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) } + 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( - deleteMode = false, - selectedShelves = emptySet(), - shelves = - it.shelves.filter { s -> - s.id !in ids - }, + editMode = false, + listView = viewBeforeEdit, + selected = emptySet(), + pendingDelete = null, + moveTargets = emptyList(), ) } + + failure?.let { throw it } + } + } + + fun cancelDelete() = _state.update { it.copy(pendingDelete = null, moveTargets = emptyList()) } + + fun undoDelete() { + val h = householdId ?: return + val batchId = _state.value.lastBatchId ?: return + launchLoading { + // 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, + * 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/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt b/app/src/main/java/dev/scuttle/inventory/ui/storage/StorageOverviewScreen.kt index 3ceb204..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 @@ -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,26 +37,26 @@ 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.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -68,8 +68,15 @@ 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.UndoOutcome +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 +89,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 +102,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 +180,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 +195,70 @@ 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, + // 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 + 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 +280,121 @@ 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() + } + } + + // 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 }, + 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() + 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..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 @@ -6,17 +6,28 @@ 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 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 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 +37,37 @@ 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, + /** + * 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, +// 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 +83,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 +91,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 +100,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 +110,278 @@ 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 deleteLocation(locationId: Long) { + 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 { - repository.delete(id, locationId) - _state.update { it.copy(locations = it.locations.filter { l -> l.id != locationId }) } + 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 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)) } + + // 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. + _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 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 } + } + } + } + + /** + * 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()) { + // 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 + // 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() + } + + 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() { + val id = householdId ?: return + val batchId = _state.value.lastBatchId ?: return + launchLoading { + // 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 }) + 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 fe3f3d4..ce98c20 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -31,8 +31,9 @@ Dashboard Opslag 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 @@ -58,10 +59,10 @@ ⚠ Voorraadwaarschuwing Toevoegen aan favorieten Verwijderen uit favorieten + %1$s inklappen + %1$s uitklappen - \"%s\" verwijderen? - Alle planken en producten hierin worden permanent verwijderd. \"%s\" verwijderen? Dit product wordt permanent van de plank verwijderd. @@ -71,27 +72,37 @@ 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 - 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 @@ -177,6 +188,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 @@ -198,4 +214,18 @@ 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. + Product verwijderd. + Ongesorteerd diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 68f92a0..6e680c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,8 +31,9 @@ Dashboard Storage 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 @@ -58,10 +59,10 @@ ⚠ Stock warning Add to favorites Remove favorite + Collapse %1$s + Expand %1$s - Delete \"%s\"? - All shelves and products inside will be permanently deleted. Delete \"%s\"? This product will be permanently removed from the shelf. @@ -71,27 +72,37 @@ 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 - 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 @@ -177,6 +188,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 @@ -198,4 +214,18 @@ 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. + Product deleted. + Unsorted 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/DashboardViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/DashboardViewModelTest.kt index bf6fe36..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 @@ -50,11 +51,6 @@ class DashboardViewModelTest { name: String, type: String, ) = LocationDto(99, name, type) - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} } private class FakeShelfRepository( @@ -75,12 +71,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( @@ -140,7 +130,7 @@ class DashboardViewModelTest { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" override suspend fun uploadImage( householdId: Long, @@ -187,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/DeletePlanTest.kt b/app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt new file mode 100644 index 0000000..94f6fb8 --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/DeletePlanTest.kt @@ -0,0 +1,40 @@ +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) + + // 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) + + 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) + + assertTrue(plan.needsStrategy) + } + + // "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/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..da818b4 --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/DeleteRequestSerializationTest.kt @@ -0,0 +1,239 @@ +package dev.scuttle.inventory + +import dev.scuttle.inventory.data.dto.DeleteLocationRequest +import dev.scuttle.inventory.data.dto.DeleteShelfRequest +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 (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: + * + * - 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_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_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) + } + + @Test + fun update_location_request_has_no_defaulted_properties() { + assertNoDefaultedProperties(UpdateLocationRequest.serializer().descriptor) + } + + @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. + * + * 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() { + 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")), + ) + } + + /** + * 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( + "${descriptor.serialName}.${descriptor.getElementName(i)} must not have a default value: " + + "encodeDefaults=false would silently drop it from the request body", + descriptor.isElementOptional(i), + ) + } + } +} diff --git a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt index c51e266..709e4d1 100644 --- a/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/DrawerViewModelTest.kt @@ -6,21 +6,28 @@ 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.app.DrawerViewModel +import dev.scuttle.inventory.ui.hierarchy.UndoOutcome 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 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 +50,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 +84,35 @@ class DrawerViewModelTest { type: String, ) = LocationDto(99, name, type) - override suspend fun delete( + 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 } } @@ -81,12 +134,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( @@ -146,7 +193,7 @@ class DrawerViewModelTest { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" override suspend fun uploadImage( householdId: Long, @@ -173,11 +220,21 @@ 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 } + 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 +243,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 +277,7 @@ class DrawerViewModelTest { ), ), ) - val vm = DrawerViewModel(store, locRepo) + val vm = viewModel(store, locRepo) assertEquals(1, vm.state.value.missingItemCount) } @@ -229,7 +286,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) @@ -261,8 +318,9 @@ class DrawerViewModelTest { FakeLocationRepository(), FakeShelfRepository(), FakeProductRepository(), + UnconfinedTestDispatcher(), ) - val vm = DrawerViewModel(store, FakeLocationRepository()) + val vm = viewModel(store, FakeLocationRepository()) store.refresh(userInitiated = true) @@ -274,72 +332,374 @@ 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 + } + + @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 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))), + ) + 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_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() - .name, + .locations + .isEmpty(), ) } + + @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) + // 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 + 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) + } } 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 }) + } +} diff --git a/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt b/app/src/test/java/dev/scuttle/inventory/HierarchyStoreTest.kt index 8c7a75f..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 @@ -56,11 +57,6 @@ class HierarchyStoreTest { name: String, type: String, ) = LocationDto(99, name, type) - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} } private class FakeShelfRepository( @@ -81,12 +77,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( @@ -146,7 +136,7 @@ class HierarchyStoreTest { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" override suspend fun uploadImage( householdId: Long, @@ -168,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) @@ -190,6 +181,7 @@ class HierarchyStoreTest { FakeLocationRepository(), FakeShelfRepository(), FakeProductRepository(), + UnconfinedTestDispatcher(), ) store.refresh(userInitiated = true) @@ -214,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) @@ -231,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) @@ -250,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( @@ -290,6 +285,7 @@ class HierarchyStoreTest { ), ), ), + UnconfinedTestDispatcher(), ) store.refresh(userInitiated = true) 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"), + ) + } +} diff --git a/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/HouseholdsViewModelTest.kt index d5401f0..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 @@ -38,13 +39,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] } } @@ -125,6 +127,78 @@ 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 { @@ -154,4 +228,26 @@ class HouseholdsViewModelTest { .name, ) } + + @Test + fun leave_sets_leftHouseholdId_so_the_edit_screen_can_navigate_back() = + runTest { + // 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)) + assertNull(viewModel.state.value.leftHouseholdId) + + viewModel.leave(householdId = 1) + + assertEquals(1L, viewModel.state.value.leftHouseholdId) + } } 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/MissingItemsViewModelTest.kt b/app/src/test/java/dev/scuttle/inventory/MissingItemsViewModelTest.kt index 6dda025..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 @@ -52,11 +53,6 @@ class MissingItemsViewModelTest { name: String, type: String, ) = LocationDto(99, name, type) - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} } private class FakeShelfRepository( @@ -77,12 +73,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( @@ -142,7 +132,7 @@ class MissingItemsViewModelTest { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" override suspend fun uploadImage( householdId: Long, @@ -167,6 +157,7 @@ class MissingItemsViewModelTest { FakeLocationRepository(locationsByHousehold), FakeShelfRepository(shelvesByLocation), FakeProductRepository(productsByShelf), + UnconfinedTestDispatcher(), ) store.loadFromCache() return MissingItemsViewModel(store) @@ -195,6 +186,7 @@ class MissingItemsViewModelTest { FakeLocationRepository(), FakeShelfRepository(), FakeProductRepository(), + UnconfinedTestDispatcher(), ) val vm = MissingItemsViewModel(store) 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/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 0af080f..db1348c 100644 --- a/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ProductsViewModelTest.kt @@ -7,21 +7,26 @@ 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.UnconfinedTestDispatcher 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 +37,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 +122,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( @@ -146,11 +164,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 +184,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 { @@ -198,14 +205,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) + val store = HierarchyStore(FakeHouseholdRepository(), locations, shelves, products, UnconfinedTestDispatcher()) + return ProductsViewModel(products, locations, shelves, search, store, restoreRepository) } @Test @@ -314,7 +343,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) @@ -326,6 +360,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 { @@ -368,6 +434,138 @@ 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 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 { + 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/ScanDeliveryActionTest.kt b/app/src/test/java/dev/scuttle/inventory/ScanDeliveryActionTest.kt new file mode 100644 index 0000000..6c7720f --- /dev/null +++ b/app/src/test/java/dev/scuttle/inventory/ScanDeliveryActionTest.kt @@ -0,0 +1,102 @@ +package dev.scuttle.inventory + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +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")) + } + + // --- 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)) + } +} 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, + ) + } } diff --git a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt index 6f3e7b1..7a6e755 100644 --- a/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/SessionCleanerTest.kt @@ -13,7 +13,10 @@ 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 kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Assert.assertTrue import org.junit.Test @@ -49,11 +52,6 @@ class SessionCleanerTest { type: String, ) = throw NotImplementedError() - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} - override fun clear() { cleared = true } @@ -78,12 +76,6 @@ class SessionCleanerTest { name: String, ) = throw NotImplementedError() - override suspend fun delete( - householdId: Long, - locationId: Long, - shelfId: Long, - ) {} - override fun clear() { cleared = true } @@ -150,7 +142,7 @@ class SessionCleanerTest { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" override fun clear() { cleared = true @@ -189,6 +181,34 @@ class SessionCleanerTest { } } + private class RecordingShelfViewStore : ShelfViewStore { + var cleared = false + + override fun isListView() = false + + override fun setListView(listView: Boolean) = Unit + + override fun clear() { + cleared = true + } + } + + 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 @@ -199,9 +219,21 @@ class SessionCleanerTest { val product = RecordingProductRepo() val favorites = RecordingFavoritesStore() val defaultHousehold = RecordingDefaultHouseholdStore() - val store = HierarchyStore(household, location, shelf, product) - - SessionCleaner(household, location, shelf, product, store, favorites, defaultHousehold).clear() + val shelfView = RecordingShelfViewStore() + val householdView = RecordingHouseholdViewStore() + val store = HierarchyStore(household, location, shelf, product, UnconfinedTestDispatcher()) + + SessionCleaner( + household, + location, + shelf, + product, + store, + favorites, + defaultHousehold, + shelfView, + householdView, + ).clear() assertTrue(household.cleared) assertTrue(location.cleared) @@ -209,5 +241,7 @@ class SessionCleanerTest { assertTrue(product.cleared) assertTrue(favorites.cleared) assertTrue(defaultHousehold.cleared) + assertTrue(shelfView.cleared) + assertTrue(householdView.cleared) } } 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 a77d644..7722567 100644 --- a/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/ShelvesViewModelTest.kt @@ -1,14 +1,25 @@ 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.hierarchy.UndoOutcome 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 +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 ShelvesViewModelTest { @get:Rule @@ -18,6 +29,20 @@ 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() + 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, @@ -42,27 +67,129 @@ class ShelvesViewModelTest { return dto } - override suspend fun delete( + 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 + } + + 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 -> + val index = items.indexOfFirst { it.id == id } + if (index >= 0) items[index] = items[index].copy(position = i) + } + // ...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( + householdId: Long, + locationId: Long, + 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 } } } + 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 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 +198,689 @@ 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) - viewModel.load(householdId = 1, locationId = 1) + vm.load(householdId = 1, locationId = 1) - assertEquals("offline", viewModel.state.value.error) + assertEquals("offline", vm.state.value.error) } @Test - fun enter_delete_mode_toggles_flag_and_clears_selection() = + 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) + + vm.load(householdId = 1, locationId = 1) + + assertEquals( + listOf("Top", "Bottom", "Unsorted"), + vm.state.value.shelves + .map { it.name }, + ) + } + + @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, "Bottom", 1, 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) + + vm.moveUp(3L) + + assertEquals( + listOf("Top", "Bottom", "Middle"), + vm.state.value.shelves + .map { it.name }, + ) + } + + @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) + + vm.moveDown(1L) + + assertEquals( + listOf("Middle", "Top", "Bottom"), + vm.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 vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + + vm.moveUp(1L) + + assertEquals( + listOf("Top"), + vm.state.value.shelves + .map { it.name }, + ) + } + + @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 { + // 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, "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) + + vm.moveUp(3L) + + assertEquals(listOf(1L, 3L, 2L), repo.lastReorderIds) + // The system shelf still renders, still last, untouched by the reorder. + assertEquals( + listOf("Top", "Bottom", "Middle", "Unsorted"), + vm.state.value.shelves + .map { it.name }, + ) + } + + @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 viewModel = ShelvesViewModel(repo) - viewModel.load(householdId = 1, locationId = 1) + 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 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 { + // 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) - viewModel.enterDeleteMode() - assertTrue(viewModel.state.value.deleteMode) assertTrue( - viewModel.state.value.selectedShelves + vm.state.value.selected .isEmpty(), ) + } + + @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 { + 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, + ) + 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) - viewModel.toggleShelfSelection(1L) - assertTrue(1L in viewModel.state.value.selectedShelves) + 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) - viewModel.exitDeleteMode() - assertFalse(viewModel.state.value.deleteMode) + 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_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( - viewModel.state.value.selectedShelves - .isEmpty(), + vm.state.value.pendingDelete + ?.needsStrategy == true, ) } @Test - fun delete_selected_removes_checked_shelves() = + 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 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 viewModel = ShelvesViewModel(repo) - viewModel.load(householdId = 1, locationId = 1) + val vm = viewModel(repo) + vm.load(householdId = 1, locationId = 1) + vm.enterEditMode() + vm.toggleSelection(1L) + vm.toggleSelection(2L) + vm.toggleSelection(3L) + vm.requestDelete() - viewModel.enterDeleteMode() - viewModel.toggleShelfSelection(1L) - viewModel.deleteSelected() + vm.confirmDelete(ShelfDeleteStrategy.DELETE_PRODUCTS, targetId = null) - assertEquals(1, viewModel.state.value.shelves.size) + // 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( - "Bottom", - viewModel.state.value.shelves - .first() - .name, + setOf(2L, 3L), + vm.state.value.shelves + .map { it.id } + .toSet(), ) - assertFalse(viewModel.state.value.deleteMode) + 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 { + 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) + // 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 + 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()) } } 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 c96af4b..ed4c559 100644 --- a/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt +++ b/app/src/test/java/dev/scuttle/inventory/StorageOverviewViewModelTest.kt @@ -1,23 +1,25 @@ 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.hierarchy.UndoOutcome 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 +import java.io.IOException class StorageOverviewViewModelTest { @get:Rule @@ -27,6 +29,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 { @@ -44,161 +62,110 @@ class StorageOverviewViewModelTest { return dto } - override suspend fun delete( + 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 + } + + // 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 -> + 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 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 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 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 +174,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 +206,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() { @@ -287,10 +254,657 @@ class StorageOverviewViewModelTest { gate.await() return LocationDto(id = 1, name = name, type = type) } - - override suspend fun delete( - householdId: Long, - 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 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 { + // 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 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 { + 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) + // 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 + 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) + } + + // 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. } 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(), + ) + } +} diff --git a/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt b/app/src/test/java/dev/scuttle/inventory/TestHierarchy.kt index 1c82241..e4acde9 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 @@ -27,11 +29,6 @@ object TestHierarchy { name: String, type: String, ) = throw NotImplementedError() - - override suspend fun delete( - householdId: Long, - locationId: Long, - ) {} } private object EmptyShelves : ShelfRepository { @@ -50,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 { @@ -117,9 +108,21 @@ object TestHierarchy { householdId: Long, shelfId: Long, productId: Long, - ) {} + ) = "batch" } - 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) } 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..8f43f25 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-storage-architecture-editing-android.md @@ -0,0 +1,1795 @@ +# 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, + // 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 +// 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, + // 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 +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 + +> **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:** +- 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, 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) + } +} +``` + +- [ ] **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. 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 +} +``` + +- [ ] **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 + + // 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 = 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 ?: 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` (`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** + +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. 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) + + viewModel.enterEditMode() + viewModel.toggleSelection(1L) + viewModel.requestDelete() + + 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 + 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, 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. + +**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.