From 2002c9728ccbeadcbc53656a54fd2253c563d09e Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Sun, 2 Aug 2026 21:02:59 -0300 Subject: [PATCH 1/2] feat: name outfits after their items when none is given An outfit saved without a name now falls back to its item names joined by " + ", so the list never shows a blank row. The fallback lives in the repository rather than in either UI, so Android and iOS get it for free. Closes #43 Co-Authored-By: Claude Opus 5 (1M context) --- .../repository/OutfitRepositoryImplTest.kt | 131 ++++++++++++++++++ .../data/repository/OutfitRepositoryImpl.kt | 35 ++++- .../presentation/viewmodel/OutfitViewModel.kt | 3 +- .../worn/data/source/local/db/ClothingItem.sq | 6 + .../worn/viewmodel/OutfitViewModelTest.kt | 24 ++++ 5 files changed, 191 insertions(+), 8 deletions(-) create mode 100644 shared/src/androidHostTest/kotlin/com/github/worn/repository/OutfitRepositoryImplTest.kt diff --git a/shared/src/androidHostTest/kotlin/com/github/worn/repository/OutfitRepositoryImplTest.kt b/shared/src/androidHostTest/kotlin/com/github/worn/repository/OutfitRepositoryImplTest.kt new file mode 100644 index 0000000..d5b0986 --- /dev/null +++ b/shared/src/androidHostTest/kotlin/com/github/worn/repository/OutfitRepositoryImplTest.kt @@ -0,0 +1,131 @@ +package com.github.worn.repository + +import app.cash.sqldelight.Query +import app.cash.sqldelight.TransactionWithoutReturn +import com.github.worn.data.repository.OutfitRepositoryImpl +import com.github.worn.data.source.local.db.ClothingItemQueries +import com.github.worn.data.source.local.db.OutfitItemQueries +import com.github.worn.data.source.local.db.OutfitQueries +import com.github.worn.data.source.local.db.WardrobeDatabase +import com.github.worn.domain.model.Outfit +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class OutfitRepositoryImplTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val db = mockk() + private val outfitQueries = mockk(relaxed = true) + private val outfitItemQueries = mockk(relaxed = true) + private val clothingItemQueries = mockk(relaxed = true) + + private lateinit var repository: OutfitRepositoryImpl + + @BeforeTest + fun setup() { + every { db.outfitQueries } returns outfitQueries + every { db.outfitItemQueries } returns outfitItemQueries + every { db.clothingItemQueries } returns clothingItemQueries + every { db.transaction(any(), any Unit>()) } answers { + val body = arg Unit>(1) + val tx = mockk(relaxed = true) + body(tx) + } + repository = OutfitRepositoryImpl(db, testDispatcher) + } + + /** + * Stands in for `getNamesByIds`, which SQLDelight generates with a caller-supplied mapper: + * the mapper is captured and applied to [rows] so the test asserts on the repository's + * ordering, not on SQLDelight's row plumbing. + */ + private fun stubItemNames(vararg rows: Pair) { + every { clothingItemQueries.getNamesByIds(any(), any<(String, String) -> Any>()) } answers { + val ids = arg>(0).toSet() + @Suppress("UNCHECKED_CAST") + val mapper = arg<(String, String) -> Any>(1) as (String, String) -> Pair + mockk>> { + every { executeAsList() } returns + rows.filter { it.first in ids }.map { mapper(it.first, it.second) } + } + } + } + + // region createOutfit + + @Test + fun `createOutfit keeps the provided name`() = runTest { + val result = repository.createOutfit(name = "Weekend Casual", itemIds = listOf("item-1")) + + assertTrue(result.isSuccess) + assertEquals("Weekend Casual", result.getOrThrow().name) + verify { outfitQueries.insert(any(), "Weekend Casual", any()) } + verify(exactly = 0) { clothingItemQueries.getNamesByIds(any(), any<(String, String) -> Any>()) } + } + + @Test + fun `createOutfit falls back to item names joined by plus`() = runTest { + stubItemNames("item-1" to "Black T-Shirt", "item-2" to "Navy Jeans") + + val result = repository.createOutfit(name = "", itemIds = listOf("item-1", "item-2")) + + assertTrue(result.isSuccess) + assertEquals("Black T-Shirt + Navy Jeans", result.getOrThrow().name) + verify { outfitQueries.insert(any(), "Black T-Shirt + Navy Jeans", any()) } + } + + @Test + fun `createOutfit default name follows the selection order`() = runTest { + stubItemNames("item-1" to "Black T-Shirt", "item-2" to "Navy Jeans") + + val result = repository.createOutfit(name = " ", itemIds = listOf("item-2", "item-1")) + + assertEquals("Navy Jeans + Black T-Shirt", result.getOrThrow().name) + } + + @Test + fun `createOutfit default name skips ids with no matching item`() = runTest { + stubItemNames("item-1" to "Black T-Shirt") + + val result = repository.createOutfit(name = "", itemIds = listOf("item-1", "missing")) + + assertEquals("Black T-Shirt", result.getOrThrow().name) + } + + // endregion + + // region updateOutfit + + @Test + fun `updateOutfit falls back to item names when the name is cleared`() = runTest { + stubItemNames("item-1" to "Black T-Shirt", "item-2" to "Navy Jeans") + val outfit = Outfit(id = "o-1", name = "", itemIds = listOf("item-1", "item-2"), createdAt = 0) + + val result = repository.updateOutfit(outfit) + + assertTrue(result.isSuccess) + assertEquals("Black T-Shirt + Navy Jeans", result.getOrThrow().name) + verify { outfitQueries.update("Black T-Shirt + Navy Jeans", "o-1") } + } + + @Test + fun `updateOutfit keeps the provided name`() = runTest { + val outfit = Outfit(id = "o-1", name = "Weekend Casual", itemIds = listOf("item-1"), createdAt = 0) + + val result = repository.updateOutfit(outfit) + + assertEquals("Weekend Casual", result.getOrThrow().name) + verify { outfitQueries.update("Weekend Casual", "o-1") } + } + + // endregion +} diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/repository/OutfitRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/github/worn/data/repository/OutfitRepositoryImpl.kt index de94ee5..1ad12e0 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/data/repository/OutfitRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/data/repository/OutfitRepositoryImpl.kt @@ -13,6 +13,9 @@ import kotlin.time.Clock import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid +/** Joins item names into an outfit's default name, e.g. `Black T-Shirt + Navy Jeans`. */ +private const val NAME_SEPARATOR = " + " + @OptIn(ExperimentalUuidApi::class) class OutfitRepositoryImpl( private val db: WardrobeDatabase, @@ -44,31 +47,49 @@ class OutfitRepositoryImpl( withContext(dispatcher) { val id = Uuid.random().toString() val createdAt = Clock.System.now().toEpochMilliseconds() + val resolvedName = resolveName(name, itemIds) db.transaction { - db.outfitQueries.insert(id = id, name = name, createdAt = createdAt) + db.outfitQueries.insert(id = id, name = resolvedName, createdAt = createdAt) itemIds.forEach { itemId -> db.outfitItemQueries.insertItem(outfitId = id, itemId = itemId) } } - Outfit(id = id, name = name, itemIds = itemIds, createdAt = createdAt) + Outfit(id = id, name = resolvedName, itemIds = itemIds, createdAt = createdAt) } } override suspend fun updateOutfit(outfit: Outfit): Result = runCatching { withContext(dispatcher) { + val resolved = outfit.copy(name = resolveName(outfit.name, outfit.itemIds)) db.transaction { - db.outfitQueries.update(name = outfit.name, id = outfit.id) - db.outfitItemQueries.deleteAllForOutfit(outfit.id) - outfit.itemIds.forEach { itemId -> - db.outfitItemQueries.insertItem(outfitId = outfit.id, itemId = itemId) + db.outfitQueries.update(name = resolved.name, id = resolved.id) + db.outfitItemQueries.deleteAllForOutfit(resolved.id) + resolved.itemIds.forEach { itemId -> + db.outfitItemQueries.insertItem(outfitId = resolved.id, itemId = itemId) } } - outfit + resolved } } + /** + * Falls back to the outfit's item names joined by [NAME_SEPARATOR] when the user left the name + * empty, so every outfit ends up with something readable in the list. + */ + private fun resolveName(name: String, itemIds: List): String = + name.ifBlank { defaultName(itemIds) }.trim() + + private fun defaultName(itemIds: List): String { + if (itemIds.isEmpty()) return "" + val namesById = db.clothingItemQueries + .getNamesByIds(itemIds) { id, name -> id to name } + .executeAsList() + .toMap() + return itemIds.mapNotNull { namesById[it] }.joinToString(NAME_SEPARATOR) + } + override suspend fun deleteOutfit(id: String): Result = runCatching { withContext(dispatcher) { db.outfitQueries.delete(id) diff --git a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/OutfitViewModel.kt b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/OutfitViewModel.kt index d75e170..b20d125 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/OutfitViewModel.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/presentation/viewmodel/OutfitViewModel.kt @@ -152,7 +152,8 @@ class OutfitViewModel( private fun createOutfit(name: String) { val itemIds = state.value.selectedItemIds.toList() - if (name.isBlank() || itemIds.isEmpty()) return + // A blank name is allowed: the repository names the outfit after its items instead. + if (itemIds.isEmpty()) return viewModelScope.launch { _uiState.update { it.copy(isSaving = true) } repository.createOutfit(name = name, itemIds = itemIds) diff --git a/shared/src/commonMain/sqldelight/com/github/worn/data/source/local/db/ClothingItem.sq b/shared/src/commonMain/sqldelight/com/github/worn/data/source/local/db/ClothingItem.sq index 77e827f..ab95732 100644 --- a/shared/src/commonMain/sqldelight/com/github/worn/data/source/local/db/ClothingItem.sq +++ b/shared/src/commonMain/sqldelight/com/github/worn/data/source/local/db/ClothingItem.sq @@ -22,6 +22,12 @@ SELECT * FROM clothingItem ORDER BY createdAt DESC; getById: SELECT * FROM clothingItem WHERE id = ?; +-- Only the columns needed to label a set of items, so building an outfit's default name is one +-- query instead of one full row read per item. Never call with an empty collection: SQLDelight +-- expands `IN ?` to `IN ()`, which SQLite rejects. +getNamesByIds: +SELECT id, name FROM clothingItem WHERE id IN ?; + getByCategory: SELECT * FROM clothingItem WHERE category = ? ORDER BY createdAt DESC; diff --git a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/OutfitViewModelTest.kt b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/OutfitViewModelTest.kt index b4897e7..a2a87a9 100644 --- a/shared/src/commonTest/kotlin/com/github/worn/viewmodel/OutfitViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/github/worn/viewmodel/OutfitViewModelTest.kt @@ -202,6 +202,30 @@ class OutfitViewModelTest { assertEquals("Weekend Casual", outfitRepository.outfits.value.first().name) } + @Test + fun `CreateOutfit with blank name still reaches the repository`() = runTest { + val vm = createViewModel() + + vm.onIntent(OutfitIntent.ToggleItemSelection("item-1")) + + vm.effects.test { + vm.onIntent(OutfitIntent.CreateOutfit(" ")) + + assertIs(awaitItem()) + } + assertEquals(1, outfitRepository.outfits.value.size) + } + + @Test + fun `CreateOutfit without selected items does nothing`() = runTest { + val vm = createViewModel() + + vm.onIntent(OutfitIntent.CreateOutfit("Weekend Casual")) + + assertTrue(outfitRepository.outfits.value.isEmpty()) + assertFalse(vm.state.value.isSaving) + } + @Test fun `CreateOutfit failure sends ShowError`() = runTest { val vm = createViewModel() From 83874e80bd00cc6c070dca3d85006f7000619e16 Mon Sep 17 00:00:00 2001 From: Joao Victor Sena Date: Sun, 2 Aug 2026 21:03:06 -0300 Subject: [PATCH 2/2] feat: make the outfit name field optional The save button no longer requires a name, the hint marks the field optional on both platforms, and outfit card titles truncate to one line since generated names concatenate every item. Co-Authored-By: Claude Opus 5 (1M context) --- .../main/kotlin/com/github/worn/ui/components/OutfitCard.kt | 6 +++++- .../kotlin/com/github/worn/ui/screen/CreateOutfitSheet.kt | 3 ++- composeApp/src/main/res/values-pt-rBR/strings.xml | 2 +- composeApp/src/main/res/values/strings.xml | 2 +- iosApp/iosApp/Screens/CreateOutfitSheet.swift | 3 ++- iosApp/iosApp/Screens/OutfitsScreen.swift | 3 +++ iosApp/iosApp/en.lproj/Localizable.strings | 2 +- iosApp/iosApp/pt-BR.lproj/Localizable.strings | 2 +- journeys/create-first-outfit.xml | 2 +- 9 files changed, 17 insertions(+), 8 deletions(-) diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/components/OutfitCard.kt b/composeApp/src/main/kotlin/com/github/worn/ui/components/OutfitCard.kt index a8cf903..d3a5a7e 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/components/OutfitCard.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/components/OutfitCard.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.github.worn.R @@ -146,12 +147,15 @@ private fun BottomRow(outfit: Outfit) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom, ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.weight(1f)) { Text( text = outfit.name, color = WornColors.TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, + // Auto-generated names concatenate every item, so they can outgrow the card. + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Text( text = formatDate(outfit.createdAt), diff --git a/composeApp/src/main/kotlin/com/github/worn/ui/screen/CreateOutfitSheet.kt b/composeApp/src/main/kotlin/com/github/worn/ui/screen/CreateOutfitSheet.kt index 0c55bbd..0e230ee 100644 --- a/composeApp/src/main/kotlin/com/github/worn/ui/screen/CreateOutfitSheet.kt +++ b/composeApp/src/main/kotlin/com/github/worn/ui/screen/CreateOutfitSheet.kt @@ -114,7 +114,8 @@ internal fun CreateOutfitForm( ) { val isEditing = existingOutfit != null var name by remember { mutableStateOf(existingOutfit?.name ?: "") } - val canSave = name.isNotBlank() && selectedItemIds.isNotEmpty() && !isSaving + // The name is optional — an empty one is filled in with the selected items' names. + val canSave = selectedItemIds.isNotEmpty() && !isSaving Column( modifier = Modifier diff --git a/composeApp/src/main/res/values-pt-rBR/strings.xml b/composeApp/src/main/res/values-pt-rBR/strings.xml index a9b6984..1330868 100644 --- a/composeApp/src/main/res/values-pt-rBR/strings.xml +++ b/composeApp/src/main/res/values-pt-rBR/strings.xml @@ -63,7 +63,7 @@ Criar combinação Editar combinação - Nome da combinação + Nome da combinação (opcional) Selecionar itens Salvar combinação diff --git a/composeApp/src/main/res/values/strings.xml b/composeApp/src/main/res/values/strings.xml index 8cfaba5..6e25f47 100644 --- a/composeApp/src/main/res/values/strings.xml +++ b/composeApp/src/main/res/values/strings.xml @@ -63,7 +63,7 @@ Create outfit Edit outfit - Outfit name + Outfit name (optional) Select items Save outfit diff --git a/iosApp/iosApp/Screens/CreateOutfitSheet.swift b/iosApp/iosApp/Screens/CreateOutfitSheet.swift index e1bf11f..bb1ce74 100644 --- a/iosApp/iosApp/Screens/CreateOutfitSheet.swift +++ b/iosApp/iosApp/Screens/CreateOutfitSheet.swift @@ -15,8 +15,9 @@ struct CreateOutfitSheet: View { @State private var name = "" @State private var didInitFromExisting = false + // The name is optional — an empty one is filled in with the selected items' names. private var canSave: Bool { - !name.isEmpty && !selectedItemIds.isEmpty && !isSaving + !selectedItemIds.isEmpty && !isSaving } var body: some View { diff --git a/iosApp/iosApp/Screens/OutfitsScreen.swift b/iosApp/iosApp/Screens/OutfitsScreen.swift index 0f3603d..abdafe5 100644 --- a/iosApp/iosApp/Screens/OutfitsScreen.swift +++ b/iosApp/iosApp/Screens/OutfitsScreen.swift @@ -322,9 +322,12 @@ private struct OutfitCardView: View { private var bottomRow: some View { HStack(alignment: .bottom) { VStack(alignment: .leading, spacing: 2) { + // Auto-generated names concatenate every item, so they can outgrow the card. Text(outfit.name) .font(.system(size: 16, weight: .semibold)) .foregroundColor(WornColors.textPrimary) + .lineLimit(1) + .truncationMode(.tail) Text(formatDate(outfit.createdAt)) .font(.system(size: 12)) .foregroundColor(WornColors.textSecondary) diff --git a/iosApp/iosApp/en.lproj/Localizable.strings b/iosApp/iosApp/en.lproj/Localizable.strings index 9a3e67c..c4725e3 100644 --- a/iosApp/iosApp/en.lproj/Localizable.strings +++ b/iosApp/iosApp/en.lproj/Localizable.strings @@ -60,7 +60,7 @@ /* Create Outfit Sheet */ "create_outfit_title" = "Create outfit"; "create_outfit_title_edit" = "Edit outfit"; -"create_outfit_name_hint" = "Outfit name"; +"create_outfit_name_hint" = "Outfit name (optional)"; "create_outfit_select_items" = "Select items"; "create_outfit_save" = "Save outfit"; diff --git a/iosApp/iosApp/pt-BR.lproj/Localizable.strings b/iosApp/iosApp/pt-BR.lproj/Localizable.strings index f20734d..f6495eb 100644 --- a/iosApp/iosApp/pt-BR.lproj/Localizable.strings +++ b/iosApp/iosApp/pt-BR.lproj/Localizable.strings @@ -60,7 +60,7 @@ /* Create Outfit Sheet */ "create_outfit_title" = "Criar combinação"; "create_outfit_title_edit" = "Editar combinação"; -"create_outfit_name_hint" = "Nome da combinação"; +"create_outfit_name_hint" = "Nome da combinação (opcional)"; "create_outfit_select_items" = "Selecionar itens"; "create_outfit_save" = "Salvar combinação"; diff --git a/journeys/create-first-outfit.xml b/journeys/create-first-outfit.xml index a808a07..23460f4 100644 --- a/journeys/create-first-outfit.xml +++ b/journeys/create-first-outfit.xml @@ -15,7 +15,7 @@ Tap the "Create your first outfit" button. - Verify the "Create outfit" sheet is shown with an "Outfit name" field and a + Verify the "Create outfit" sheet is shown with an "Outfit name (optional)" field and a "Select items" section.