Skip to content

fix: refresh gap suggestions from the live wardrobe and actually save from them - #52

Merged
jvsena42 merged 7 commits into
mainfrom
fix/gaps-suggestions-refresh
Aug 3, 2026
Merged

fix: refresh gap suggestions from the live wardrobe and actually save from them#52
jvsena42 merged 7 commits into
mainfrom
fix/gaps-suggestions-refresh

Conversation

@jvsena42

@jvsena42 jvsena42 commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes #42 — "added a polo shirt but it is still displayed in the suggestions".

Two independent defects each reproduced that report:

  1. Suggestions never refreshed. GapsViewModel computed the list once in init (and on the LoadGaps intent, which is wired only to the error-state Retry button), reading the wardrobe through the one-shot getAll() rather than observeAll(). The Gaps screen stays alive on both platforms — HorizontalPager on Android, @StateObject on iOS — so adding an item on the Wardrobe tab left the suggestion on screen until the ViewModel happened to be recreated. That is the "updated later" in the issue thread.
  2. The Gaps "Add to Wardrobe" button discarded the item. Both platforms opened AddItemSheet with onSave = { _,_,_,_,_,_,_,_ -> dismiss } — no repository call, no photo written. A user who tapped the Polo shirt suggestion, filled the form and saved got nothing stored, so the suggestion legitimately stayed.

Suggestions are now derived from the live wardrobe rather than fetched-and-cached, and the ownership filter applies to AI recommendations as well as the local capsule fallback — so both lists re-filter the instant an item is added or deleted anywhere, without spending another Claude request. The AI list itself is still fetched only on init, on an AI-availability change, or on an explicit retry.

Changes

  • excludingOwned extracted into GapRecommendation.kt and applied to both the AI list and the capsule fallback. Matches on Subcategory alone (mappedCategory disagrees by design) and keeps recommendations the AI left untagged, since a null subcategory has nothing to compare against.
  • GapsViewModel restructuredcombine(observeAll().catch { … }, _uiState).stateIn(viewModelScope, Eagerly), mirroring WardrobeViewModel. A wardrobe emission never triggers an AI call; a test pins the call count.
  • Gaps save wired through GapsIntent.AddItem on GapsViewModel, which already had WardrobeRepository injected. Resolving a second WardrobeViewModel was rejected: it is a Koin factoryOf, so on iOS that would leak an uncancelled observeAll() collector per screen, and on Android it would share the Wardrobe tab's instance and close its sheet too.
  • New prefillItem param on AddItemSheet (both platforms). Seeding through existingItem put the sheet in editing mode — "Edit item", "Save changes", AI badge hidden — for an item that did not exist yet.
  • Effects are now collected on the Gaps screen on both platforms; previously GapsEffect.ShowError was dropped on the floor everywhere.
  • AI failure message defaulted — a null Throwable.message left state.error null, which rendered the "your wardrobe looks complete" screen on a failure.
  • iOS: clear selectedSubcategory when the category changes, mirroring Android. Without it an iOS user could save a BOTTOM item carrying Subcategory.POLO, which permanently suppressed the Polo shirt suggestion — the same symptom as Check fallback suggestions logic #42 from a different cause. Reset in the picker action rather than .onChange(of: selectedCategory), which would also fire while seeding the form and wipe a legitimate prefill.
  • Test fakes extended: FakeWardrobeRepository.addItem silently dropped subcategory/fit/material, and getGapRecommendations was hardcoded to an empty list — neither the regression test nor the no-extra-AI-call assertion was expressible before.
  • GapRecommendationParsingTest re-implemented the filter expression inline instead of calling production code, so it would have kept passing if production stopped filtering; moved to GapRecommendationFilterTest in commonTest.
  • Docs: CLAUDE.md now requires branching off main, and the documented single-test command is corrected — :shared:allTests is an aggregate task that rejects --tests.

Test plan

  • ./gradlew :shared:allTests — 160 tests green, including 18 new in GapsViewModelTest and 7 in GapRecommendationFilterTest.
  • Key regression tests: adding an item to the wardrobe removes its suggestion without any intent, adding an item re-filters the AI list without a new AI call, adding the recommended item removes it from the suggestions.
  • ./gradlew :composeApp:assembleDebug and ./gradlew detekt clean.
  • Manual (needs a real photo, so out of the journeys): add a polo on the Wardrobe tab, swipe to Gaps — "Polo shirt" is gone with no retry; delete it — it comes back.
  • New journey journeys/gaps-add-suggestion.xml covers the prefill sheet reading "Add new item" / "Save to wardrobe" rather than the editing copy, stopping before the photo. Existing journeys/gaps-common-suggestions.xml is untouched and must still pass.

