Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <type>/<short-description>`, 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, seasons: List<Season>,
Expand All @@ -108,6 +109,7 @@ fun AddItemSheet(
isSaving = isSaving,
isAiAvailable = isAiAvailable,
existingItem = existingItem,
prefillItem = prefillItem,
onSave = onSave,
)
}
Expand All @@ -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<String>, List<Season>, Subcategory?, Fit?, Material?) -> Unit =
{ _, _, _, _, _, _, _, _ -> },
) {
val formState = rememberAddItemFormState(existingItem)
val formState = rememberAddItemFormState(existingItem, prefillItem)
val backgroundRemover = koinInject<BackgroundRemover>()
val scope = rememberCoroutineScope()
val context = LocalContext.current
Expand Down Expand Up @@ -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
}
Expand Down
41 changes: 37 additions & 4 deletions composeApp/src/main/kotlin/com/github/worn/ui/screen/GapsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -88,6 +92,19 @@ fun GapsScreen(onTabSelected: (Tab) -> Unit) {
var showAddItemSheet by remember { mutableStateOf(false) }
var addItemPreFill by remember { mutableStateOf<GapRecommendation?>(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,
Expand Down Expand Up @@ -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
},
)
}
}
Expand Down
13 changes: 12 additions & 1 deletion iosApp/iosApp/Screens/AddItemSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
35 changes: 28 additions & 7 deletions iosApp/iosApp/Screens/GapsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
45 changes: 40 additions & 5 deletions iosApp/iosApp/ViewModels/GapsViewModelWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<GapsState>(flow: vm.state)
self.state = adapter.currentValue
cancellable = adapter.subscribe { [weak self] newState in
let stateAdapter = FlowAdapter<GapsState>(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()
}
}
36 changes: 36 additions & 0 deletions journeys/gaps-add-suggestion.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<journey name="Gaps add suggestion">
<description>
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.
</description>
<actions>
<action>
Tap the "GAPS" tab in the bottom navigation bar.
</action>
<action>
Verify the Gaps screen shows the "What's missing" heading and a "Polo shirt" suggestion
card.
</action>
<action>
Tap the "Polo shirt" suggestion card.
</action>
<action>
Verify the suggestion detail sheet is shown with an "Add to Wardrobe" button.
</action>
<action>
Tap the "Add to Wardrobe" button.
</action>
<action>
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".
</action>
<action>
Verify the category is pre-selected as "Tops" and the subcategory as "Polo".
</action>
<action>
Verify the save button reads "Save to wardrobe" — not "Save Changes" — and is disabled,
because no photo has been chosen yet.
</action>
</actions>
</journey>
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
}
Loading
Loading