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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion composeApp/src/main/res/values-pt-rBR/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
<!-- Create Outfit Sheet -->
<string name="create_outfit_title">Criar combinação</string>
<string name="create_outfit_title_edit">Editar combinação</string>
<string name="create_outfit_name_hint">Nome da combinação</string>
<string name="create_outfit_name_hint">Nome da combinação (opcional)</string>
<string name="create_outfit_select_items">Selecionar itens</string>
<string name="create_outfit_save">Salvar combinação</string>

Expand Down
2 changes: 1 addition & 1 deletion composeApp/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
<!-- Create Outfit Sheet -->
<string name="create_outfit_title">Create outfit</string>
<string name="create_outfit_title_edit">Edit outfit</string>
<string name="create_outfit_name_hint">Outfit name</string>
<string name="create_outfit_name_hint">Outfit name (optional)</string>
<string name="create_outfit_select_items">Select items</string>
<string name="create_outfit_save">Save outfit</string>

Expand Down
3 changes: 2 additions & 1 deletion iosApp/iosApp/Screens/CreateOutfitSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions iosApp/iosApp/Screens/OutfitsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion iosApp/iosApp/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
2 changes: 1 addition & 1 deletion iosApp/iosApp/pt-BR.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
2 changes: 1 addition & 1 deletion journeys/create-first-outfit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Tap the "Create your first outfit" button.
</action>
<action>
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.
</action>
<action>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<WardrobeDatabase>()
private val outfitQueries = mockk<OutfitQueries>(relaxed = true)
private val outfitItemQueries = mockk<OutfitItemQueries>(relaxed = true)
private val clothingItemQueries = mockk<ClothingItemQueries>(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<TransactionWithoutReturn.() -> Unit>()) } answers {
val body = arg<TransactionWithoutReturn.() -> Unit>(1)
val tx = mockk<TransactionWithoutReturn>(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<String, String>) {
every { clothingItemQueries.getNamesByIds(any(), any<(String, String) -> Any>()) } answers {
val ids = arg<Collection<String>>(0).toSet()
@Suppress("UNCHECKED_CAST")
val mapper = arg<(String, String) -> Any>(1) as (String, String) -> Pair<String, String>
mockk<Query<Pair<String, String>>> {
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Outfit> = 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>): String =
name.ifBlank { defaultName(itemIds) }.trim()

private fun defaultName(itemIds: List<String>): 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<Unit> = runCatching {
withContext(dispatcher) {
db.outfitQueries.delete(id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<OutfitEffect.OutfitCreated>(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()
Expand Down
Loading