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.
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()