Not verified: the Swift changes are not compile-checked — linkDebugFrameworkIosSimulatorArm64 is skipped on Linux and there is no Xcode in this environment. The Kotlin iosSimulatorArm64 compilation does pass, so the exported GapsIntentAddItem / GapsEffectItemAdded symbols exist, but the iOS app needs a build on macOS before merge.

Related

Filed #51 while working on this: Subcategory.HOODIE sits under Category.TOP while the capsule hoodie gap is Category.OUTERWEAR, so the pre-filled sheet shows a subcategory its own dropdown cannot re-select. Not blocking — the filter matches on subcategory alone precisely because those two disagree.

Checklist

  • ./gradlew detekt passes
  • Tested on Android
  • Tested on iOS
  • Updated documentation (if applicable)

🤖 Generated with Claude Code

jvsena42 and others added 7 commits August 3, 2026 07:58
FakeWardrobeRepository.addItem dropped the three optional fields, so no test
could assert that adding an item covers a suggestion's subcategory, and
getGapRecommendations was hardcoded to an empty list with no call counter —
leaving no way to prove a wardrobe write does not trigger a new paid AI call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The filter lived inline in GapsViewModel and was applied to the capsule
fallback only. Pulling it into the domain model lets the AI list reuse it and
makes it testable on its own; GapRecommendationParsingTest re-implemented the
expression instead of calling it, so it would have kept passing even if
production stopped filtering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suggestions were computed once in init and on the LoadGaps intent, which is
wired only to the error-state retry button — so adding a polo shirt on the
Wardrobe tab left "Polo shirt" on the Gaps tab until the ViewModel happened to
be recreated. The screen stays alive on both platforms (HorizontalPager on
Android, @StateObject on iOS), so that could be a long while.

State is now derived from observeAll(), and the ownership filter applies to the
AI list as well as the capsule fallback: a wardrobe write re-filters both for
free, without spending another Claude request. The AI list is still fetched only
on init, on an availability change, or on an explicit retry.

Also default the AI failure message — a null Throwable.message left state.error
null, which rendered the "your wardrobe looks complete" screen on a failure.

Fixes part of #42

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Add to Wardrobe" on a gap opened the add sheet with
`onSave = { _,_,_,_,_,_,_,_ -> dismiss }` on both platforms: the photo, the
name and every tag the user filled in were dropped on the floor, so the
suggestion they had just satisfied stayed on the list.

GapsViewModel now owns the save — it already had the WardrobeRepository — so
the item goes through the same path as the Wardrobe tab and the derived state
drops its suggestion on the resulting DB emission. Resolving a second
WardrobeViewModel here would have leaked an uncancelled observeAll() collector
on iOS, where it is a Koin factory, and coupled the two screens on Android,
where it is not.

The pre-fill also moves off `existingItem` onto a new `prefillItem`: seeding
through `existingItem` put the sheet in editing mode, so it read "Edit item" /
"Save changes" and hid the AI badge for an item that did not exist yet.

Neither screen collected its effects before, so errors were silent; both do now.

Fixes #42

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Android resets it (AddItemSheet.kt) but iOS did not, so picking Tops → Polo and
then switching to Bottoms saved a BOTTOM item carrying Subcategory.POLO. Gap
suggestions match on subcategory alone, so that item silently suppressed the
"Polo shirt" suggestion forever — the same symptom as #42, from a different
cause.

Reset in the picker action rather than .onChange(of: selectedCategory), which
also fires while seeding the form from existingItem/prefillItem and would wipe
a legitimately pre-filled subcategory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the prefill sheet reading "Add new item" / "Save to wardrobe" rather
than the editing copy, and stops before the photo, which is external input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`:shared:allTests` is an aggregate lifecycle task and rejects `--tests`; the
per-target `:shared:testAndroidHostTest` accepts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jvsena42
jvsena42 enabled auto-merge August 3, 2026 11:13
@jvsena42
jvsena42 merged commit 8e6b4e4 into main Aug 3, 2026
2 checks passed
@jvsena42
jvsena42 deleted the fix/gaps-suggestions-refresh branch August 3, 2026 11:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Check fallback suggestions logic

1 participant