From ec40f328f39c6a104ed524043a29462fcaba1bf8 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 3 Aug 2026 07:58:30 -0300 Subject: [PATCH 1/7] test: record subcategory, fit, material and AI gap calls in the fake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../github/worn/fake/FakeWardrobeRepository.kt | 16 ++++++++++++++-- .../kotlin/com/github/worn/fake/TestData.kt | 3 +++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/shared/src/commonTest/kotlin/com/github/worn/fake/FakeWardrobeRepository.kt b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeWardrobeRepository.kt index f12a305..16c5329 100644 --- a/shared/src/commonTest/kotlin/com/github/worn/fake/FakeWardrobeRepository.kt +++ b/shared/src/commonTest/kotlin/com/github/worn/fake/FakeWardrobeRepository.kt @@ -26,6 +26,13 @@ class FakeWardrobeRepository : WardrobeRepository { var updateItemError: Throwable? = null var observeAllError: Throwable? = null + var gapRecommendations: List = emptyList() + var gapRecommendationsError: Throwable? = null + + /** Lets tests assert that a wardrobe write never triggers a new (paid) AI call. */ + var gapRecommendationCalls = 0 + private set + val deletedIds = mutableListOf() fun addItems(vararg newItems: ClothingItem) { @@ -65,6 +72,9 @@ class FakeWardrobeRepository : WardrobeRepository { category = category, colors = colors, seasons = seasons, + subcategory = subcategory, + fit = fit, + material = material, photoPath = "/photos/fake.jpg", createdAt = Clock.System.now().toEpochMilliseconds(), ) @@ -85,8 +95,10 @@ class FakeWardrobeRepository : WardrobeRepository { return Result.success(Unit) } - override suspend fun getGapRecommendations(): Result> = - Result.success(emptyList()) + override suspend fun getGapRecommendations(): Result> { + gapRecommendationCalls++ + return gapRecommendationsError?.let { Result.failure(it) } ?: Result.success(gapRecommendations) + } override suspend fun analyzeProspectiveItem(imageBytes: ByteArray): Result = Result.success( diff --git a/shared/src/commonTest/kotlin/com/github/worn/fake/TestData.kt b/shared/src/commonTest/kotlin/com/github/worn/fake/TestData.kt index 729f738..bc878bb 100644 --- a/shared/src/commonTest/kotlin/com/github/worn/fake/TestData.kt +++ b/shared/src/commonTest/kotlin/com/github/worn/fake/TestData.kt @@ -4,6 +4,7 @@ import com.github.worn.domain.model.Category import com.github.worn.domain.model.ClothingItem import com.github.worn.domain.model.Outfit import com.github.worn.domain.model.Season +import com.github.worn.domain.model.Subcategory fun clothingItem( id: String = "item-1", @@ -13,6 +14,7 @@ fun clothingItem( seasons: List = listOf(Season.SUMMER), tags: List = emptyList(), description: String? = null, + subcategory: Subcategory? = null, photoPath: String = "/photos/$id.jpg", createdAt: Long = 1_000_000L, ): ClothingItem = ClothingItem( @@ -23,6 +25,7 @@ fun clothingItem( seasons = seasons, tags = tags, description = description, + subcategory = subcategory, photoPath = photoPath, createdAt = createdAt, ) From 56c78befefeecc4ddd9f4c22cf76459060adb14f Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 3 Aug 2026 07:58:31 -0300 Subject: [PATCH 2/7] refactor: extract the owned-subcategory gap filter into excludingOwned 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) --- .../GapRecommendationParsingTest.kt | 13 --- .../worn/domain/model/GapRecommendation.kt | 23 ++++ .../worn/model/GapRecommendationFilterTest.kt | 102 ++++++++++++++++++ 3 files changed, 125 insertions(+), 13 deletions(-) create mode 100644 shared/src/commonTest/kotlin/com/github/worn/model/GapRecommendationFilterTest.kt diff --git a/shared/src/androidHostTest/kotlin/com/github/worn/repository/GapRecommendationParsingTest.kt b/shared/src/androidHostTest/kotlin/com/github/worn/repository/GapRecommendationParsingTest.kt index f584d5d..e7701eb 100644 --- a/shared/src/androidHostTest/kotlin/com/github/worn/repository/GapRecommendationParsingTest.kt +++ b/shared/src/androidHostTest/kotlin/com/github/worn/repository/GapRecommendationParsingTest.kt @@ -117,17 +117,4 @@ class GapRecommendationParsingTest { assertTrue(suggestion.colors.isNotEmpty(), "Colors should not be empty") } } - - @Test - fun `capsule wardrobe filtering excludes owned subcategories`() { - val ownedSubcategories = setOf(Subcategory.JEANS, Subcategory.T_SHIRT, Subcategory.SNEAKERS) - - val filtered = capsuleWardrobeSuggestions.filter { it.subcategory !in ownedSubcategories } - - assertTrue(filtered.none { it.subcategory == Subcategory.JEANS }) - assertTrue(filtered.none { it.subcategory == Subcategory.T_SHIRT }) - assertTrue(filtered.none { it.subcategory == Subcategory.SNEAKERS }) - assertTrue(filtered.any { it.subcategory == Subcategory.CHINOS }) - assertTrue(filtered.any { it.subcategory == Subcategory.HENLEY }) - } } diff --git a/shared/src/commonMain/kotlin/com/github/worn/domain/model/GapRecommendation.kt b/shared/src/commonMain/kotlin/com/github/worn/domain/model/GapRecommendation.kt index b96be92..b37bfdb 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/domain/model/GapRecommendation.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/domain/model/GapRecommendation.kt @@ -11,3 +11,26 @@ data class GapRecommendation( val material: Material? = null, val mappedCategory: Category = Category.TOP, ) + +/** + * Drops every recommendation for a [Subcategory] the wardrobe already covers. + * + * Applied to the AI list as well as the capsule fallback. The model is given the wardrobe but is + * not forbidden from suggesting something already in it, and re-asking after every add would cost + * one paid request per item — filtering here keeps both lists honest for free, and reactively. + * + * Matching is on [Subcategory] alone, deliberately ignoring [GapRecommendation.mappedCategory]: + * the two can disagree (the "Navy zip-up hoodie" gap is OUTERWEAR while `subcategoriesFor` files + * HOODIE under TOP), so pairing them would resurrect suggestions the user has already satisfied. + * + * A recommendation with no subcategory is kept. [GapRecommendation.subcategory] is null whenever + * the AI omitted the field or sent a value we don't model — lenient parsing nulls it — so there is + * nothing to compare against; dropping it would hide a legitimate suggestion, whereas keeping it + * can at worst repeat one. + */ +fun List.excludingOwned(ownedItems: List): List { + val owned = ownedItems.mapNotNullTo(mutableSetOf()) { it.subcategory } + return filter { recommendation -> + recommendation.subcategory?.let { it !in owned } ?: true + } +} diff --git a/shared/src/commonTest/kotlin/com/github/worn/model/GapRecommendationFilterTest.kt b/shared/src/commonTest/kotlin/com/github/worn/model/GapRecommendationFilterTest.kt new file mode 100644 index 0000000..fea59bc --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/worn/model/GapRecommendationFilterTest.kt @@ -0,0 +1,102 @@ +package com.github.worn.model + +import com.github.worn.domain.model.Category +import com.github.worn.domain.model.GapRecommendation +import com.github.worn.domain.model.Subcategory +import com.github.worn.domain.model.capsuleWardrobeSuggestions +import com.github.worn.domain.model.excludingOwned +import com.github.worn.fake.clothingItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GapRecommendationFilterTest { + + private fun recommendation(name: String, subcategory: Subcategory?) = GapRecommendation( + itemName = name, + category = "TOPS", + pairingCount = 0, + subcategory = subcategory, + ) + + @Test + fun `drops recommendations whose subcategory is already owned`() { + val recommendations = listOf( + recommendation("Polo shirt", Subcategory.POLO), + recommendation("White dress shirt", Subcategory.DRESS_SHIRT), + ) + + val filtered = recommendations.excludingOwned( + listOf(clothingItem(id = "owned-polo", subcategory = Subcategory.POLO)), + ) + + assertEquals(listOf("White dress shirt"), filtered.map { it.itemName }) + } + + @Test + fun `keeps recommendations for subcategories that are not owned`() { + val recommendations = listOf(recommendation("Chino pants", Subcategory.CHINOS)) + + val filtered = recommendations.excludingOwned( + listOf(clothingItem(subcategory = Subcategory.JEANS)), + ) + + assertEquals(recommendations, filtered) + } + + @Test + fun `keeps a recommendation with no subcategory`() { + val recommendations = listOf(recommendation("Something the AI did not tag", null)) + + val filtered = recommendations.excludingOwned( + listOf(clothingItem(subcategory = Subcategory.POLO)), + ) + + assertEquals(recommendations, filtered) + } + + @Test + fun `items with no subcategory suppress nothing`() { + val recommendations = listOf(recommendation("Polo shirt", Subcategory.POLO)) + + val filtered = recommendations.excludingOwned(listOf(clothingItem(subcategory = null))) + + assertEquals(recommendations, filtered) + } + + @Test + fun `an empty wardrobe returns the whole list`() { + assertEquals( + capsuleWardrobeSuggestions, + capsuleWardrobeSuggestions.excludingOwned(emptyList()), + ) + } + + @Test + fun `matches on subcategory regardless of the item category`() { + // The capsule hoodie is mappedCategory = OUTERWEAR while the item is filed as a TOP; + // matching on subcategory alone is what keeps the suggestion from coming back. + val filtered = capsuleWardrobeSuggestions.excludingOwned( + listOf(clothingItem(category = Category.TOP, subcategory = Subcategory.HOODIE)), + ) + + assertTrue(filtered.none { it.subcategory == Subcategory.HOODIE }) + } + + @Test + fun `capsule suggestions exclude every owned subcategory`() { + val owned = listOf( + clothingItem(id = "owned-1", subcategory = Subcategory.JEANS), + clothingItem(id = "owned-2", subcategory = Subcategory.T_SHIRT), + clothingItem(id = "owned-3", subcategory = Subcategory.SNEAKERS), + ) + + val filtered = capsuleWardrobeSuggestions.excludingOwned(owned) + + assertTrue(filtered.none { it.subcategory == Subcategory.JEANS }) + assertTrue(filtered.none { it.subcategory == Subcategory.T_SHIRT }) + assertTrue(filtered.none { it.subcategory == Subcategory.SNEAKERS }) + assertTrue(filtered.any { it.subcategory == Subcategory.CHINOS }) + assertTrue(filtered.any { it.subcategory == Subcategory.HENLEY }) + } +} From 89a618c4e238785834f8d95522daadade8f032cb Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 3 Aug 2026 08:04:11 -0300 Subject: [PATCH 3/7] fix: derive gap suggestions from the live wardrobe stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../presentation/viewmodel/GapsViewModel.kt | 116 ++++++--- .../worn/viewmodel/GapsViewModelTest.kt | 242 ++++++++++++++++++ 2 files changed, 322 insertions(+), 36 deletions(-) create mode 100644 shared/src/commonTest/kotlin/com/github/worn/viewmodel/GapsViewModelTest.kt diff --git a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt index 13ec3b9..08286ce 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt @@ -4,14 +4,18 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.worn.domain.model.GapRecommendation import com.github.worn.domain.model.capsuleWardrobeSuggestions +import com.github.worn.domain.model.excludingOwned import com.github.worn.domain.repository.SettingsRepository import com.github.worn.domain.repository.WardrobeRepository import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -32,72 +36,112 @@ sealed interface GapsEffect { data class ShowError(val message: String) : GapsEffect } +/** + * The half of [GapsState] that is not derived from the wardrobe stream. + * + * [aiRecommendations] holds the list exactly as the model returned it; the ownership filter runs + * downstream in [GapsViewModel.state], so a wardrobe write re-filters it without a new AI call. + */ +private data class GapsUiState( + val aiRecommendations: List = emptyList(), + val isLoading: Boolean = true, + val isAiAvailable: Boolean = false, + val isAiMode: Boolean = false, + val error: String? = null, +) + class GapsViewModel( private val wardrobeRepository: WardrobeRepository, private val settingsRepository: SettingsRepository, ) : ViewModel() { - private val _state = MutableStateFlow(GapsState()) - val state: StateFlow = _state.asStateFlow() + private val _uiState = MutableStateFlow(GapsUiState()) private val _effects = Channel(Channel.BUFFERED) val effects: Flow = _effects.receiveAsFlow() + /** + * Suggestions are derived, never stored: the wardrobe stream re-runs the ownership filter on + * every write, so an item added on any tab makes its suggestion disappear here immediately. + * Only the AI list is fetched, and only on init, on an availability change, or on an explicit + * retry — a wardrobe emission must never cost a request. + * + * [SharingStarted.Eagerly] keeps the last value cached, so returning to the tab renders + * immediately rather than behind a spinner. + */ + val state: StateFlow = combine( + wardrobeRepository.observeAll().catch { error -> + _effects.send(GapsEffect.ShowError(error.message ?: UNKNOWN_ERROR)) + emit(emptyList()) + }, + _uiState, + ) { items, ui -> + val source = if (ui.isAiMode) ui.aiRecommendations else capsuleWardrobeSuggestions + GapsState( + recommendations = source.excludingOwned(items), + isLoading = ui.isLoading, + isAiAvailable = ui.isAiAvailable, + isAiMode = ui.isAiMode, + error = ui.error, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, GapsState(isLoading = true)) + init { - // Resolve the provider before branching, and off the main thread — see SettingsRepository. - // Collected, not read once: adding a key on Settings has to switch this screen over to AI - // recommendations without a restart, which means recomputing the gaps too. + observeAiAvailability() + } + + /** + * Collected, not read once: a key added on Settings has to switch this screen over to AI + * recommendations without a restart. The fallback needs no fetch at all — it is a constant, and + * the filter above already tracks the wardrobe. + */ + private fun observeAiAvailability() { viewModelScope.launch { settingsRepository.isAiAvailableFlow().collect { hasAi -> - _state.update { it.copy(isAiAvailable = hasAi, isAiMode = hasAi) } - loadGaps() + _uiState.update { + it.copy(isAiAvailable = hasAi, isAiMode = hasAi, isLoading = hasAi, error = null) + } + if (hasAi) loadAiRecommendations() } } } fun onIntent(intent: GapsIntent) { when (intent) { - is GapsIntent.LoadGaps -> viewModelScope.launch { loadGaps() } + is GapsIntent.LoadGaps -> retry() } } - private suspend fun loadGaps() { - _state.update { it.copy(isLoading = true, error = null) } - if (_state.value.isAiMode) { - loadAiRecommendations() - } else { - loadFallbackSuggestions() + /** Only the AI call can fail; the capsule list is a constant filtered against a live wardrobe. */ + private fun retry() { + viewModelScope.launch { + if (_uiState.value.isAiMode) { + loadAiRecommendations() + } else { + _uiState.update { it.copy(isLoading = false, error = null) } + } } } private suspend fun loadAiRecommendations() { + _uiState.update { it.copy(isLoading = true, error = null) } wardrobeRepository.getGapRecommendations() .onSuccess { recommendations -> - _state.update { it.copy(recommendations = recommendations, isLoading = false) } + _uiState.update { it.copy(aiRecommendations = recommendations, isLoading = false) } } .onFailure { error -> - _state.update { it.copy(isLoading = false, error = error.message) } - _effects.send(GapsEffect.ShowError(error.message ?: "Failed to load recommendations")) + // Defaulted rather than passed through: a null message would leave the error state + // clear and render the "wardrobe complete" screen on a failure. + val message = error.message ?: RECOMMENDATIONS_ERROR + _uiState.update { + it.copy(aiRecommendations = emptyList(), isLoading = false, error = message) + } + _effects.send(GapsEffect.ShowError(message)) } } - private suspend fun loadFallbackSuggestions() { - wardrobeRepository.getAll() - .onSuccess { items -> - val ownedSubcategories = items.mapNotNull { it.subcategory }.toSet() - val filtered = capsuleWardrobeSuggestions.filter { - it.subcategory !in ownedSubcategories - } - _state.update { it.copy(recommendations = filtered, isLoading = false) } - } - .onFailure { error -> - _state.update { - it.copy( - recommendations = capsuleWardrobeSuggestions, - isLoading = false, - ) - } - _effects.send(GapsEffect.ShowError(error.message ?: "Failed to load wardrobe")) - } + private companion object { + const val UNKNOWN_ERROR = "Unknown error" + const val RECOMMENDATIONS_ERROR = "Failed to load recommendations" } } diff --git a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/GapsViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/GapsViewModelTest.kt new file mode 100644 index 0000000..a47e75b --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/GapsViewModelTest.kt @@ -0,0 +1,242 @@ +package com.github.worn.viewmodel + +import app.cash.turbine.test +import com.github.worn.domain.model.Category +import com.github.worn.domain.model.GapRecommendation +import com.github.worn.domain.model.Season +import com.github.worn.domain.model.Subcategory +import com.github.worn.domain.model.capsuleWardrobeSuggestions +import com.github.worn.fake.FakeSettingsRepository +import com.github.worn.fake.FakeWardrobeRepository +import com.github.worn.fake.clothingItem +import com.github.worn.presentation.viewmodel.GapsEffect +import com.github.worn.presentation.viewmodel.GapsIntent +import com.github.worn.presentation.viewmodel.GapsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class GapsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private lateinit var repository: FakeWardrobeRepository + private lateinit var settingsRepository: FakeSettingsRepository + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + repository = FakeWardrobeRepository() + settingsRepository = FakeSettingsRepository() + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): GapsViewModel = + GapsViewModel(repository, settingsRepository) + + private fun aiRecommendation( + name: String = "Navy overshirt", + subcategory: Subcategory? = Subcategory.TRUCKER, + ) = GapRecommendation( + itemName = name, + category = "LAYERING", + pairingCount = 7, + subcategory = subcategory, + mappedCategory = Category.OUTERWEAR, + ) + + // region fallback mode + + @Test + fun `init without AI shows the full capsule list and stops loading`() { + val vm = createViewModel() + + assertEquals(capsuleWardrobeSuggestions, vm.state.value.recommendations) + assertFalse(vm.state.value.isLoading) + assertFalse(vm.state.value.isAiMode) + } + + @Test + fun `subcategories already owned are excluded from the capsule list`() { + repository.addItems(clothingItem(subcategory = Subcategory.POLO)) + + val vm = createViewModel() + + assertTrue(vm.state.value.recommendations.none { it.subcategory == Subcategory.POLO }) + } + + @Test + fun `adding an item to the wardrobe removes its suggestion without any intent`() = runTest { + val vm = createViewModel() + + vm.state.test { + assertTrue(awaitItem().recommendations.any { it.subcategory == Subcategory.POLO }) + + repository.addItems(clothingItem(id = "polo", subcategory = Subcategory.POLO)) + + assertTrue(awaitItem().recommendations.none { it.subcategory == Subcategory.POLO }) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `deleting an item brings its suggestion back`() = runTest { + repository.addItems(clothingItem(id = "polo", subcategory = Subcategory.POLO)) + val vm = createViewModel() + + vm.state.test { + assertTrue(awaitItem().recommendations.none { it.subcategory == Subcategory.POLO }) + + repository.deleteItem("polo") + + assertTrue(awaitItem().recommendations.any { it.subcategory == Subcategory.POLO }) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a wardrobe stream failure falls back to the unfiltered capsule list`() = runTest { + repository.observeAllError = IllegalStateException("db gone") + + val vm = createViewModel() + + assertEquals(capsuleWardrobeSuggestions, vm.state.value.recommendations) + assertNull(vm.state.value.error) + vm.effects.test { + assertEquals("db gone", assertIs(awaitItem()).message) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `LoadGaps in fallback mode does not call the AI`() { + val vm = createViewModel() + + vm.onIntent(GapsIntent.LoadGaps) + + assertEquals(0, repository.gapRecommendationCalls) + assertFalse(vm.state.value.isLoading) + } + + // endregion + + // region AI mode + + @Test + fun `init with a Claude key loads AI recommendations`() { + settingsRepository.apiKey = "test-key" + repository.gapRecommendations = listOf(aiRecommendation()) + + val vm = createViewModel() + + assertTrue(vm.state.value.isAiMode) + assertEquals(listOf("Navy overshirt"), vm.state.value.recommendations.map { it.itemName }) + assertFalse(vm.state.value.isLoading) + } + + @Test + fun `AI recommendations for an owned subcategory are filtered out`() { + settingsRepository.apiKey = "test-key" + repository.gapRecommendations = listOf(aiRecommendation()) + repository.addItems(clothingItem(subcategory = Subcategory.TRUCKER)) + + val vm = createViewModel() + + assertTrue(vm.state.value.recommendations.isEmpty()) + } + + @Test + fun `adding an item re-filters the AI list without a new AI call`() = runTest { + settingsRepository.apiKey = "test-key" + repository.gapRecommendations = listOf(aiRecommendation()) + val vm = createViewModel() + + vm.state.test { + assertEquals(1, awaitItem().recommendations.size) + + repository.addItems(clothingItem(subcategory = Subcategory.TRUCKER)) + + assertTrue(awaitItem().recommendations.isEmpty()) + assertEquals(1, repository.gapRecommendationCalls) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `an AI recommendation without a subcategory is always kept`() { + settingsRepository.apiKey = "test-key" + repository.gapRecommendations = listOf(aiRecommendation(subcategory = null)) + repository.addItems(clothingItem(subcategory = Subcategory.TRUCKER)) + + val vm = createViewModel() + + assertEquals(1, vm.state.value.recommendations.size) + } + + @Test + fun `an AI failure clears the list and reports the error`() = runTest { + settingsRepository.apiKey = "test-key" + repository.gapRecommendationsError = IllegalStateException("api down") + + val vm = createViewModel() + + assertTrue(vm.state.value.recommendations.isEmpty()) + assertEquals("api down", vm.state.value.error) + vm.effects.test { + assertEquals("api down", assertIs(awaitItem()).message) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `an AI failure with a null message still sets a non-null error`() { + settingsRepository.apiKey = "test-key" + repository.gapRecommendationsError = IllegalStateException() + + val vm = createViewModel() + + assertNotNull(vm.state.value.error) + } + + @Test + fun `LoadGaps retries the AI call`() { + settingsRepository.apiKey = "test-key" + val vm = createViewModel() + assertEquals(1, repository.gapRecommendationCalls) + + vm.onIntent(GapsIntent.LoadGaps) + + assertEquals(2, repository.gapRecommendationCalls) + } + + @Test + fun `enabling AI after construction switches from the capsule list to AI recommendations`() { + repository.gapRecommendations = listOf(aiRecommendation()) + val vm = createViewModel() + assertEquals(capsuleWardrobeSuggestions, vm.state.value.recommendations) + + settingsRepository.apiKey = "test-key" + + assertTrue(vm.state.value.isAiMode) + assertEquals(listOf("Navy overshirt"), vm.state.value.recommendations.map { it.itemName }) + } + + // endregion + +} From 99828273ff9fec2a14f4d9f698c2bf0c5553cb53 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 3 Aug 2026 08:04:53 -0300 Subject: [PATCH 4/7] fix: save the item added from a gap suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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) --- .../com/github/worn/ui/screen/AddItemSheet.kt | 18 ++++- .../com/github/worn/ui/screen/GapsScreen.kt | 41 +++++++++- iosApp/iosApp/Screens/AddItemSheet.swift | 8 +- iosApp/iosApp/Screens/GapsScreen.swift | 35 ++++++-- .../ViewModels/GapsViewModelWrapper.swift | 45 +++++++++-- .../presentation/viewmodel/GapsViewModel.kt | 47 +++++++++++ .../worn/viewmodel/GapsViewModelTest.kt | 80 +++++++++++++++++++ 7 files changed, 254 insertions(+), 20 deletions(-) diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt index df5b876..b4dc65c 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/AddItemSheet.kt @@ -88,6 +88,7 @@ fun AddItemSheet( isSaving: Boolean, isAiAvailable: Boolean, existingItem: ClothingItem? = null, + prefillItem: ClothingItem? = null, onSave: ( imageBytes: ByteArray, name: String, category: Category, colors: List, seasons: List, @@ -108,6 +109,7 @@ fun AddItemSheet( isSaving = isSaving, isAiAvailable = isAiAvailable, existingItem = existingItem, + prefillItem = prefillItem, onSave = onSave, ) } @@ -118,10 +120,16 @@ internal fun AddItemForm( isSaving: Boolean = false, isAiAvailable: Boolean = false, existingItem: ClothingItem? = null, + /** + * Seed values for a *new* item, e.g. a Gaps suggestion. Unlike [existingItem] it does not put + * the sheet in editing mode: a photo is still required and the button still says "Save to + * wardrobe", because nothing has been stored yet. + */ + prefillItem: ClothingItem? = null, onSave: (ByteArray, String, Category, List, List, Subcategory?, Fit?, Material?) -> Unit = { _, _, _, _, _, _, _, _ -> }, ) { - val formState = rememberAddItemFormState(existingItem) + val formState = rememberAddItemFormState(existingItem, prefillItem) val backgroundRemover = koinInject() val scope = rememberCoroutineScope() val context = LocalContext.current @@ -248,9 +256,13 @@ private class AddItemFormState(existingItem: ClothingItem?) { } @Composable -private fun rememberAddItemFormState(existingItem: ClothingItem?): AddItemFormState { +private fun rememberAddItemFormState( + existingItem: ClothingItem?, + prefillItem: ClothingItem?, +): AddItemFormState { + // Only a stored item has a photo on disk; a prefill's photoPath is empty by construction. val existingPhotoBitmap = rememberDecodedImage(existingItem?.photoPath) - val formState = remember { AddItemFormState(existingItem) } + val formState = remember { AddItemFormState(existingItem ?: prefillItem) } formState.existingPhotoBitmap = existingPhotoBitmap return formState } diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt index 31eb0a4..a72d99a 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt @@ -2,6 +2,7 @@ package com.github.worn.ui.screen +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -33,6 +34,7 @@ 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.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,6 +61,7 @@ import com.github.worn.R import com.github.worn.domain.model.Category import com.github.worn.domain.model.GapRecommendation import com.github.worn.domain.model.Season +import com.github.worn.presentation.viewmodel.GapsEffect import com.github.worn.presentation.viewmodel.GapsIntent import com.github.worn.presentation.viewmodel.GapsState import com.github.worn.presentation.viewmodel.GapsViewModel @@ -80,6 +83,7 @@ import org.koin.compose.viewmodel.koinViewModel fun GapsScreen(onTabSelected: (Tab) -> Unit) { val viewModel: GapsViewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current val windowInfo = currentWindowAdaptiveInfo() val isCompact = windowInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT @@ -88,6 +92,19 @@ fun GapsScreen(onTabSelected: (Tab) -> Unit) { var showAddItemSheet by remember { mutableStateOf(false) } var addItemPreFill by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + is GapsEffect.ItemAdded -> { + showAddItemSheet = false + addItemPreFill = null + } + is GapsEffect.ShowError -> + Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() + } + } + } + GapsScaffold( state = state, isCompact = isCompact, @@ -122,11 +139,27 @@ fun GapsScreen(onTabSelected: (Tab) -> Unit) { if (showAddItemSheet && addItemPreFill != null) { val gap = addItemPreFill!! AddItemSheet( - isSaving = false, + isSaving = state.isSaving, isAiAvailable = state.isAiAvailable, - existingItem = gap.toPreFilledItem(), - onSave = { _, _, _, _, _, _, _, _ -> showAddItemSheet = false }, - onDismiss = { showAddItemSheet = false }, + prefillItem = gap.toPreFilledItem(), + onSave = { imageBytes, name, category, colors, seasons, subcategory, fit, material -> + viewModel.onIntent( + GapsIntent.AddItem( + imageBytes = imageBytes, + name = name, + category = category, + colors = colors, + seasons = seasons, + subcategory = subcategory, + fit = fit, + material = material, + ), + ) + }, + onDismiss = { + showAddItemSheet = false + addItemPreFill = null + }, ) } } diff --git a/iosApp/iosApp/Screens/AddItemSheet.swift b/iosApp/iosApp/Screens/AddItemSheet.swift index 46a2d8f..e067c46 100644 --- a/iosApp/iosApp/Screens/AddItemSheet.swift +++ b/iosApp/iosApp/Screens/AddItemSheet.swift @@ -6,6 +6,10 @@ struct AddItemSheet: View { let isSaving: Bool let isAiAvailable: Bool var existingItem: ClothingItem? + /// Seed values for a *new* item, e.g. a Gaps suggestion. Unlike `existingItem` it does not put + /// the sheet in editing mode: a photo is still required and the button still says "Save to + /// wardrobe", because nothing has been stored yet. + var prefillItem: ClothingItem? let onSave: (Data, String, Shared.Category, [String], [Season], Subcategory?, Fit?, Shared.Material?) -> Void let onDismiss: () -> Void @@ -124,7 +128,9 @@ struct AddItemSheet: View { } } .onAppear { - if let item = existingItem, !didInitFromExisting { + // A prefill seeds the same fields; only a stored item has a photo on disk, which + // the `photoPath` check below already accounts for. + if let item = existingItem ?? prefillItem, !didInitFromExisting { didInitFromExisting = true name = item.name selectedCategory = item.category diff --git a/iosApp/iosApp/Screens/GapsScreen.swift b/iosApp/iosApp/Screens/GapsScreen.swift index e467c8d..5b9da21 100644 --- a/iosApp/iosApp/Screens/GapsScreen.swift +++ b/iosApp/iosApp/Screens/GapsScreen.swift @@ -45,14 +45,35 @@ struct GapsScreen: View { .sheet(isPresented: $showAddItemSheet) { if let gap = addItemPreFill { AddItemSheet( - isSaving: false, + isSaving: viewModel.state.isSaving, isAiAvailable: viewModel.state.isAiAvailable, - existingItem: gap.toPreFilledItem(), - onSave: { _, _, _, _, _, _, _, _ in showAddItemSheet = false }, - onDismiss: { showAddItemSheet = false } + prefillItem: gap.toPreFilledItem(), + onSave: { data, name, category, colors, seasons, subcategory, fit, material in + viewModel.addItem( + imageData: data, + name: name, + category: category, + colors: colors, + seasons: seasons, + subcategory: subcategory, + fit: fit, + material: material + ) + }, + onDismiss: { + showAddItemSheet = false + addItemPreFill = nil + } ) } } + .onChange(of: viewModel.itemAdded) { _, added in + if added { + showAddItemSheet = false + addItemPreFill = nil + viewModel.itemAdded = false + } + } } } @@ -486,7 +507,7 @@ private let previewGaps: [GapRecommendation] = [ #Preview("iPhone") { GapsContent( state: GapsState( - recommendations: previewGaps, isLoading: false, + recommendations: previewGaps, isLoading: false, isSaving: false, isAiAvailable: true, isAiMode: true, error: nil ), isCompact: true @@ -496,7 +517,7 @@ private let previewGaps: [GapRecommendation] = [ #Preview("iPhone - Complete") { GapsContent( state: GapsState( - recommendations: [], isLoading: false, + recommendations: [], isLoading: false, isSaving: false, isAiAvailable: false, isAiMode: false, error: nil ), isCompact: true @@ -506,7 +527,7 @@ private let previewGaps: [GapRecommendation] = [ #Preview("iPad Portrait", traits: .portrait) { GapsContent( state: GapsState( - recommendations: previewGaps, isLoading: false, + recommendations: previewGaps, isLoading: false, isSaving: false, isAiAvailable: true, isAiMode: true, error: nil ), isCompact: false diff --git a/iosApp/iosApp/ViewModels/GapsViewModelWrapper.swift b/iosApp/iosApp/ViewModels/GapsViewModelWrapper.swift index 9af7979..04fff93 100644 --- a/iosApp/iosApp/ViewModels/GapsViewModelWrapper.swift +++ b/iosApp/iosApp/ViewModels/GapsViewModelWrapper.swift @@ -5,30 +5,65 @@ import Shared @MainActor class GapsViewModelWrapper: ObservableObject { private let viewModel: GapsViewModel - private var cancellable: Cancellable? + private var stateCancellable: Cancellable? + private var effectsCancellable: Cancellable? @Published var state: GapsState + @Published var itemAdded = false init() { let vm = KoinHelper.shared.gapsViewModel self.viewModel = vm - let adapter = FlowAdapter(flow: vm.state) - self.state = adapter.currentValue - cancellable = adapter.subscribe { [weak self] newState in + let stateAdapter = FlowAdapter(flow: vm.state) + self.state = stateAdapter.currentValue + stateCancellable = stateAdapter.subscribe { [weak self] newState in DispatchQueue.main.async { withAnimation(.easeInOut(duration: 0.3)) { self?.state = newState } } } + + let effectsAdapter = EffectAdapter(flow: vm.effects) + effectsCancellable = effectsAdapter.subscribe { [weak self] effect in + guard let effect = effect as? GapsEffect else { return } + DispatchQueue.main.async { + if effect is GapsEffectItemAdded { + self?.itemAdded = true + } + } + } } func loadGaps() { viewModel.onIntent(intent: GapsIntentLoadGaps()) } + func addItem( + imageData: Data, name: String, category: Shared.Category, colors: [String], seasons: [Season], + subcategory: Subcategory? = nil, fit: Fit? = nil, material: Shared.Material? = nil + ) { + let bytes = [UInt8](imageData) + let kotlinBytes = KotlinByteArray(size: Int32(bytes.count)) + for (index, byte) in bytes.enumerated() { + kotlinBytes.set(index: Int32(index), value: Int8(bitPattern: byte)) + } + let intent = GapsIntentAddItem( + imageBytes: kotlinBytes, + name: name, + category: category, + colors: colors, + seasons: seasons, + subcategory: subcategory, + fit: fit, + material: material + ) + viewModel.onIntent(intent: intent) + } + deinit { - cancellable?.cancel() + stateCancellable?.cancel() + effectsCancellable?.cancel() } } diff --git a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt index 08286ce..296bdd5 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt @@ -2,7 +2,12 @@ package com.github.worn.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.worn.domain.model.Category +import com.github.worn.domain.model.Fit import com.github.worn.domain.model.GapRecommendation +import com.github.worn.domain.model.Material +import com.github.worn.domain.model.Season +import com.github.worn.domain.model.Subcategory import com.github.worn.domain.model.capsuleWardrobeSuggestions import com.github.worn.domain.model.excludingOwned import com.github.worn.domain.repository.SettingsRepository @@ -21,11 +26,22 @@ import kotlinx.coroutines.launch sealed interface GapsIntent { data object LoadGaps : GapsIntent + data class AddItem( + val imageBytes: ByteArray, + val name: String, + val category: Category, + val colors: List, + val seasons: List, + val subcategory: Subcategory? = null, + val fit: Fit? = null, + val material: Material? = null, + ) : GapsIntent } data class GapsState( val recommendations: List = emptyList(), val isLoading: Boolean = false, + val isSaving: Boolean = false, /** A Claude key is configured, or on-device AI is enabled and available. */ val isAiAvailable: Boolean = false, val isAiMode: Boolean = false, @@ -34,6 +50,7 @@ data class GapsState( sealed interface GapsEffect { data class ShowError(val message: String) : GapsEffect + data object ItemAdded : GapsEffect } /** @@ -45,6 +62,7 @@ sealed interface GapsEffect { private data class GapsUiState( val aiRecommendations: List = emptyList(), val isLoading: Boolean = true, + val isSaving: Boolean = false, val isAiAvailable: Boolean = false, val isAiMode: Boolean = false, val error: String? = null, @@ -80,6 +98,7 @@ class GapsViewModel( GapsState( recommendations = source.excludingOwned(items), isLoading = ui.isLoading, + isSaving = ui.isSaving, isAiAvailable = ui.isAiAvailable, isAiMode = ui.isAiMode, error = ui.error, @@ -109,6 +128,7 @@ class GapsViewModel( fun onIntent(intent: GapsIntent) { when (intent) { is GapsIntent.LoadGaps -> retry() + is GapsIntent.AddItem -> addItem(intent) } } @@ -140,8 +160,35 @@ class GapsViewModel( } } + /** + * Saved through the same repository the Wardrobe tab uses, so the new item lands in the DB and + * the stream above drops its suggestion — no explicit refresh anywhere. + */ + private fun addItem(intent: GapsIntent.AddItem) { + viewModelScope.launch { + _uiState.update { it.copy(isSaving = true) } + wardrobeRepository.addItem( + imageBytes = intent.imageBytes, + name = intent.name, + category = intent.category, + colors = intent.colors, + seasons = intent.seasons, + subcategory = intent.subcategory, + fit = intent.fit, + material = intent.material, + ).onSuccess { + _uiState.update { it.copy(isSaving = false) } + _effects.send(GapsEffect.ItemAdded) + }.onFailure { error -> + _uiState.update { it.copy(isSaving = false) } + _effects.send(GapsEffect.ShowError(error.message ?: SAVE_ERROR)) + } + } + } + private companion object { const val UNKNOWN_ERROR = "Unknown error" const val RECOMMENDATIONS_ERROR = "Failed to load recommendations" + const val SAVE_ERROR = "Failed to save" } } diff --git a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/GapsViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/GapsViewModelTest.kt index a47e75b..ebb094d 100644 --- a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/GapsViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/GapsViewModelTest.kt @@ -2,7 +2,9 @@ package com.github.worn.viewmodel import app.cash.turbine.test import com.github.worn.domain.model.Category +import com.github.worn.domain.model.Fit import com.github.worn.domain.model.GapRecommendation +import com.github.worn.domain.model.Material import com.github.worn.domain.model.Season import com.github.worn.domain.model.Subcategory import com.github.worn.domain.model.capsuleWardrobeSuggestions @@ -239,4 +241,82 @@ class GapsViewModelTest { // endregion + // region AddItem + + @Test + fun `AddItem forwards every field to the repository`() = runTest { + val vm = createViewModel() + + vm.onIntent( + GapsIntent.AddItem( + imageBytes = byteArrayOf(1, 2, 3), + name = "Polo shirt", + category = Category.TOP, + colors = listOf("Black"), + seasons = listOf(Season.SUMMER), + subcategory = Subcategory.POLO, + fit = Fit.SLIM_FIT, + material = Material.COTTON, + ), + ) + + val saved = repository.items.value.single() + assertEquals("Polo shirt", saved.name) + assertEquals(Category.TOP, saved.category) + assertEquals(Subcategory.POLO, saved.subcategory) + assertEquals(Fit.SLIM_FIT, saved.fit) + assertEquals(Material.COTTON, saved.material) + } + + @Test + fun `AddItem emits ItemAdded on success`() = runTest { + val vm = createViewModel() + + vm.effects.test { + vm.onIntent(addPoloIntent()) + + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + assertFalse(vm.state.value.isSaving) + } + + @Test + fun `AddItem failure emits ShowError and clears isSaving`() = runTest { + repository.addItemError = IllegalStateException("disk full") + val vm = createViewModel() + + vm.effects.test { + vm.onIntent(addPoloIntent()) + + assertEquals("disk full", assertIs(awaitItem()).message) + cancelAndIgnoreRemainingEvents() + } + assertFalse(vm.state.value.isSaving) + } + + @Test + fun `adding the recommended item removes it from the suggestions`() = runTest { + val vm = createViewModel() + + vm.state.test { + assertTrue(awaitItem().recommendations.any { it.subcategory == Subcategory.POLO }) + + vm.onIntent(addPoloIntent()) + + assertTrue(awaitItem().recommendations.none { it.subcategory == Subcategory.POLO }) + cancelAndIgnoreRemainingEvents() + } + } + + private fun addPoloIntent() = GapsIntent.AddItem( + imageBytes = byteArrayOf(1), + name = "Polo shirt", + category = Category.TOP, + colors = listOf("Black"), + seasons = listOf(Season.SUMMER), + subcategory = Subcategory.POLO, + ) + + // endregion } From 3cc5a924a3be2ebc21e42974f7a6e58f93cf54f8 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 3 Aug 2026 08:05:06 -0300 Subject: [PATCH 5/7] fix: clear the subcategory when the category changes on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- iosApp/iosApp/Screens/AddItemSheet.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/iosApp/iosApp/Screens/AddItemSheet.swift b/iosApp/iosApp/Screens/AddItemSheet.swift index e067c46..d1ec0c4 100644 --- a/iosApp/iosApp/Screens/AddItemSheet.swift +++ b/iosApp/iosApp/Screens/AddItemSheet.swift @@ -310,6 +310,11 @@ struct AddItemSheet: View { let (category, label) = item Button { selectedCategory = category + // Mirrors AddItemSheet.kt: the old subcategory is not offered by the new + // category, and a stale one (e.g. BOTTOM + POLO) would wrongly suppress a + // gap suggestion. Done here rather than in `.onChange(of: selectedCategory)`, + // which would also fire while seeding from existingItem/prefillItem. + selectedSubcategory = nil withAnimation { categoryExpanded = false } } label: { HStack(spacing: 12) { From 2eecfa361156aed2f93bd2794bd05e017f5d0e4b Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 3 Aug 2026 08:06:07 -0300 Subject: [PATCH 6/7] test: add a journey for adding an item from a gap suggestion 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) --- journeys/gaps-add-suggestion.xml | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 journeys/gaps-add-suggestion.xml diff --git a/journeys/gaps-add-suggestion.xml b/journeys/gaps-add-suggestion.xml new file mode 100644 index 0000000..0af6509 --- /dev/null +++ b/journeys/gaps-add-suggestion.xml @@ -0,0 +1,36 @@ + + + Verifies that "Add to Wardrobe" on a common suggestion opens the add-item sheet pre-filled + with that suggestion, in add mode rather than edit mode. Stops before the photo, which is + external input. Start state: fresh install with no API key configured. + + + + Tap the "GAPS" tab in the bottom navigation bar. + + + Verify the Gaps screen shows the "What's missing" heading and a "Polo shirt" suggestion + card. + + + Tap the "Polo shirt" suggestion card. + + + Verify the suggestion detail sheet is shown with an "Add to Wardrobe" button. + + + Tap the "Add to Wardrobe" button. + + + Verify the add-item sheet is shown with the "Add new item" title — not "Edit item" — + and the name field pre-filled with "Polo shirt". + + + Verify the category is pre-selected as "Tops" and the subcategory as "Polo". + + + Verify the save button reads "Save to wardrobe" — not "Save Changes" — and is disabled, + because no photo has been chosen yet. + + + From 20aad2253b1fb49f2ddafb7d7aba9a455289605b Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Mon, 3 Aug 2026 08:06:07 -0300 Subject: [PATCH 7/7] docs: require branching off main, and fix the single-test command `: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) --- CLAUDE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c804bff..76e9e1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,8 +18,9 @@ Worn is a Kotlin Multiplatform wardrobe manager app for Android and iOS. Users c # Run all shared tests ./gradlew :shared:allTests -# Run a single test class -./gradlew :shared:allTests --tests "com.github.worn.repository.WardrobeRepositoryTest" +# Run a single test class — `allTests` is an aggregate task and rejects `--tests`, +# so target the per-target task instead +./gradlew :shared:testAndroidHostTest --tests "com.github.worn.repository.WardrobeRepositoryTest" # Check dependency resolution ./gradlew :shared:dependencies @@ -92,6 +93,7 @@ Reference these for version compatibility and best practices: ## Commits +- **Always branch off `main`** — never commit directly to `main`. Before starting any work, create a branch from an up-to-date `main` (`git checkout main && git pull && git checkout -b /`, e.g. `fix/gaps-suggestions-refresh`). Merge back through a pull request. - **Atomic commits** — each commit should represent exactly one logical change. Don't mix unrelated changes (e.g., a bug fix and a refactor) in the same commit. - **Commit early, commit often** — break work into small, self-contained commits rather than one large commit at the end. Each commit should leave the project in a buildable state. - **Meaningful commit messages** — use the imperative mood (e.g., "add category filter" not "added category filter"). Keep the subject line concise (<72 chars) and add a body when the *why* isn't obvious from the diff.