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. 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..d1ec0c4 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 @@ -304,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) { 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/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. + + + 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/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/GapsViewModel.kt index 13ec3b9..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,26 +2,46 @@ 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 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 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, @@ -30,74 +50,145 @@ data class GapsState( sealed interface GapsEffect { data class ShowError(val message: String) : GapsEffect + data object ItemAdded : 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 isSaving: Boolean = false, + 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, + isSaving = ui.isSaving, + 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() + is GapsIntent.AddItem -> addItem(intent) } } - 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")) + /** + * 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/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, ) 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 }) + } +} 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..ebb094d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/GapsViewModelTest.kt @@ -0,0 +1,322 @@ +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 +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 + + // 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 +}