From 1eefa7a8228411201499ac95c32deb9e67a907d7 Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Tue, 10 Mar 2026 14:31:07 +0900 Subject: [PATCH 01/23] =?UTF-8?q?chore=20:=20KanbanBoardCard=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composeApp/build.gradle.kts | 5 +- .../kotlin/woowacourse/kanban/board/App.kt | 37 ++-- .../board/domain/KanbanBoardCardData.kt | 61 ++++++ .../kanban/board/ui/InputWindow.kt | 95 ++++++++ .../kanban/board/ui/KanbanBoardCard.kt | 204 ++++++++++++++++++ .../kanban/board/ui/KanbanBoardCardPreview.kt | 59 +++++ .../board/domain/KanbanBoardCardDataTest.kt | 132 ++++++++++++ .../kanban/board/ui/KanbanBoardCardTest.kt | 97 +++++++++ .../woowacourse/kanban/board/ui/study.kt | 111 ++++++++++ gradle/gradle-daemon-jvm.properties | 13 ++ 10 files changed, 791 insertions(+), 23 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/KanbanBoardCardData.kt create mode 100644 composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/InputWindow.kt create mode 100644 composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCard.kt create mode 100644 composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardPreview.kt create mode 100644 composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/KanbanBoardCardDataTest.kt create mode 100644 composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardTest.kt create mode 100644 composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/study.kt create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2606666..7c5d8ac 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -33,9 +33,9 @@ kotlin { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) + implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3") } commonTest.dependencies { - implementation(libs.kotlin.test) implementation(libs.assertj.core) @OptIn(ExperimentalComposeLibrary::class) implementation(compose.uiTest) @@ -45,6 +45,9 @@ kotlin { implementation(libs.kotlinx.coroutinesSwing) } } + sourceSets.commonMain.dependencies { + implementation(kotlin("test")) + } } android { diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt index 2944a25..9153c75 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt @@ -1,43 +1,36 @@ package woowacourse.kanban.board -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image -import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.safeContentPadding -import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import kanbanboard.composeapp.generated.resources.Res -import kanbanboard.composeapp.generated.resources.compose_multiplatform -import org.jetbrains.compose.resources.painterResource +import woowacourse.kanban.board.domain.KanbanBoardCardData +import woowacourse.kanban.board.ui.InputWindow @Composable @Preview fun App() { MaterialTheme { - var showContent by remember { mutableStateOf(false) } - Column( - modifier = Modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .safeContentPadding() - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, + val kanbanBoardCards = remember { mutableStateListOf() } + var showInputWindow by remember { mutableStateOf(false) } + + Box( + modifier = Modifier.fillMaxSize(), ) { - Button(onClick = { showContent = !showContent }) { - Text("Click me!") - } - AnimatedVisibility(showContent) { - Image(painterResource(Res.drawable.compose_multiplatform), null) + + if (showInputWindow) { + InputWindow( + onAddItem = { kanbanBoardCards.add(it) }, + onShowInputWindow = { showInputWindow = it }, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/KanbanBoardCardData.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/KanbanBoardCardData.kt new file mode 100644 index 0000000..12e4817 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/KanbanBoardCardData.kt @@ -0,0 +1,61 @@ +package woowacourse.kanban.board.domain + +/** + * KanbanBoardCard 도메인 모델입니다. + * 카드 생성 규칙을 적용합니다. + * 생성은 [create] 팩토리 메서드로 수행합니다. + */ +class KanbanBoardCardData private constructor( + val title: String, + val content: String, + val tags: List, + val accountName: String, +) { + companion object { + private const val MAX_TAG_COUNT = 5 + private const val MAX_TAG_LENGTH = 5 + + /** + * [KanbanBoardCardData] 객체 생성 팩토리 메서드입니다. + * @param title 필수 | 제목 + * @param content 본문 + * @param tags 태그 + * @param accountName 필수 | 계정명 + * @throws IllegalArgumentException 기능 요구사항을 충족하지 않을 경우 예외를 던집니다. + */ + fun create( + title: String, + content: String, + tags: List, + accountName: String, + ): KanbanBoardCardData { + require(title.isNotBlank()) { "[KanbanCard] 제목은 필수 입력 항목입니다." } + require(accountName.isNotBlank()) { "[KanbanCard] 계정명은 필수 입력 항목입니다." } + + val normalizedTags = tags + .map { it.trim() } + .filter { it.isNotEmpty() } + + require(normalizedTags.size <= MAX_TAG_COUNT) { "[KanbanCard] 태그는 최대 ${MAX_TAG_COUNT}개까지 가능합니다." } + require(normalizedTags.all { it.length <= MAX_TAG_LENGTH }) { "[KanbanCard] 태그는 최대 ${MAX_TAG_LENGTH}자까지 가능합니다." } + + return KanbanBoardCardData( + title = title, + content = content, + tags = normalizedTags, + accountName = accountName, + ) + } + } + + /** + * 카드 내용 존재 여부를 리턴합니다. + * @return 내용이 공백이 아니면 true 리턴. + */ + fun hasContent(): Boolean = content.isNotBlank() + /** + * 태그 존재 여부를 리턴합니다 + * @return 태그가 있다면 true 리턴. + */ + fun hasTag(): Boolean = tags.isNotEmpty() +} diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/InputWindow.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/InputWindow.kt new file mode 100644 index 0000000..e7fb1a8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/InputWindow.kt @@ -0,0 +1,95 @@ +package woowacourse.kanban.board.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import woowacourse.kanban.board.domain.KanbanBoardCardData + +@Composable +fun InputWindow( + onAddItem: (KanbanBoardCardData) -> Unit, + onShowInputWindow: (Boolean) -> Unit, +) { + var title by remember { mutableStateOf("LazyColumn 컴포넌트 구현") } + var contents by remember { mutableStateOf("") } + var tempTag by remember { mutableStateOf("") } + var tags by remember { mutableStateOf(listOf()) } + var author by remember { mutableStateOf("다이노") } + + OutlinedCard(modifier = Modifier.padding(10.dp)) { + Column(modifier = Modifier.padding(10.dp)) { + // 제목 입력 + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("제목 입력") }, + modifier = Modifier.fillMaxWidth(), + ) + + // 내용 입력 + OutlinedTextField( + value = contents, + onValueChange = { contents = it }, + label = { Text("내용 입력") }, + modifier = Modifier.fillMaxWidth(), + ) + + // 태그 입력 + OutlinedTextField( + value = tempTag, + onValueChange = { tempTag = it }, + label = { Text("태그 입력 후 엔터") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + if (tempTag.isNotBlank() && !tags.contains(tempTag)) { + tags = tags + tempTag.trim() + tempTag = "" + } + }, + ), + ) + + /** + * 작성자 입력 + * @param author 작성자 + * @param onAuthorChange 작성자 변경 콜백 + * @param onAddItem 추가 콜백 + */ + OutlinedTextField( + value = author, + onValueChange = { author = it }, + label = { Text("작성자 입력") }, + modifier = Modifier.fillMaxWidth(), + ) + + // 확인 버튼 + Button( + onClick = { + onAddItem(KanbanBoardCardData.create(title, contents, tags, author)) + onShowInputWindow(false) + }, + modifier = Modifier.align(Alignment.End), + ) { + Text("확인") + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCard.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCard.kt new file mode 100644 index 0000000..8a6a5db --- /dev/null +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCard.kt @@ -0,0 +1,204 @@ +package woowacourse.kanban.board.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import woowacourse.kanban.board.domain.KanbanBoardCardData + +/** + * KanbanBoardCard UI입니다. + * @param kanbanBoardCardData KanbanCard의 데이터입니다. + * @param modifier Modifier + */ +@Composable +fun KanbanBoardCard( + kanbanBoardCardData: KanbanBoardCardData, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .background(color = Color(0xffffffff), shape = RoundedCornerShape(16.dp)) + .border(color = Color(0xffE5E7Eb), width = 1.dp, shape = RoundedCornerShape(16.dp)) + .padding(all = 17.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + + CardTitle( + title = kanbanBoardCardData.title, + modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Kanban Card Title" }, + ) + + if (kanbanBoardCardData.hasContent()) { + CardContent( + modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Kanban Card Content" }, + content = kanbanBoardCardData.content + ) + } + + if (kanbanBoardCardData.hasTag()) CardTagsSection( + tags = kanbanBoardCardData.tags + ) + + HorizontalDivider() + + CardAccountInfo( + accountName = kanbanBoardCardData.accountName, + modifier = Modifier + .padding(vertical = 10.dp) + .fillMaxWidth() + .semantics { contentDescription = "Kanban Card Account Info" }, + accountImage = Icons.Default.AccountCircle, /* 추후 api나, Async 등으로 이미지를 불러올 경우 수정할 예정. */ + ) + } +} + +/** + * 최대 1줄까지 표시되는 Card의 Header입니다. + * @param modifier Modifier + * @param title 카드 제목으로, 너무 길면...로 표시됩니다. + */ +@Composable +private fun CardTitle(title: String, modifier: Modifier = Modifier) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.3.sp, + lineHeight = 24.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} + +@Preview(backgroundColor = 0xffffffff, showBackground = true) +@Composable +fun CardTitlePreview() { + CardTitle(title = "Card Title") +} + + +/** + * 최대 2줄까지 표시되는 Card의 Content입니다. + * @param modifier Modifier + * @param content 카드 본문으로, 너무 길면 ...로 표시됩니다. + */ +@Composable +private fun CardContent(modifier: Modifier = Modifier, content: String) { + Text( + text = content, + fontSize = 14.sp, + letterSpacing = 0.15.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.W400, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} + +@Preview(backgroundColor = 0xffffffff, showBackground = true) +@Composable +fun CardContentPreview() { + CardContent(content = "Card Content") +} + +/** + * CardTag 섹션입니다. TagChip이 표시됩니다. + * @param tags 카드 태그로, 최대 5개까지 입력할 수 있습니다. + */ +@Composable +private fun CardTagsSection(tags: List = listOf()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) {/* TagModifer, SectionModifier로 분리할까 고민했으나, 우선 현 방식대로 수정. */ + tags.forEach { TagChip(modifier = Modifier.semantics { contentDescription = "Kanban Card Tag" }, chipContent = it) } + } +} + +/** + * CardTag의 Chip입니다. + * @param modifier Modifier + * @param chipContent TagChip의 내용입니다. + */ +@Composable +private fun TagChip(modifier: Modifier = Modifier, chipContent: String) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .background(color = Color(0xfff3f4f6), shape = RoundedCornerShape(16.dp)) + .padding(vertical = 5.dp, horizontal = 8.dp), + ) { + Text(text = chipContent, fontWeight = FontWeight.W400, fontSize = 12.sp) + } +} + +@Preview(backgroundColor = 0xffffffff, showBackground = true) +@Composable +fun TagChipPreview() { + TagChip(chipContent = "Tag") +} + +/** + * CardAccountInfo 섹션입니다. + * @param modifier Modifier + * @param accountImage 프로필 아이콘입니다. 기본 값은 Icons.Default.AccountCircle입니다. + * @param accountName 카드 계정 이름으로, 너무 길면 ...로 표시됩니다. + */ +@Composable +private fun CardAccountInfo( + accountName: String, + modifier: Modifier = Modifier, + accountImage: ImageVector = Icons.Default.AccountCircle, +) { + Row( + modifier = modifier, + ) { + Icon( + imageVector = accountImage, + contentDescription = "프로필 아이콘", + modifier = Modifier.size(24.dp), + tint = Color(0xff838383), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = accountName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Preview(backgroundColor = 0xffffffff, showBackground = true) +@Composable +fun CardAccountInfoPreview() { + CardAccountInfo(accountName = "Test") +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardPreview.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardPreview.kt new file mode 100644 index 0000000..7c55807 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardPreview.kt @@ -0,0 +1,59 @@ +package woowacourse.kanban.board.ui + +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import woowacourse.kanban.board.domain.KanbanBoardCardData + +/** + * 여러 케이스에 따른 KanbanCard의 Preview를 모아볼 수 있습니다. + */ +private class KanbanCardPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + KanbanBoardCardData.create( + title = "Lazy Column 컴포넌트 구현", + content = "세로 스크롤 가능한 리스트 컴포넌트를 만들고 성능 최적화를 적용합니다.", + tags = listOf("컴포넌트", "성능"), + accountName = "구름", + ), + KanbanBoardCardData.create( + title = "Lazy Column 컴포넌트 구현", + content = "", + tags = listOf("컴포넌트", "성능"), + accountName = "구름", + ), + KanbanBoardCardData.create( + title = "Lazy Column 컴포넌트 구현", + content = "세로 스크롤 가능한 리스트 컴포넌트를 만들고 성능 최적화를 적용합니다.", + tags = emptyList(), + accountName = "구름", + ), + KanbanBoardCardData.create( + title = "Lazy Column 컴포넌트 구현", + content = "", + tags = emptyList(), + accountName = "구름", + ), + KanbanBoardCardData.create( + title = "너무너무 긴 제목은 한 줄까지만 노출너무너무 긴 제목은 한 줄까지만 노출", + content = "너무너무너무 긴 설명은 두 줄까지만 노출하고 말줄임표로 처리합니다 두 줄까지만 노너무너무너무 긴 설명은 두 줄까지만 노출하고 말줄임표로 처리합니다 두 줄까지만 노", + tags = listOf("너무너무", "긴 태그", "최대로", "5자까지진짜로", "5개제한임", "6개"), + accountName = "너무너무너무 긴 담당자도 한 줄너무너무너무 긴 담당자도 한 줄", + ), + ) +} + +@Preview(showBackground = true, name = "KanbanBoardCard") +@Composable +private fun KanbanBoardCardPreview( + @PreviewParameter(KanbanCardPreviewParameterProvider::class) kanbanBoardCardData: KanbanBoardCardData, +) { + KanbanBoardCard( + modifier = Modifier.width(286.dp), + kanbanBoardCardData = kanbanBoardCardData, + ) +} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/KanbanBoardCardDataTest.kt b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/KanbanBoardCardDataTest.kt new file mode 100644 index 0000000..c5be8f4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/KanbanBoardCardDataTest.kt @@ -0,0 +1,132 @@ +package woowacourse.kanban.board.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [KanbanBoardCardData] Unit 테스트 클래스입니다. + */ +class KanbanBoardCardDataTest { + @Test + fun `제목이 공백만 있으면 생성할 수 없다`() { + assertFailsWith { + KanbanBoardCardData.create( + title = " ", + content = "내용", + tags = listOf("태그1"), + accountName = "테스트 계정", + ) + } + } + + @Test + fun `계정명이 공백만 있으면 생성할 수 없다`() { + assertFailsWith { + KanbanBoardCardData.create( + title = "제목", + content = "내용", + tags = listOf("태그1"), + accountName = " ", + ) + } + } + + @Test + fun `내용이 있으면 hasContent 리턴 값은 true이다`() { + val cardData = KanbanBoardCardData.create( + title = "제목", + content = "내용", + tags = emptyList(), + accountName = "테스트 계정", + ) + + assertTrue(cardData.hasContent()) + } + + @Test + fun `내용이 공백이면 hasContent 리턴 값은 false이다`() { + val cardData = KanbanBoardCardData.create( + title = "제목", + content = " ", + tags = emptyList(), + accountName = "테스트 계정", + ) + + assertFalse(cardData.hasContent()) + } + + @Test + fun `태그의 앞뒤 공백은 제거된다`() { + val cardData = KanbanBoardCardData.create( + title = "제목", + content = "내용", + tags = listOf(" 태그1 ", " 태그2 "), + accountName = "테스트 계정", + ) + + assertEquals(listOf("태그1", "태그2"), cardData.tags) + } + + @Test + fun `공백으로만 구성된 태그는 제거된다`() { + val cardData = KanbanBoardCardData.create( + title = "제목", + content = "내용", + tags = listOf("태그1", " ", "", " "), + accountName = "테스트 계정", + ) + + assertEquals(listOf("태그1"), cardData.tags) + } + + @Test + fun `태그가 5개를 초과하면 생성할 수 없다`() { + assertFailsWith { + KanbanBoardCardData.create( + title = "제목", + content = "내용", + tags = listOf("태그1", "태그2", "태그3", "태그4", "태그5", "태그6"), + accountName = "테스트 계정", + ) + } + } + + @Test + fun `태그 내용이 5글자를 초과하면 5글자까지만 유지된다`() { + assertFailsWith { + KanbanBoardCardData.create( + title = "제목", + content = "내용", + tags = listOf("우아한테크코스", "안드로이드8기", "칸반보드리팩터링"), + accountName = "테스트 계정", + ) + } + } + + @Test + fun `태그가 있으면 hasTag 리턴 값은 true이다`() { + val cardData = KanbanBoardCardData.create( + title = "제목", + content = "내용", + tags = listOf("태그1", " "), + accountName = "테스트 계정", + ) + + assertTrue(cardData.hasTag()) + } + + @Test + fun `태그가 비어 있으면 hasTag 리턴 값은 false이다`() { + val cardData = KanbanBoardCardData.create( + title = "제목", + content = "내용", + tags = listOf(" ", ""), + accountName = "테스트 계정", + ) + + assertFalse(cardData.hasTag()) + } +} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardTest.kt b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardTest.kt new file mode 100644 index 0000000..a1054ee --- /dev/null +++ b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardTest.kt @@ -0,0 +1,97 @@ +package woowacourse.kanban.board.ui + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import woowacourse.kanban.board.domain.KanbanBoardCardData +import kotlin.test.Test + +/** + * [KanbanBoardCard] UI 테스트 클래스입니다. + */ +@OptIn(ExperimentalTestApi::class) +class KanbanBoardCardTest { + + @Test + fun `KanbanBoardCard의 제목, 내용, 태그, 계정명이 입력되면, 해당 필드가 모두 표시된다`() = runComposeUiTest { + val cardData = KanbanBoardCardData.create( + title = "제목", + content = "내용", + tags = listOf("태그1", "태그2", "태그3", "태그4", "태그5"), + accountName = "테스트 계정", + ) + + setContent { + KanbanBoardCard(kanbanBoardCardData = cardData) + } + + onNodeWithText("제목").assertIsDisplayed() + onNodeWithText("내용").assertIsDisplayed() + onNodeWithText("태그1").assertIsDisplayed() + onNodeWithText("태그2").assertIsDisplayed() + onNodeWithText("태그3").assertIsDisplayed() + onNodeWithText("태그4").assertIsDisplayed() + onNodeWithText("태그5").assertIsDisplayed() + onNodeWithText("테스트 계정").assertIsDisplayed() + } + + @Test + fun `제목과 계정명이 화면에 표시된다`() = runComposeUiTest { + val cardData = KanbanBoardCardData.create( + title = "제목", + content = "", + tags = emptyList(), + accountName = "테스트 계정", + ) + + setContent { + KanbanBoardCard(kanbanBoardCardData = cardData) + } + onNodeWithContentDescription("Kanban Card Title").assertIsDisplayed() + onNodeWithContentDescription("Kanban Card Content").assertDoesNotExist() + onAllNodesWithContentDescription("Kanban Card Tag").assertCountEquals(0) + onNodeWithContentDescription("Kanban Card Account Info").assertIsDisplayed() + } + + @Test + fun `내용이 없는 경우, 내용 영역이 표시되지 않는다`() = runComposeUiTest { + val tags = listOf("태그1", "태그2", "태그3", "태그4") + val cardData = KanbanBoardCardData.create( + title = "제목", + content = "", + tags = tags, + accountName = "테스트 계정", + ) + + setContent { + KanbanBoardCard(kanbanBoardCardData = cardData) + } + onNodeWithContentDescription("Kanban Card Title").assertIsDisplayed() + onNodeWithContentDescription("Kanban Card Content").assertDoesNotExist() + onAllNodesWithContentDescription("Kanban Card Tag").assertCountEquals(tags.size) + onNodeWithContentDescription("Kanban Card Account Info").assertIsDisplayed() + } + + @Test + fun `태그가 없는 경우, 태그가 출력되지 않는다`() = runComposeUiTest { + val cardData = KanbanBoardCardData.create( + title = "제목", + content = "내용", + tags = emptyList(), + accountName = "테스트 계정", + ) + + setContent { + KanbanBoardCard(kanbanBoardCardData = cardData) + } + + onNodeWithContentDescription("Kanban Card Title").assertIsDisplayed() + onNodeWithContentDescription("Kanban Card Content").assertIsDisplayed() + onAllNodesWithContentDescription("Kanban Card Tag").assertCountEquals(0) + onNodeWithContentDescription("Kanban Card Account Info").assertIsDisplayed() + } +} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/study.kt b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/study.kt new file mode 100644 index 0000000..204c221 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/study.kt @@ -0,0 +1,111 @@ +package woowacourse.kanban.board.ui + +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import kotlin.test.Test + +@OptIn(ExperimentalTestApi::class) +class RecompositionTest { + var username by mutableStateOf("") + var label by mutableStateOf("") + var validationCount = 0 + + @Before + fun setup() { + username = "" + label = "라벨" + validationCount = 0 + } + + @Composable + fun Username(username: String, label: String) { + val isError = (username.length in 2..5).also { validationCount++ } + + TextField( + value = username, + label = { Text(label) }, + isError = isError, + onValueChange = { this.username = it }, + ) + } + + // value, label 변경 시 모두 validationCount 증가 + @Composable + fun UsernameWithRemember(username: String, label: String) { + val isError = remember { (username.length in 2..5).also { validationCount++ } } + + TextField( + value = username, + label = { if (isError) Text("에러") else Text(label) }, + isError = isError, + onValueChange = { this.username = it }, + ) + } + + // remember 의 키로 넣은 값만 수정되었을 때 validationCount 증가 + @Composable + fun UsernameWithRememberKey(username: String, label: String) { + val isError = remember(username) { (username.length in 2..5).also { validationCount++ } } + + TextField( + value = username, + label = { if (isError) Text("에러") else Text(label) }, + isError = isError, + onValueChange = { this.username = it }, + ) + } + + @Test + fun `리컴포지션할 때 매번 유효성 검사`() = runComposeUiTest { + setContent { + Username(username = username, label = label) + } + + username = "김컴포즈" + waitForIdle() + assertThat(validationCount).isEqualTo(1) + + label = "바뀐 라벨" + waitForIdle() + assertThat(validationCount).isEqualTo(2) + } + + @Test + fun `최초 컴포지션만 유효성 검사`() = runComposeUiTest { + setContent { + UsernameWithRemember(username, label) + } + + waitForIdle() + assertThat(validationCount).isEqualTo(1) + username = "사무엘" + waitForIdle() + assertThat(validationCount).isEqualTo(1) + onNodeWithText("에러").assertExists() + } + + @Test + fun `특정 값 변경시만 유효성 검사`() = runComposeUiTest { + setContent { + UsernameWithRememberKey(username, label) + } + + username = "김컴포즈" + waitForIdle() + assertThat(validationCount).isEqualTo(1) + + label = "바뀐 라벨" + waitForIdle() + assertThat(validationCount).isEqualTo(1) + } +} \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..f4ce628 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/10fc3bf1ee0001078a473afe6e43cfdb/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9c55677aff3966382f3d853c0959bfb2/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/29ee363f71d060405f729a8f1b7f7aef/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/23adb857f3cb3cbe28750bc7faa7abc0/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/c9346d9c4bd3ae087fba56b027600ff7/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 From 1103f1020a13f992e9d0f4d90951d6ceae97d339 Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Tue, 10 Mar 2026 14:31:13 +0900 Subject: [PATCH 02/23] =?UTF-8?q?docs=20:=20README=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?-=20=EA=B8=B0=EC=A1=B4=20=EC=BD=94=EB=93=9C=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=97=AC?= =?UTF-8?q?=EB=B6=80=20=EC=B2=B4=ED=81=AC=20-=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20=EC=82=AC=ED=95=AD,=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=95=A0=20=EA=B8=B0=EB=8A=A5,=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EB=82=B4=EC=9A=A9=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 74 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 98d7e7e..700ad99 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,48 @@ -This is a Kotlin Multiplatform project targeting Android, Desktop (JVM). +# 칸반 보드 생성 +## 1단계 - 칸반 보드 생성(상품 목록) -* [/composeApp](./composeApp/src) is for code that will be shared across your Compose Multiplatform applications. - It contains several subfolders: - - [commonMain](./composeApp/src/commonMain/kotlin) is for code that’s common for all targets. - - Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. - For example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app, - the [iosMain](./composeApp/src/iosMain/kotlin) folder would be the right place for such calls. - Similarly, if you want to edit the Desktop (JVM) specific part, the [jvmMain](./composeApp/src/jvmMain/kotlin) - folder is the appropriate location. +## 요구 사항 +### 기능 요구 사항 +- [ ] 필수 입력 란과 선택 입력 란을 구분한다. + - [ ] 필수 입력: 제목, 상태, 담당자 + - [ ] 선택 입력: 설명, 태그 +- [ ] 상태와 담당자는 첫 번째 항목으로 기본 선택되어 있고, 한 항목만 선택 가능하다. +- [ ] 유효성 검사가 실패하면 생성 버튼을 누를 수 없다. +- [x] 기존 코드를 마이그레이션한 뒤 하나의 커밋으로 합친다. -### Build and Run Android Application +### 프로그래밍 요구 사항 +- ViewModel, Hilt 등은 장바구니 미션에서 활용하지 않는다. 컴포즈 학습에 집중하자. +- 컴포저블 함수가 너무 많은 일을 하지 않도록 분리하기 위해 노력해 본다. +- 디자인 정합성을 맞추기 위한 너무 많은 노력을 기울이지 않아도 된다. + - 1px 단위에 연연하지 말고, 폰트와 색상도 중요하지 않다. +- 단위 테스트만으로도 충분한 로직과, UI 테스트가 필요한 영역을 구분한다. + - 핵심 비즈니스 로직을 가지는 객체를 분리해 단위 테스트를 진행한다. + - Compose UI Testing을 활용하여 기능 요구 사항을 테스트한다. -To build and run the development version of the Android app, use the run configuration from the run widget -in your IDE’s toolbar or build it directly from the terminal: -- on macOS/Linux - ```shell - ./gradlew :composeApp:assembleDebug - ``` -- on Windows - ```shell - .\gradlew.bat :composeApp:assembleDebug - ``` +## 구현할 기능 +### UI +- [ ] 제목 입력 필드 작성 +- [ ] 설명 입력 필드 작성 +- [ ] 태그 입력 필드 작성 +- [ ] 상태 선택 버튼 작성 +- [ ] 담당자 선택 버튼 작성 +- [ ] 생성, 취소, 닫기 버튼 작성 +- [ ] KanbanBoard 새 태스크 생성 버튼 작성 +- [ ] Card 입력 UI 내 비즈니즈 로직 분리 -### Build and Run Desktop (JVM) Application +### Domain +- [ ] 상태 로직 추가 +- [ ] 디자인 가이드에 따른 용어 변경 -To build and run the development version of the desktop app, use the run configuration from the run widget -in your IDE’s toolbar or run it directly from the terminal: -- on macOS/Linux - ```shell - ./gradlew :composeApp:run - ``` -- on Windows - ```shell - .\gradlew.bat :composeApp:run - ``` +## 테스트 +### Unit 테스트 +- [ ] 상태 관련 테스트 로직 추가 ---- +### UI 테스트 +- [ ] 상태 버튼 클릭 시 선택 효과 표시 여부 확인 +- [ ] 담당자 버튼 클릭 시 선택 효과 표시 여부 확인 -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… \ No newline at end of file +## 리팩토링 +### 1단계 +- 프로덕션 +- 테스트 From b3d8ac58c1770d7e2f7c75490cecfae8cfdf6b14 Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Tue, 10 Mar 2026 14:47:27 +0900 Subject: [PATCH 03/23] =?UTF-8?q?fix=20:=20=EC=BD=94=EB=93=9C=20=EB=82=B4?= =?UTF-8?q?=20Card=20=EB=AA=85=EC=B9=AD=20=EC=88=98=EC=A0=95=20-=20Kanban.?= =?UTF-8?q?..=20=EC=A0=91=EB=91=90=EC=96=B4=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20Card=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + .../kotlin/woowacourse/kanban/board/App.kt | 9 ++-- .../{KanbanBoardCardData.kt => CardData.kt} | 18 ++++---- .../board/ui/{KanbanBoardCard.kt => Card.kt} | 22 ++++----- .../{InputWindow.kt => CardCreationPanel.kt} | 8 ++-- ...nbanBoardCardPreview.kt => CardPreview.kt} | 24 +++++----- ...anBoardCardDataTest.kt => CardDataTest.kt} | 24 +++++----- .../{KanbanBoardCardTest.kt => CardTest.kt} | 46 +++++++++---------- 8 files changed, 76 insertions(+), 76 deletions(-) rename composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/{KanbanBoardCardData.kt => CardData.kt} (69%) rename composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/{KanbanBoardCard.kt => Card.kt} (92%) rename composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/{InputWindow.kt => CardCreationPanel.kt} (93%) rename composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/{KanbanBoardCardPreview.kt => CardPreview.kt} (77%) rename composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/{KanbanBoardCardDataTest.kt => CardDataTest.kt} (85%) rename composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/{KanbanBoardCardTest.kt => CardTest.kt} (59%) diff --git a/README.md b/README.md index 700ad99..e67494b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - [ ] 상태와 담당자는 첫 번째 항목으로 기본 선택되어 있고, 한 항목만 선택 가능하다. - [ ] 유효성 검사가 실패하면 생성 버튼을 누를 수 없다. - [x] 기존 코드를 마이그레이션한 뒤 하나의 커밋으로 합친다. +- [x] 페어와 협의에 따른 카드 명칭 변경 ### 프로그래밍 요구 사항 - ViewModel, Hilt 등은 장바구니 미션에서 활용하지 않는다. 컴포즈 학습에 집중하자. diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt index 9153c75..22e9b1b 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt @@ -1,7 +1,6 @@ package woowacourse.kanban.board import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -12,14 +11,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import woowacourse.kanban.board.domain.KanbanBoardCardData -import woowacourse.kanban.board.ui.InputWindow +import woowacourse.kanban.board.domain.CardData +import woowacourse.kanban.board.ui.CardCreationPanel @Composable @Preview fun App() { MaterialTheme { - val kanbanBoardCards = remember { mutableStateListOf() } + val kanbanBoardCards = remember { mutableStateListOf() } var showInputWindow by remember { mutableStateOf(false) } Box( @@ -27,7 +26,7 @@ fun App() { ) { if (showInputWindow) { - InputWindow( + CardCreationPanel( onAddItem = { kanbanBoardCards.add(it) }, onShowInputWindow = { showInputWindow = it }, ) diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/KanbanBoardCardData.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt similarity index 69% rename from composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/KanbanBoardCardData.kt rename to composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt index 12e4817..c46dacb 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/KanbanBoardCardData.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt @@ -1,11 +1,11 @@ package woowacourse.kanban.board.domain /** - * KanbanBoardCard 도메인 모델입니다. + * Card 도메인 모델입니다. * 카드 생성 규칙을 적용합니다. * 생성은 [create] 팩토리 메서드로 수행합니다. */ -class KanbanBoardCardData private constructor( +class CardData private constructor( val title: String, val content: String, val tags: List, @@ -16,7 +16,7 @@ class KanbanBoardCardData private constructor( private const val MAX_TAG_LENGTH = 5 /** - * [KanbanBoardCardData] 객체 생성 팩토리 메서드입니다. + * [CardData] 객체 생성 팩토리 메서드입니다. * @param title 필수 | 제목 * @param content 본문 * @param tags 태그 @@ -28,18 +28,18 @@ class KanbanBoardCardData private constructor( content: String, tags: List, accountName: String, - ): KanbanBoardCardData { - require(title.isNotBlank()) { "[KanbanCard] 제목은 필수 입력 항목입니다." } - require(accountName.isNotBlank()) { "[KanbanCard] 계정명은 필수 입력 항목입니다." } + ): CardData { + require(title.isNotBlank()) { "[Card] 제목은 필수 입력 항목입니다." } + require(accountName.isNotBlank()) { "[Card] 계정명은 필수 입력 항목입니다." } val normalizedTags = tags .map { it.trim() } .filter { it.isNotEmpty() } - require(normalizedTags.size <= MAX_TAG_COUNT) { "[KanbanCard] 태그는 최대 ${MAX_TAG_COUNT}개까지 가능합니다." } - require(normalizedTags.all { it.length <= MAX_TAG_LENGTH }) { "[KanbanCard] 태그는 최대 ${MAX_TAG_LENGTH}자까지 가능합니다." } + require(normalizedTags.size <= MAX_TAG_COUNT) { "[Card] 태그는 최대 ${MAX_TAG_COUNT}개까지 가능합니다." } + require(normalizedTags.all { it.length <= MAX_TAG_LENGTH }) { "[Card] 태그는 최대 ${MAX_TAG_LENGTH}자까지 가능합니다." } - return KanbanBoardCardData( + return CardData( title = title, content = content, tags = normalizedTags, diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCard.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Card.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCard.kt rename to composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Card.kt index 8a6a5db..2187261 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCard.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Card.kt @@ -30,16 +30,16 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import woowacourse.kanban.board.domain.KanbanBoardCardData +import woowacourse.kanban.board.domain.CardData /** - * KanbanBoardCard UI입니다. - * @param kanbanBoardCardData KanbanCard의 데이터입니다. + * Card UI입니다. + * @param cardData Card의 데이터입니다. * @param modifier Modifier */ @Composable -fun KanbanBoardCard( - kanbanBoardCardData: KanbanBoardCardData, +fun Card( + cardData: CardData, modifier: Modifier = Modifier, ) { Column( @@ -51,25 +51,25 @@ fun KanbanBoardCard( ) { CardTitle( - title = kanbanBoardCardData.title, + title = cardData.title, modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Kanban Card Title" }, ) - if (kanbanBoardCardData.hasContent()) { + if (cardData.hasContent()) { CardContent( modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Kanban Card Content" }, - content = kanbanBoardCardData.content + content = cardData.content ) } - if (kanbanBoardCardData.hasTag()) CardTagsSection( - tags = kanbanBoardCardData.tags + if (cardData.hasTag()) CardTagsSection( + tags = cardData.tags ) HorizontalDivider() CardAccountInfo( - accountName = kanbanBoardCardData.accountName, + accountName = cardData.accountName, modifier = Modifier .padding(vertical = 10.dp) .fillMaxWidth() diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/InputWindow.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt similarity index 93% rename from composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/InputWindow.kt rename to composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt index e7fb1a8..0c514f4 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/InputWindow.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt @@ -18,11 +18,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp -import woowacourse.kanban.board.domain.KanbanBoardCardData +import woowacourse.kanban.board.domain.CardData @Composable -fun InputWindow( - onAddItem: (KanbanBoardCardData) -> Unit, +fun CardCreationPanel( + onAddItem: (CardData) -> Unit, onShowInputWindow: (Boolean) -> Unit, ) { var title by remember { mutableStateOf("LazyColumn 컴포넌트 구현") } @@ -83,7 +83,7 @@ fun InputWindow( // 확인 버튼 Button( onClick = { - onAddItem(KanbanBoardCardData.create(title, contents, tags, author)) + onAddItem(CardData.create(title, contents, tags, author)) onShowInputWindow(false) }, modifier = Modifier.align(Alignment.End), diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardPreview.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardPreview.kt similarity index 77% rename from composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardPreview.kt rename to composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardPreview.kt index 7c55807..b84244a 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardPreview.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardPreview.kt @@ -7,38 +7,38 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import woowacourse.kanban.board.domain.KanbanBoardCardData +import woowacourse.kanban.board.domain.CardData /** * 여러 케이스에 따른 KanbanCard의 Preview를 모아볼 수 있습니다. */ -private class KanbanCardPreviewParameterProvider : PreviewParameterProvider { - override val values: Sequence = sequenceOf( - KanbanBoardCardData.create( +private class CardPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + CardData.create( title = "Lazy Column 컴포넌트 구현", content = "세로 스크롤 가능한 리스트 컴포넌트를 만들고 성능 최적화를 적용합니다.", tags = listOf("컴포넌트", "성능"), accountName = "구름", ), - KanbanBoardCardData.create( + CardData.create( title = "Lazy Column 컴포넌트 구현", content = "", tags = listOf("컴포넌트", "성능"), accountName = "구름", ), - KanbanBoardCardData.create( + CardData.create( title = "Lazy Column 컴포넌트 구현", content = "세로 스크롤 가능한 리스트 컴포넌트를 만들고 성능 최적화를 적용합니다.", tags = emptyList(), accountName = "구름", ), - KanbanBoardCardData.create( + CardData.create( title = "Lazy Column 컴포넌트 구현", content = "", tags = emptyList(), accountName = "구름", ), - KanbanBoardCardData.create( + CardData.create( title = "너무너무 긴 제목은 한 줄까지만 노출너무너무 긴 제목은 한 줄까지만 노출", content = "너무너무너무 긴 설명은 두 줄까지만 노출하고 말줄임표로 처리합니다 두 줄까지만 노너무너무너무 긴 설명은 두 줄까지만 노출하고 말줄임표로 처리합니다 두 줄까지만 노", tags = listOf("너무너무", "긴 태그", "최대로", "5자까지진짜로", "5개제한임", "6개"), @@ -49,11 +49,11 @@ private class KanbanCardPreviewParameterProvider : PreviewParameterProvider { - KanbanBoardCardData.create( + CardData.create( title = " ", content = "내용", tags = listOf("태그1"), @@ -25,7 +25,7 @@ class KanbanBoardCardDataTest { @Test fun `계정명이 공백만 있으면 생성할 수 없다`() { assertFailsWith { - KanbanBoardCardData.create( + CardData.create( title = "제목", content = "내용", tags = listOf("태그1"), @@ -36,7 +36,7 @@ class KanbanBoardCardDataTest { @Test fun `내용이 있으면 hasContent 리턴 값은 true이다`() { - val cardData = KanbanBoardCardData.create( + val cardData = CardData.create( title = "제목", content = "내용", tags = emptyList(), @@ -48,7 +48,7 @@ class KanbanBoardCardDataTest { @Test fun `내용이 공백이면 hasContent 리턴 값은 false이다`() { - val cardData = KanbanBoardCardData.create( + val cardData = CardData.create( title = "제목", content = " ", tags = emptyList(), @@ -60,7 +60,7 @@ class KanbanBoardCardDataTest { @Test fun `태그의 앞뒤 공백은 제거된다`() { - val cardData = KanbanBoardCardData.create( + val cardData = CardData.create( title = "제목", content = "내용", tags = listOf(" 태그1 ", " 태그2 "), @@ -72,7 +72,7 @@ class KanbanBoardCardDataTest { @Test fun `공백으로만 구성된 태그는 제거된다`() { - val cardData = KanbanBoardCardData.create( + val cardData = CardData.create( title = "제목", content = "내용", tags = listOf("태그1", " ", "", " "), @@ -85,7 +85,7 @@ class KanbanBoardCardDataTest { @Test fun `태그가 5개를 초과하면 생성할 수 없다`() { assertFailsWith { - KanbanBoardCardData.create( + CardData.create( title = "제목", content = "내용", tags = listOf("태그1", "태그2", "태그3", "태그4", "태그5", "태그6"), @@ -97,7 +97,7 @@ class KanbanBoardCardDataTest { @Test fun `태그 내용이 5글자를 초과하면 5글자까지만 유지된다`() { assertFailsWith { - KanbanBoardCardData.create( + CardData.create( title = "제목", content = "내용", tags = listOf("우아한테크코스", "안드로이드8기", "칸반보드리팩터링"), @@ -108,7 +108,7 @@ class KanbanBoardCardDataTest { @Test fun `태그가 있으면 hasTag 리턴 값은 true이다`() { - val cardData = KanbanBoardCardData.create( + val cardData = CardData.create( title = "제목", content = "내용", tags = listOf("태그1", " "), @@ -120,7 +120,7 @@ class KanbanBoardCardDataTest { @Test fun `태그가 비어 있으면 hasTag 리턴 값은 false이다`() { - val cardData = KanbanBoardCardData.create( + val cardData = CardData.create( title = "제목", content = "내용", tags = listOf(" ", ""), diff --git a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardTest.kt b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardTest.kt similarity index 59% rename from composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardTest.kt rename to composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardTest.kt index a1054ee..e50e65c 100644 --- a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/KanbanBoardCardTest.kt +++ b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardTest.kt @@ -7,18 +7,18 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.runComposeUiTest -import woowacourse.kanban.board.domain.KanbanBoardCardData +import woowacourse.kanban.board.domain.CardData import kotlin.test.Test /** - * [KanbanBoardCard] UI 테스트 클래스입니다. + * [Card] UI 테스트 클래스입니다. */ @OptIn(ExperimentalTestApi::class) -class KanbanBoardCardTest { +class CardTest { @Test fun `KanbanBoardCard의 제목, 내용, 태그, 계정명이 입력되면, 해당 필드가 모두 표시된다`() = runComposeUiTest { - val cardData = KanbanBoardCardData.create( + val cardData = CardData.create( title = "제목", content = "내용", tags = listOf("태그1", "태그2", "태그3", "태그4", "태그5"), @@ -26,7 +26,7 @@ class KanbanBoardCardTest { ) setContent { - KanbanBoardCard(kanbanBoardCardData = cardData) + Card(cardData = cardData) } onNodeWithText("제목").assertIsDisplayed() @@ -41,7 +41,7 @@ class KanbanBoardCardTest { @Test fun `제목과 계정명이 화면에 표시된다`() = runComposeUiTest { - val cardData = KanbanBoardCardData.create( + val cardData = CardData.create( title = "제목", content = "", tags = emptyList(), @@ -49,18 +49,18 @@ class KanbanBoardCardTest { ) setContent { - KanbanBoardCard(kanbanBoardCardData = cardData) + Card(cardData = cardData) } - onNodeWithContentDescription("Kanban Card Title").assertIsDisplayed() - onNodeWithContentDescription("Kanban Card Content").assertDoesNotExist() - onAllNodesWithContentDescription("Kanban Card Tag").assertCountEquals(0) - onNodeWithContentDescription("Kanban Card Account Info").assertIsDisplayed() + onNodeWithContentDescription("Card Title").assertIsDisplayed() + onNodeWithContentDescription("Card Content").assertDoesNotExist() + onAllNodesWithContentDescription("Card Tag").assertCountEquals(0) + onNodeWithContentDescription("Card Account Info").assertIsDisplayed() } @Test fun `내용이 없는 경우, 내용 영역이 표시되지 않는다`() = runComposeUiTest { val tags = listOf("태그1", "태그2", "태그3", "태그4") - val cardData = KanbanBoardCardData.create( + val cardData = CardData.create( title = "제목", content = "", tags = tags, @@ -68,17 +68,17 @@ class KanbanBoardCardTest { ) setContent { - KanbanBoardCard(kanbanBoardCardData = cardData) + Card(cardData = cardData) } - onNodeWithContentDescription("Kanban Card Title").assertIsDisplayed() - onNodeWithContentDescription("Kanban Card Content").assertDoesNotExist() - onAllNodesWithContentDescription("Kanban Card Tag").assertCountEquals(tags.size) - onNodeWithContentDescription("Kanban Card Account Info").assertIsDisplayed() + onNodeWithContentDescription("Card Title").assertIsDisplayed() + onNodeWithContentDescription("Card Content").assertDoesNotExist() + onAllNodesWithContentDescription("Card Tag").assertCountEquals(tags.size) + onNodeWithContentDescription("Card Account Info").assertIsDisplayed() } @Test fun `태그가 없는 경우, 태그가 출력되지 않는다`() = runComposeUiTest { - val cardData = KanbanBoardCardData.create( + val cardData = CardData.create( title = "제목", content = "내용", tags = emptyList(), @@ -86,12 +86,12 @@ class KanbanBoardCardTest { ) setContent { - KanbanBoardCard(kanbanBoardCardData = cardData) + Card(cardData = cardData) } - onNodeWithContentDescription("Kanban Card Title").assertIsDisplayed() - onNodeWithContentDescription("Kanban Card Content").assertIsDisplayed() - onAllNodesWithContentDescription("Kanban Card Tag").assertCountEquals(0) - onNodeWithContentDescription("Kanban Card Account Info").assertIsDisplayed() + onNodeWithContentDescription("Card Title").assertIsDisplayed() + onNodeWithContentDescription("Card Content").assertIsDisplayed() + onAllNodesWithContentDescription("Card Tag").assertCountEquals(0) + onNodeWithContentDescription("Card Account Info").assertIsDisplayed() } } \ No newline at end of file From 0c0832160e8b8dcf34ab24fb197802d640acc34c Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 13:45:11 +0900 Subject: [PATCH 04/23] =?UTF-8?q?feat=20:=20CardCreationPanel=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=20=EC=84=B9=EC=85=98=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- .../kanban/board/ui/CardCreationPanel.kt | 111 ++++++++---------- 2 files changed, 49 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index e67494b..bcc3334 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,16 @@ ## 구현할 기능 ### UI +- [x] 헤더 섹션 작성 - [ ] 제목 입력 필드 작성 - [ ] 설명 입력 필드 작성 - [ ] 태그 입력 필드 작성 - [ ] 상태 선택 버튼 작성 - [ ] 담당자 선택 버튼 작성 -- [ ] 생성, 취소, 닫기 버튼 작성 +- [ ] 생성, 취소 버튼 작성 - [ ] KanbanBoard 새 태스크 생성 버튼 작성 - [ ] Card 입력 UI 내 비즈니즈 로직 분리 +- [ ] 디자인 가이드에 따른 용어 변경 ### Domain - [ ] 상태 로직 추가 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt index 0c514f4..04ba3ad 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt @@ -1,13 +1,17 @@ package woowacourse.kanban.board.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -16,80 +20,57 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import woowacourse.kanban.board.domain.CardData @Composable fun CardCreationPanel( onAddItem: (CardData) -> Unit, - onShowInputWindow: (Boolean) -> Unit, + onShowCardCreationPanel: (Boolean) -> Unit, ) { - var title by remember { mutableStateOf("LazyColumn 컴포넌트 구현") } + var taskTitle by remember { mutableStateOf("") } var contents by remember { mutableStateOf("") } - var tempTag by remember { mutableStateOf("") } - var tags by remember { mutableStateOf(listOf()) } - var author by remember { mutableStateOf("다이노") } + var tags by remember { mutableStateOf("") } + var state by remember { mutableStateOf("To Do") } + var manager by remember { mutableStateOf("다이노") } - OutlinedCard(modifier = Modifier.padding(10.dp)) { - Column(modifier = Modifier.padding(10.dp)) { - // 제목 입력 - OutlinedTextField( - value = title, - onValueChange = { title = it }, - label = { Text("제목 입력") }, - modifier = Modifier.fillMaxWidth(), + OutlinedCard { + Column( + modifier = Modifier.background(Color.White).width(672.dp), + ) { + CardCreationPanelHeaderSection( + onShowCardCreationPanel = onShowCardCreationPanel, ) + } - // 내용 입력 - OutlinedTextField( - value = contents, - onValueChange = { contents = it }, - label = { Text("내용 입력") }, - modifier = Modifier.fillMaxWidth(), - ) + } +} - // 태그 입력 - OutlinedTextField( - value = tempTag, - onValueChange = { tempTag = it }, - label = { Text("태그 입력 후 엔터") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { - if (tempTag.isNotBlank() && !tags.contains(tempTag)) { - tags = tags + tempTag.trim() - tempTag = "" - } - }, - ), - ) +@Composable +private fun CardCreationPanelHeaderSection( + onShowCardCreationPanel: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 28.dp, horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "새 태스크 생성", + fontSize = 20.sp, + fontWeight = FontWeight.W600, + lineHeight = 28.sp, + letterSpacing = (-0.45).sp, + ) - /** - * 작성자 입력 - * @param author 작성자 - * @param onAuthorChange 작성자 변경 콜백 - * @param onAddItem 추가 콜백 - */ - OutlinedTextField( - value = author, - onValueChange = { author = it }, - label = { Text("작성자 입력") }, - modifier = Modifier.fillMaxWidth(), - ) + Spacer(modifier = Modifier.weight(1f)) - // 확인 버튼 - Button( - onClick = { - onAddItem(CardData.create(title, contents, tags, author)) - onShowInputWindow(false) - }, - modifier = Modifier.align(Alignment.End), - ) { - Text("확인") - } - } + Icon( + imageVector = Icons.Default.Close, + contentDescription = "닫기 아이콘", + modifier = Modifier.clickable { onShowCardCreationPanel(false) }, + ) } } \ No newline at end of file From da55d2e2b15ac193aaa76a3319cc746a847d69cf Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 13:46:12 +0900 Subject: [PATCH 05/23] =?UTF-8?q?feat=20:=20CardCreationPanel=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EC=9E=85=EB=A0=A5=20=EC=84=B9=EC=85=98=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kanban/board/ui/CardCreationPanel.kt | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bcc3334..863a32f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ ## 구현할 기능 ### UI - [x] 헤더 섹션 작성 -- [ ] 제목 입력 필드 작성 +- [x] 제목 입력 필드 작성 - [ ] 설명 입력 필드 작성 - [ ] 태그 입력 필드 작성 - [ ] 상태 선택 버튼 작성 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt index 04ba3ad..3c6a55b 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt @@ -2,16 +2,20 @@ package woowacourse.kanban.board.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -21,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -44,6 +49,20 @@ fun CardCreationPanel( CardCreationPanelHeaderSection( onShowCardCreationPanel = onShowCardCreationPanel, ) + + HorizontalDivider(modifier = Modifier.fillMaxWidth()) + + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + CardCreationPanelSection( + title = "제목 *", + placeholder = "태스크 제목을 입력하세요", + value = taskTitle, + onTextChange = { taskTitle = it }, + ) + } } } @@ -73,4 +92,65 @@ private fun CardCreationPanelHeaderSection( modifier = Modifier.clickable { onShowCardCreationPanel(false) }, ) } +} + +@Composable +private fun CardCreationPanelSection( + title: String, + modifier: Modifier = Modifier, + placeholder: String = "", + value: String, + onTextChange: (String) -> Unit = {}, + showAdditionalInfo: Boolean = false, + infoText: String = "5자 이내의 태그를 최대 5개까지 등록할 수 있습니다.", +) { + Column( + modifier = modifier, + ) { + TitleText(title) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = value, + onValueChange = { onTextChange(it) }, + placeholder = { + Text( + text = placeholder, + color = Color(0xFFAAAAAA), + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 1.sp, + ) + }, + textStyle = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 1.sp, + ), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) + if (showAdditionalInfo) { + Text( + text = infoText, + color = Color(0xFF49454F), + fontSize = 12.sp, + fontWeight = FontWeight.W400, + lineHeight = 16.sp, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + +@Composable +private fun TitleText( + title: String, +) { + Text( + text = title, + fontSize = 14.sp, + color = Color(0xFF364153), + fontWeight = FontWeight.Medium, + lineHeight = 20.sp, + letterSpacing = 0.15.sp, + ) } \ No newline at end of file From a98bcbc1825fe49a7387da8ae31525e8347fc17e Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 13:48:12 +0900 Subject: [PATCH 06/23] =?UTF-8?q?feat=20:=20CardCreationPanel=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EC=9E=85=EB=A0=A5=20=EC=84=B9=EC=85=98=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../woowacourse/kanban/board/ui/CardCreationPanel.kt | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 863a32f..d0ae65b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ ### UI - [x] 헤더 섹션 작성 - [x] 제목 입력 필드 작성 -- [ ] 설명 입력 필드 작성 +- [x] 설명 입력 필드 작성 - [ ] 태그 입력 필드 작성 - [ ] 상태 선택 버튼 작성 - [ ] 담당자 선택 버튼 작성 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt index 3c6a55b..8254537 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt @@ -62,6 +62,13 @@ fun CardCreationPanel( value = taskTitle, onTextChange = { taskTitle = it }, ) + + CardCreationPanelSection( + title = "설명", + placeholder = "태스크에 대한 자세한 설명을 입력하세요", + value = contents, + onTextChange = { contents = it }, + ) } } From b30ae690507cb85a4f710d5e9c4c310503024e39 Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 13:49:24 +0900 Subject: [PATCH 07/23] =?UTF-8?q?feat=20:=20CardCreationPanel=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=9E=85=EB=A0=A5=20=EC=84=B9=EC=85=98=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kanban/board/ui/CardCreationPanel.kt | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d0ae65b..eee94be 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ - [x] 헤더 섹션 작성 - [x] 제목 입력 필드 작성 - [x] 설명 입력 필드 작성 -- [ ] 태그 입력 필드 작성 +- [x] 태그 입력 필드 작성 - [ ] 상태 선택 버튼 작성 - [ ] 담당자 선택 버튼 작성 - [ ] 생성, 취소 버튼 작성 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt index 8254537..2fd7437 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt @@ -1,5 +1,6 @@ package woowacourse.kanban.board.ui +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -9,11 +10,17 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -69,6 +76,14 @@ fun CardCreationPanel( value = contents, onTextChange = { contents = it }, ) + + CardCreationPanelSection( + title = "태그", + placeholder = "태그를 쉼표로 구분하여 입력하세요 (예: 버그, 긴급)", + value = tags, + onTextChange = { tags = it }, + showAdditionalInfo = true, + ) } } From 476f27e3f4d346cac396d12254f80d4437adf64a Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 13:50:12 +0900 Subject: [PATCH 08/23] =?UTF-8?q?feat=20:=20CardCreationPanel=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=84=A0=ED=83=9D=20=EB=B2=84=ED=8A=BC=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kanban/board/ui/CardCreationPanel.kt | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eee94be..2e72362 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - [x] 제목 입력 필드 작성 - [x] 설명 입력 필드 작성 - [x] 태그 입력 필드 작성 -- [ ] 상태 선택 버튼 작성 +- [x] 상태 선택 버튼 작성 - [ ] 담당자 선택 버튼 작성 - [ ] 생성, 취소 버튼 작성 - [ ] KanbanBoard 새 태스크 생성 버튼 작성 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt index 2fd7437..ece1f05 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt @@ -84,6 +84,11 @@ fun CardCreationPanel( onTextChange = { tags = it }, showAdditionalInfo = true, ) + + CardCreationPanelStateSection( + selectedState = state, + onStateChange = { state = it }, + ) } } @@ -163,6 +168,75 @@ private fun CardCreationPanelSection( } } + +@Composable +private fun CardCreationPanelStateSection( + selectedState: String, + onStateChange: (String) -> Unit, +) { + Column() { + TitleText("상태 *") + Spacer(modifier = Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + StateButton( + text = "To Do", + isSelected = selectedState == "To Do", + onClick = { onStateChange("To Do") }, + modifier = Modifier.width(200.dp).height(52.dp), + + ) + StateButton( + text = "In Progress", + isSelected = selectedState == "In Progress", + onClick = { onStateChange("In Progress") }, + modifier = Modifier.width(200.dp).height(52.dp), + + ) + StateButton( + text = "Done", + isSelected = selectedState == "Done", + onClick = { onStateChange("Done") }, + modifier = Modifier.width(200.dp).height(52.dp), + + ) + } + } +} + +@Composable +private fun StateButton( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val containerColor = if (isSelected) Color(0xFFE5E7EB) else Color.White + val contentColor = if (isSelected) Color(0xFF1447E6) else Color.Black + val borderColor = if (isSelected) Color(0xFF1447E6) else Color(0xFFE5E7EB) + + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(20), + border = BorderStroke(1.dp, borderColor), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = containerColor, + contentColor = contentColor, + ), + modifier = modifier, + ) { + Text( + text = text, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + letterSpacing = (-0.3).sp, + lineHeight = 24.sp, + ) + } +} + @Composable private fun TitleText( title: String, From 131599d0404a9e262f31730b9ac5a60eef1099b2 Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 13:52:08 +0900 Subject: [PATCH 09/23] =?UTF-8?q?feat=20:=20CardCreationPanel=20=EB=8B=B4?= =?UTF-8?q?=EB=8B=B9=EC=9E=90=20=EC=84=A0=ED=83=9D=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kanban/board/ui/CardCreationPanel.kt | 76 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2e72362..67def4a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ - [x] 설명 입력 필드 작성 - [x] 태그 입력 필드 작성 - [x] 상태 선택 버튼 작성 -- [ ] 담당자 선택 버튼 작성 +- [x] 담당자 선택 버튼 작성 - [ ] 생성, 취소 버튼 작성 - [ ] KanbanBoard 새 태스크 생성 버튼 작성 - [ ] Card 입력 UI 내 비즈니즈 로직 분리 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt index ece1f05..c424401 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt @@ -89,6 +89,11 @@ fun CardCreationPanel( selectedState = state, onStateChange = { state = it }, ) + + CardCreationPanelManagerSection( + selectedManager = manager, + onManagerChange = { manager = it }, + ) } } @@ -237,6 +242,77 @@ private fun StateButton( } } +@Composable +private fun CardCreationPanelManagerSection( + selectedManager: String, + onManagerChange: (String) -> Unit, +) { + Column() { + TitleText("담당자 *") + Spacer(modifier = Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ManagerButton( + text = "다이노", + isSelected = selectedManager == "다이노", + onClick = { onManagerChange("다이노") }, + modifier = Modifier.width(200.dp).height(68.dp), + ) + ManagerButton( + text = "페임스", + isSelected = selectedManager == "페임스", + onClick = { onManagerChange("페임스") }, + modifier = Modifier.width(200.dp).height(68.dp), + ) + } + } +} + +@Composable +private fun ManagerButton( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val containerColor = if (isSelected) Color(0xFFE5E7EB) else Color.White + val contentColor = if (isSelected) Color(0xFF1447E6) else Color.Black + val borderColor = if (isSelected) Color(0xFF1447E6) else Color(0xFFE5E7EB) + + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(20), + border = BorderStroke(1.dp, borderColor), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = containerColor, + contentColor = contentColor, + ), + modifier = modifier, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.AccountCircle, + contentDescription = "매니저 아이콘", + modifier = Modifier.size(24.dp), + tint = Color(0xFF838383), + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = text, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + letterSpacing = (-0.15).sp, + lineHeight = 20.sp, + ) + } + } +} + @Composable private fun TitleText( title: String, From d4a57000413e50e00fadf5d6fa1edeac2f14832a Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 13:53:27 +0900 Subject: [PATCH 10/23] =?UTF-8?q?feat=20:=20CardCreationPanel=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=20=EC=B7=A8=EC=86=8C=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- .../kanban/board/ui/CardCreationPanel.kt | 72 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 67def4a..ec12bfe 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,10 @@ - [x] 태그 입력 필드 작성 - [x] 상태 선택 버튼 작성 - [x] 담당자 선택 버튼 작성 -- [ ] 생성, 취소 버튼 작성 +- [x] 생성, 취소 버튼 작성 - [ ] KanbanBoard 새 태스크 생성 버튼 작성 - [ ] Card 입력 UI 내 비즈니즈 로직 분리 -- [ ] 디자인 가이드에 따른 용어 변경 +- [x] 디자인 가이드에 따른 용어 변경 ### Domain - [ ] 상태 로직 추가 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt index c424401..eefeaa1 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt @@ -94,6 +94,17 @@ fun CardCreationPanel( selectedManager = manager, onManagerChange = { manager = it }, ) + + HorizontalDivider(modifier = Modifier.fillMaxWidth()) + + ActionButtonSection( + onClick = { + onShowCardCreationPanel(false) + }, + onCreate = { + onAddItem(CardData.create(taskTitle, contents, tags.split(","), manager)) + } + ) } } @@ -313,6 +324,67 @@ private fun ManagerButton( } } +@Composable +private fun ActionButtonSection( + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onCreate: () -> Unit = {}, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + ActionButton( + buttonText = "취소", + onClick = { + onClick() + }, + ) + Spacer(modifier = Modifier.width(12.dp)) + ActionButton( + buttonText = "생성", + onClick = { + onClick() + onCreate() + }, + ) + } +} + +@Composable +private fun ActionButton( + buttonText: String, + onClick: () -> Unit = {}, +) { + val contentColor = if (buttonText == "생성") Color.White else Color(0xFF364153) + val buttonColor = if (buttonText == "생성") Color(0xFF4F39F6) else Color.White + val elevation = if (buttonText == "생성") 3.dp else 0.dp + + Button( + onClick = { onClick() }, + modifier = Modifier, + elevation = ButtonDefaults.buttonElevation( + defaultElevation = elevation, + pressedElevation = elevation, + disabledElevation = elevation, + ), + colors = ButtonDefaults.buttonColors( + containerColor = buttonColor, + contentColor = contentColor, + ), + shape = RoundedCornerShape(20), + ) { + Text( + text = buttonText, + color = contentColor, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + letterSpacing = (-0.3).sp, + lineHeight = 24.sp, + ) + } +} @Composable private fun TitleText( title: String, From 740fb54e66d09ee2ca3d02690867feaa9516f9db Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 17:22:51 +0900 Subject: [PATCH 11/23] =?UTF-8?q?docs=20:=20=ED=94=BC=EA=B7=B8=EB=A7=88=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EA=B0=80=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20UI/Domain=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=EC=82=AC=ED=95=AD=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ec12bfe..f123685 100644 --- a/README.md +++ b/README.md @@ -32,18 +32,16 @@ - [ ] KanbanBoard 새 태스크 생성 버튼 작성 - [ ] Card 입력 UI 내 비즈니즈 로직 분리 - [x] 디자인 가이드에 따른 용어 변경 +- [ ] 제목 유효성 검증 결과에 따른 시각적 표현 추가 +- [ ] 태그 유효성 검증 결과에 따른 시각적 표현 추가 +- [ ] 테스크 유효성 검증 결과에 따른 버튼 활성화 로직 추가 ### Domain - [ ] 상태 로직 추가 -- [ ] 디자인 가이드에 따른 용어 변경 - -## 테스트 -### Unit 테스트 -- [ ] 상태 관련 테스트 로직 추가 - -### UI 테스트 -- [ ] 상태 버튼 클릭 시 선택 효과 표시 여부 확인 -- [ ] 담당자 버튼 클릭 시 선택 효과 표시 여부 확인 +- [ ] 제목 유효성 로직 작성 +- [ ] 태그 파싱 로직 작성 +- [ ] 정규화 태그 유효성 검증 로직 작성 +- [ ] 태그 검증 결과에 따른 에러 메시지 반환 로직 작성 ## 리팩토링 ### 1단계 From dae59571e12232d7238daf81e49c98fe90070bd8 Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 17:23:38 +0900 Subject: [PATCH 12/23] =?UTF-8?q?feat=20:=20=EC=83=81=ED=83=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kotlin/woowacourse/kanban/board/domain/CardData.kt | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f123685..926a932 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ - [ ] 테스크 유효성 검증 결과에 따른 버튼 활성화 로직 추가 ### Domain -- [ ] 상태 로직 추가 +- [x] 상태 로직 추가 - [ ] 제목 유효성 로직 작성 - [ ] 태그 파싱 로직 작성 - [ ] 정규화 태그 유효성 검증 로직 작성 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt index c46dacb..f6d7bac 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt @@ -9,12 +9,17 @@ class CardData private constructor( val title: String, val content: String, val tags: List, - val accountName: String, + val manager: String, ) { companion object { private const val MAX_TAG_COUNT = 5 private const val MAX_TAG_LENGTH = 5 + private const val TITLE_INVALID_FORMAT_MSG = "제목을 입력해 주세요." + private const val TAG_VALID_FORMAT_MSG = "5자 이내의 태그를 최대 5개까지 등록할 수 있습니다." + private const val TAG_INVALID_FORMAT_MSG = "태그 형식이 올바르지 않습니다." + private const val TAG_INVALID_RULE_MSG = "태그는 5자 이내로 5개까지만 등록할 수 있습니다." + /** * [CardData] 객체 생성 팩토리 메서드입니다. * @param title 필수 | 제목 @@ -43,7 +48,7 @@ class CardData private constructor( title = title, content = content, tags = normalizedTags, - accountName = accountName, + manager = accountName, ) } } @@ -53,6 +58,7 @@ class CardData private constructor( * @return 내용이 공백이 아니면 true 리턴. */ fun hasContent(): Boolean = content.isNotBlank() + /** * 태그 존재 여부를 리턴합니다 * @return 태그가 있다면 true 리턴. From ece2e2fbb4b08dcbd606a8eaf73270d45baaa02f Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 17:24:53 +0900 Subject: [PATCH 13/23] =?UTF-8?q?feat=20:=20=EC=A0=9C=EB=AA=A9=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kotlin/woowacourse/kanban/board/domain/CardData.kt | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 926a932..565b1bd 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ ### Domain - [x] 상태 로직 추가 -- [ ] 제목 유효성 로직 작성 +- [x] 제목 유효성 로직 작성 - [ ] 태그 파싱 로직 작성 - [ ] 정규화 태그 유효성 검증 로직 작성 - [ ] 태그 검증 결과에 따른 에러 메시지 반환 로직 작성 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt index f6d7bac..a6cf0a5 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt @@ -20,6 +20,14 @@ class CardData private constructor( private const val TAG_INVALID_FORMAT_MSG = "태그 형식이 올바르지 않습니다." private const val TAG_INVALID_RULE_MSG = "태그는 5자 이내로 5개까지만 등록할 수 있습니다." + fun isValidText(rawText: String): Boolean { + return rawText.trim().isNotBlank() + } + + fun getTitleInfo(): String { + return TITLE_INVALID_FORMAT_MSG + } + /** * [CardData] 객체 생성 팩토리 메서드입니다. * @param title 필수 | 제목 From 1621414e08982384e67ad2b23830796814f38e21 Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 17:25:28 +0900 Subject: [PATCH 14/23] =?UTF-8?q?feat=20:=20=ED=83=9C=EA=B7=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kotlin/woowacourse/kanban/board/domain/CardData.kt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 565b1bd..087889d 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ ### Domain - [x] 상태 로직 추가 - [x] 제목 유효성 로직 작성 -- [ ] 태그 파싱 로직 작성 +- [x] 태그 파싱 로직 작성 - [ ] 정규화 태그 유효성 검증 로직 작성 - [ ] 태그 검증 결과에 따른 에러 메시지 반환 로직 작성 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt index a6cf0a5..b5ef2c5 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt @@ -28,6 +28,10 @@ class CardData private constructor( return TITLE_INVALID_FORMAT_MSG } + fun parseTag(tempTags: String): List { + return tempTags.trim().split(",") + } + /** * [CardData] 객체 생성 팩토리 메서드입니다. * @param title 필수 | 제목 From d7a87a2a525041bdd7f173fb92f3ad7548a3161d Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 17:25:57 +0900 Subject: [PATCH 15/23] =?UTF-8?q?feat=20:=20=EC=A0=95=EA=B7=9C=ED=99=94=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kotlin/woowacourse/kanban/board/domain/CardData.kt | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 087889d..2921488 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ - [x] 상태 로직 추가 - [x] 제목 유효성 로직 작성 - [x] 태그 파싱 로직 작성 -- [ ] 정규화 태그 유효성 검증 로직 작성 +- [x] 정규화 태그 유효성 검증 로직 작성 - [ ] 태그 검증 결과에 따른 에러 메시지 반환 로직 작성 ## 리팩토링 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt index b5ef2c5..1c80396 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt @@ -32,6 +32,13 @@ class CardData private constructor( return tempTags.trim().split(",") } + fun isValidTag(rawText: String): Boolean { + if (rawText.isBlank()) return true + + val parsedText = parseTag(rawText) + return (parsedText.all { isValidText(it) } && parsedText.size <= MAX_TAG_COUNT) + } + /** * [CardData] 객체 생성 팩토리 메서드입니다. * @param title 필수 | 제목 From 42126215f4e7bd759be61a7a4f0710a478fbafd8 Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 17:26:27 +0900 Subject: [PATCH 16/23] =?UTF-8?q?feat=20:=20=ED=83=9C=EA=B7=B8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B2=B0=EA=B3=BC=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kotlin/woowacourse/kanban/board/domain/CardData.kt | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2921488..1b46ad7 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ - [x] 제목 유효성 로직 작성 - [x] 태그 파싱 로직 작성 - [x] 정규화 태그 유효성 검증 로직 작성 -- [ ] 태그 검증 결과에 따른 에러 메시지 반환 로직 작성 +- [x] 태그 검증 결과에 따른 에러 메시지 반환 로직 작성 ## 리팩토링 ### 1단계 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt index 1c80396..f31762d 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt @@ -39,6 +39,16 @@ class CardData private constructor( return (parsedText.all { isValidText(it) } && parsedText.size <= MAX_TAG_COUNT) } + fun isValidTagInfo(rawText: String): String { + val parsedText = parseTag(rawText) + + if (isValidText(rawText) && parsedText.any { isValidText(it) == false }) return TAG_INVALID_FORMAT_MSG + + if (isValidText(rawText) && parsedText.size > MAX_TAG_COUNT) return TAG_INVALID_RULE_MSG + + return TAG_VALID_FORMAT_MSG + } + /** * [CardData] 객체 생성 팩토리 메서드입니다. * @param title 필수 | 제목 From e566d9fdbb02aaa1f644d66869fa9822fe984a4f Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 17:28:43 +0900 Subject: [PATCH 17/23] =?UTF-8?q?feat=20:=20KanbanBoard=20=EC=83=88=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=81=AC=20=EC=9E=91=EC=84=B1=20UI=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=20-=20Board=20=EC=9E=84=EC=8B=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=20-=20Board=20=EB=82=B4=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EA=B5=AC=ED=98=84=20-=20Progress=20Bar=20?= =?UTF-8?q?=EB=AA=A9=EC=97=85=20=EA=B5=AC=ED=98=84=20-=20Contents=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=EC=9A=A9=20Contents=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kotlin/woowacourse/kanban/board/App.kt | 19 +-- .../woowacourse/kanban/board/ui/Board.kt | 153 ++++++++++++++++++ 3 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Board.kt diff --git a/README.md b/README.md index 1b46ad7..66babde 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ - [x] 상태 선택 버튼 작성 - [x] 담당자 선택 버튼 작성 - [x] 생성, 취소 버튼 작성 -- [ ] KanbanBoard 새 태스크 생성 버튼 작성 +- [x] KanbanBoard 새 태스크 생성 버튼 작성 - [ ] Card 입력 UI 내 비즈니즈 로직 분리 - [x] 디자인 가이드에 따른 용어 변경 - [ ] 제목 유효성 검증 결과에 따른 시각적 표현 추가 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt index 22e9b1b..29a5d67 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt @@ -10,27 +10,16 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Devices.DESKTOP import androidx.compose.ui.tooling.preview.Preview import woowacourse.kanban.board.domain.CardData +import woowacourse.kanban.board.ui.Board import woowacourse.kanban.board.ui.CardCreationPanel @Composable -@Preview +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, device = DESKTOP) fun App() { MaterialTheme { - val kanbanBoardCards = remember { mutableStateListOf() } - var showInputWindow by remember { mutableStateOf(false) } - - Box( - modifier = Modifier.fillMaxSize(), - ) { - - if (showInputWindow) { - CardCreationPanel( - onAddItem = { kanbanBoardCards.add(it) }, - onShowInputWindow = { showInputWindow = it }, - ) - } - } + Board() } } diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Board.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Board.kt new file mode 100644 index 0000000..374267e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Board.kt @@ -0,0 +1,153 @@ +package woowacourse.kanban.board.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import woowacourse.kanban.board.domain.CardData + +@Composable +fun Board() { + val cards = remember { mutableStateListOf() } + var showCardCreationPanel by remember { mutableStateOf(false) } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp), + ) { + BoardHeaderSection( + modifier = Modifier.fillMaxWidth(), + onClick = { + showCardCreationPanel = true + }, + ) + BoardContents( + modifier = Modifier.fillMaxSize(), + cards = cards + ) + } + + if (showCardCreationPanel) { + CardCreationPanel( + modifier = Modifier.align(Alignment.Center), + onAddItem = { cards.add(it) }, + onShowCardCreationPanel = { showCardCreationPanel = it }, + ) + } + } +} + +@Composable +private fun BoardHeaderSection( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Compose Desktop 칸반 보드 ", + fontWeight = FontWeight.W500, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.07.sp, + ) + Text( + text = "완료율: 0% (0/0)", + fontWeight = FontWeight.W400, + color = Color(0xFF6A7282), + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = (-0.15).sp, + ) + } + + Button( + onClick = { onClick() }, + modifier = Modifier, + shape = RoundedCornerShape(20), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "새 태스크 생성 아이콘", + tint = Color.White, + ) + Text( + text = "새 태스크 생성", + color = Color.White, + fontWeight = FontWeight.W400, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = (-0.31).sp, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + LinearProgressIndicator( + progress = { 0.5f }, + modifier = Modifier.fillMaxWidth().height(8.dp), + color = ProgressIndicatorDefaults.linearColor, + trackColor = ProgressIndicatorDefaults.linearTrackColor, + strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, + ) + } +} + +/** + * 테스트 용 보드 컨텐츠 간단 출력 모듈입니다. (추후 단계에서 구현 예정) + */ +@Composable +private fun BoardContents( + modifier: Modifier = Modifier, + cards: List +) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(cards.size) {item -> + Card(cardData = cards[item]) + } + } +} \ No newline at end of file From eaa5e6a8dad5cc6b841a7a2def3cc311ef6192b7 Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 17:29:48 +0900 Subject: [PATCH 18/23] =?UTF-8?q?fix=20:=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=9A=A9=EC=96=B4=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/commonMain/kotlin/woowacourse/kanban/board/ui/Card.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Card.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Card.kt index 2187261..7ee90cf 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Card.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Card.kt @@ -69,7 +69,7 @@ fun Card( HorizontalDivider() CardAccountInfo( - accountName = cardData.accountName, + accountName = cardData.manager, modifier = Modifier .padding(vertical = 10.dp) .fillMaxWidth() From 41598e4f0581c4f011f9cfcc43bb81da5c1e51cc Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 17:32:33 +0900 Subject: [PATCH 19/23] =?UTF-8?q?feat=20:=20=EC=A0=9C=EB=AA=A9=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20UI=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- .../kanban/board/ui/CardCreationPanel.kt | 53 +++++++++++++------ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 66babde..d64636d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ - [x] KanbanBoard 새 태스크 생성 버튼 작성 - [ ] Card 입력 UI 내 비즈니즈 로직 분리 - [x] 디자인 가이드에 따른 용어 변경 -- [ ] 제목 유효성 검증 결과에 따른 시각적 표현 추가 +- [x] 제목 유효성 검증 결과에 따른 시각적 표현 추가 - [ ] 태그 유효성 검증 결과에 따른 시각적 표현 추가 - [ ] 테스크 유효성 검증 결과에 따른 버튼 활성화 로직 추가 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt index eefeaa1..189b486 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Error import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider @@ -25,6 +26,7 @@ import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -40,16 +42,21 @@ import woowacourse.kanban.board.domain.CardData @Composable fun CardCreationPanel( + modifier: Modifier = Modifier, onAddItem: (CardData) -> Unit, onShowCardCreationPanel: (Boolean) -> Unit, ) { var taskTitle by remember { mutableStateOf("") } var contents by remember { mutableStateOf("") } - var tags by remember { mutableStateOf("") } + var tempTags by remember { mutableStateOf("") } + val tags = CardData.parseTag(tempTags) var state by remember { mutableStateOf("To Do") } var manager by remember { mutableStateOf("다이노") } - OutlinedCard { + + OutlinedCard( + modifier = modifier, + ) { Column( modifier = Modifier.background(Color.White).width(672.dp), ) { @@ -67,7 +74,12 @@ fun CardCreationPanel( title = "제목 *", placeholder = "태스크 제목을 입력하세요", value = taskTitle, - onTextChange = { taskTitle = it }, + onTextChange = { + taskTitle = it + }, + showAdditionalInfo = !CardData.isValidText(taskTitle), + infoText = CardData.getTitleInfo(), + isError = !CardData.isValidText(taskTitle), ) CardCreationPanelSection( @@ -80,9 +92,12 @@ fun CardCreationPanel( CardCreationPanelSection( title = "태그", placeholder = "태그를 쉼표로 구분하여 입력하세요 (예: 버그, 긴급)", - value = tags, - onTextChange = { tags = it }, + value = tempTags, + onTextChange = { + tempTags = it + }, showAdditionalInfo = true, + isError = !CardData.isValidTag(tempTags), ) CardCreationPanelStateSection( @@ -98,16 +113,20 @@ fun CardCreationPanel( HorizontalDivider(modifier = Modifier.fillMaxWidth()) ActionButtonSection( - onClick = { - onShowCardCreationPanel(false) - }, + onClick = { onShowCardCreationPanel(false) }, onCreate = { - onAddItem(CardData.create(taskTitle, contents, tags.split(","), manager)) - } + onAddItem( + CardData.create( + taskTitle, + contents, + tags, + manager, + ), + ) + }, ) } } - } } @@ -145,8 +164,10 @@ private fun CardCreationPanelSection( value: String, onTextChange: (String) -> Unit = {}, showAdditionalInfo: Boolean = false, - infoText: String = "5자 이내의 태그를 최대 5개까지 등록할 수 있습니다.", + infoText: String = "", + isError: Boolean = false, ) { + val errorColor = Color(0xFFB3261E) Column( modifier = modifier, ) { @@ -165,6 +186,7 @@ private fun CardCreationPanelSection( ) }, textStyle = TextStyle( + color = if (isError) errorColor else Color.Black, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 1.sp, @@ -174,7 +196,7 @@ private fun CardCreationPanelSection( if (showAdditionalInfo) { Text( text = infoText, - color = Color(0xFF49454F), + color = if (isError) errorColor else Color(0xFF49454F), fontSize = 12.sp, fontWeight = FontWeight.W400, lineHeight = 16.sp, @@ -337,9 +359,7 @@ private fun ActionButtonSection( ) { ActionButton( buttonText = "취소", - onClick = { - onClick() - }, + onClick = { onClick() }, ) Spacer(modifier = Modifier.width(12.dp)) ActionButton( @@ -385,6 +405,7 @@ private fun ActionButton( ) } } + @Composable private fun TitleText( title: String, From ffbffa0fc0cdb97b663160d757f068391fee4a1e Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 17:35:53 +0900 Subject: [PATCH 20/23] =?UTF-8?q?feat=20:=20=ED=83=9C=EA=B7=B8=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EC=9C=A0=ED=9A=A8=EA=B2=85=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=97=90=20=EB=94=B0=EB=A5=B8=20UI=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1=20-=20?= =?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EC=9E=85=EB=A0=A5=EC=9D=B4=20=EC=98=AC?= =?UTF-8?q?=EB=B0=94=EB=A5=B4=EC=A7=80=20=EC=95=8A=EC=9D=84=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EA=B2=BD=EA=B3=A0=20=EB=AC=B8=EA=B5=AC=20/=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=B6=9C=EB=A0=A5=20-=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=81=AC=20=ED=8F=BC=20=EC=9C=A0=ED=9A=A8?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=A6=9D=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B2=84=ED=8A=BC=20=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 ++++++------- .../kanban/board/ui/CardCreationPanel.kt | 23 ++++++++++++++++++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d64636d..9403660 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ ## 요구 사항 ### 기능 요구 사항 -- [ ] 필수 입력 란과 선택 입력 란을 구분한다. - - [ ] 필수 입력: 제목, 상태, 담당자 - - [ ] 선택 입력: 설명, 태그 -- [ ] 상태와 담당자는 첫 번째 항목으로 기본 선택되어 있고, 한 항목만 선택 가능하다. -- [ ] 유효성 검사가 실패하면 생성 버튼을 누를 수 없다. +- [x] 필수 입력 란과 선택 입력 란을 구분한다. + - [x] 필수 입력: 제목, 상태, 담당자 + - [x] 선택 입력: 설명, 태그 +- [x] 상태와 담당자는 첫 번째 항목으로 기본 선택되어 있고, 한 항목만 선택 가능하다. +- [x] 유효성 검사가 실패하면 생성 버튼을 누를 수 없다. - [x] 기존 코드를 마이그레이션한 뒤 하나의 커밋으로 합친다. - [x] 페어와 협의에 따른 카드 명칭 변경 @@ -30,11 +30,11 @@ - [x] 담당자 선택 버튼 작성 - [x] 생성, 취소 버튼 작성 - [x] KanbanBoard 새 태스크 생성 버튼 작성 -- [ ] Card 입력 UI 내 비즈니즈 로직 분리 +- [x] Card 입력 UI 내 비즈니즈 로직 분리 - [x] 디자인 가이드에 따른 용어 변경 - [x] 제목 유효성 검증 결과에 따른 시각적 표현 추가 -- [ ] 태그 유효성 검증 결과에 따른 시각적 표현 추가 -- [ ] 테스크 유효성 검증 결과에 따른 버튼 활성화 로직 추가 +- [x] 태그 유효성 검증 결과에 따른 시각적 표현 추가 +- [x] 테스크 유효성 검증 결과에 따른 버튼 활성화 로직 추가 ### Domain - [x] 상태 로직 추가 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt index 189b486..d01b4c2 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt @@ -52,7 +52,12 @@ fun CardCreationPanel( val tags = CardData.parseTag(tempTags) var state by remember { mutableStateOf("To Do") } var manager by remember { mutableStateOf("다이노") } - + var tagInfoText by remember { mutableStateOf("5자 이내의 태그를 최대 5개까지 등록할 수 있습니다.") } + val createEnabled by remember { + derivedStateOf { + CardData.isValidText(taskTitle) && CardData.isValidTag(tempTags) + } + } OutlinedCard( modifier = modifier, @@ -95,8 +100,10 @@ fun CardCreationPanel( value = tempTags, onTextChange = { tempTags = it + tagInfoText = CardData.isValidTagInfo(tempTags) }, showAdditionalInfo = true, + infoText = tagInfoText, isError = !CardData.isValidTag(tempTags), ) @@ -113,6 +120,7 @@ fun CardCreationPanel( HorizontalDivider(modifier = Modifier.fillMaxWidth()) ActionButtonSection( + createEnabled = createEnabled, onClick = { onShowCardCreationPanel(false) }, onCreate = { onAddItem( @@ -176,6 +184,14 @@ private fun CardCreationPanelSection( OutlinedTextField( value = value, onValueChange = { onTextChange(it) }, + trailingIcon = { + if (isError) Icon( + imageVector = Icons.Default.Error, + contentDescription = "에러 아이콘", + tint = errorColor, + ) + }, + isError = isError, placeholder = { Text( text = placeholder, @@ -348,6 +364,7 @@ private fun ManagerButton( @Composable private fun ActionButtonSection( + createEnabled: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit = {}, onCreate: () -> Unit = {}, @@ -359,11 +376,13 @@ private fun ActionButtonSection( ) { ActionButton( buttonText = "취소", + enabled = true, onClick = { onClick() }, ) Spacer(modifier = Modifier.width(12.dp)) ActionButton( buttonText = "생성", + enabled = createEnabled, onClick = { onClick() onCreate() @@ -375,6 +394,7 @@ private fun ActionButtonSection( @Composable private fun ActionButton( buttonText: String, + enabled: Boolean, onClick: () -> Unit = {}, ) { val contentColor = if (buttonText == "생성") Color.White else Color(0xFF364153) @@ -383,6 +403,7 @@ private fun ActionButton( Button( onClick = { onClick() }, + enabled = enabled, modifier = Modifier, elevation = ButtonDefaults.buttonElevation( defaultElevation = elevation, From 1b0b97620e6fdf9a1fed2ebfb3ef5ed5c88a6252 Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 22:29:52 +0900 Subject: [PATCH 21/23] =?UTF-8?q?feat=20:=20UI=20=EB=B0=8F=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kanban/board/domain/CardDataTest.kt | 27 ++++ .../kanban/board/ui/CardCreationPanelTest.kt | 116 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardCreationPanelTest.kt diff --git a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/CardDataTest.kt b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/CardDataTest.kt index 8bbe43a..cf69816 100644 --- a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/CardDataTest.kt +++ b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/CardDataTest.kt @@ -129,4 +129,31 @@ class CardDataTest { assertFalse(cardData.hasTag()) } + + @Test + fun `잘못된 태그 문자열이 주어질 시 false가 반환된다`() { + assertFalse(CardData.isValidTag(",...")) + } + + @Test + fun `잘못된 태그 문자열이 주어질 시 에러메시지가 반환된다`() { + assertEquals("태그 형식이 올바르지 않습니다.", CardData.isValidTagInfo(",...")) + assertEquals("태그는 5자 이내로 5개까지만 등록할 수 있습니다.", CardData.isValidTagInfo("태그1,태그2,태그3,태그4,태그5,태그6")) + } + + @Test + fun `쉼표를 기준으로 태그 문자열을 분리한다`() { + assertEquals( + listOf("태그1", "태그2", "태그3"), + CardData.parseTag("태그1,태그2,태그3"), + ) + } + + @Test + fun `태그 문자열의 앞뒤 공백을 제거한 후 쉼표를 기준으로 분리한다`() { + assertEquals( + listOf("태그1", "태그2"), + CardData.parseTag("태그1,태그2 "), + ) + } } \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardCreationPanelTest.kt b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardCreationPanelTest.kt new file mode 100644 index 0000000..681cca9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardCreationPanelTest.kt @@ -0,0 +1,116 @@ +package woowacourse.kanban.board.ui + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runComposeUiTest +import org.junit.Test +import woowacourse.kanban.board.domain.CardData + +@OptIn(ExperimentalTestApi::class) +class CardCreationPanelTest { + @Test + fun `초기 진입 시 기본 UI 상태가 올바르게 표시된다`() = runComposeUiTest { + //when + setContent { + CardCreationPanel( + onAddItem = {}, + onShowCardCreationPanel = {}, + ) + } + + onNodeWithText("새 태스크 생성").assertExists() + onNodeWithText("제목을 입력해 주세요.").assertExists() + onNodeWithText("제목 *").assertExists() + onNodeWithText("설명").assertExists() + onNodeWithText("태그").assertExists() + onNodeWithText("상태 *").assertExists() + onNodeWithText("담당자 *").assertExists() + onNodeWithText("생성").assertIsNotEnabled() + } + + @Test + fun `제목을 입력하지 않으면, 생성 버튼이 비활성화된다`() = runComposeUiTest { + // given + val blankTitle = "" + + //when + setContent { + CardCreationPanel( + onAddItem = { CardData.create(blankTitle, "", listOf(("")), "구름") }, + onShowCardCreationPanel = {}, + ) + } + + //then + onNodeWithText("생성").assertIsNotEnabled() + } + + @Test + fun `제목을 입력하지 않으면 에러메시지가 노출된다`() = runComposeUiTest { + // given + val blankTitle = "" + + //when + setContent { + CardCreationPanel( + onAddItem = { CardData.create(blankTitle, "", listOf(("")), "구름") }, + onShowCardCreationPanel = {}, + ) + } + + //then + onNodeWithText("제목을 입력해 주세요.").assertExists() + } + + @Test + fun `제목을 입력하면 제목 에러메시지가 사라진다`() = runComposeUiTest { + //when + setContent { + CardCreationPanel( + onAddItem = {}, + onShowCardCreationPanel = {}, + ) + } + + //then + onNodeWithText("태스크 제목을 입력하세요").performTextInput("제목입니다~") + + onNodeWithText("제목을 입력해 주세요.").assertDoesNotExist() + } + + @Test + fun `태그를 잘못 입력하면, 생성 버튼이 비활성화된다`() = runComposeUiTest { + // given + val wrongTag = ",태그" + + //when + setContent { + CardCreationPanel( + onAddItem = { CardData.create("제목", "", wrongTag.split(","), "구름") }, + onShowCardCreationPanel = {}, + ) + } + + //then + onNodeWithText("생성").assertIsNotEnabled() + } + + @Test + fun `올바른 태그를 입력하면 안내 문구가 유지된다`() = runComposeUiTest { + //when + setContent { + CardCreationPanel( + onAddItem = {}, + onShowCardCreationPanel = {}, + ) + } + + //then + onNodeWithText("태그를 쉼표로 구분하여 입력하세요 (예: 버그, 긴급)") + .performTextInput("버그,긴급") + + onNodeWithText("5자 이내의 태그를 최대 5개까지 등록할 수 있습니다.").assertExists() + } +} \ No newline at end of file From 088ac22fbe12bd2a21bb928ded7887acae27ac95 Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 22:30:59 +0900 Subject: [PATCH 22/23] =?UTF-8?q?fix=20:=20Test=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=EB=9F=AC=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/woowacourse/kanban/board/ui/CardCreationPanelTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardCreationPanelTest.kt b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardCreationPanelTest.kt index 681cca9..c38bd70 100644 --- a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardCreationPanelTest.kt +++ b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardCreationPanelTest.kt @@ -5,8 +5,8 @@ import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.runComposeUiTest -import org.junit.Test import woowacourse.kanban.board.domain.CardData +import kotlin.test.Test @OptIn(ExperimentalTestApi::class) class CardCreationPanelTest { From 9eb88447c94c4e2b664db06cad74e86a1fbce8ae Mon Sep 17 00:00:00 2001 From: gykim_kr Date: Wed, 11 Mar 2026 23:01:25 +0900 Subject: [PATCH 23/23] =?UTF-8?q?fix=20:=201=EB=8B=A8=EA=B3=84=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=99=B8=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/woowacourse/kanban/board/App.kt | 15 +- .../woowacourse/kanban/board/ui/Board.kt | 153 ------------------ 2 files changed, 1 insertion(+), 167 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Board.kt diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt index 29a5d67..9b9b296 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt @@ -1,25 +1,12 @@ package woowacourse.kanban.board -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Devices.DESKTOP import androidx.compose.ui.tooling.preview.Preview -import woowacourse.kanban.board.domain.CardData -import woowacourse.kanban.board.ui.Board -import woowacourse.kanban.board.ui.CardCreationPanel @Composable @Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, device = DESKTOP) fun App() { - MaterialTheme { - Board() - } + MaterialTheme {} } diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Board.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Board.kt deleted file mode 100644 index 374267e..0000000 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Board.kt +++ /dev/null @@ -1,153 +0,0 @@ -package woowacourse.kanban.board.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.ProgressIndicatorDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import woowacourse.kanban.board.domain.CardData - -@Composable -fun Board() { - val cards = remember { mutableStateListOf() } - var showCardCreationPanel by remember { mutableStateOf(false) } - - Box( - modifier = Modifier.fillMaxSize(), - ) { - Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp), - ) { - BoardHeaderSection( - modifier = Modifier.fillMaxWidth(), - onClick = { - showCardCreationPanel = true - }, - ) - BoardContents( - modifier = Modifier.fillMaxSize(), - cards = cards - ) - } - - if (showCardCreationPanel) { - CardCreationPanel( - modifier = Modifier.align(Alignment.Center), - onAddItem = { cards.add(it) }, - onShowCardCreationPanel = { showCardCreationPanel = it }, - ) - } - } -} - -@Composable -private fun BoardHeaderSection( - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier, - ) { - Row( - modifier = modifier, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "Compose Desktop 칸반 보드 ", - fontWeight = FontWeight.W500, - fontSize = 24.sp, - lineHeight = 32.sp, - letterSpacing = 0.07.sp, - ) - Text( - text = "완료율: 0% (0/0)", - fontWeight = FontWeight.W400, - color = Color(0xFF6A7282), - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = (-0.15).sp, - ) - } - - Button( - onClick = { onClick() }, - modifier = Modifier, - shape = RoundedCornerShape(20), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = "새 태스크 생성 아이콘", - tint = Color.White, - ) - Text( - text = "새 태스크 생성", - color = Color.White, - fontWeight = FontWeight.W400, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = (-0.31).sp, - ) - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - LinearProgressIndicator( - progress = { 0.5f }, - modifier = Modifier.fillMaxWidth().height(8.dp), - color = ProgressIndicatorDefaults.linearColor, - trackColor = ProgressIndicatorDefaults.linearTrackColor, - strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, - ) - } -} - -/** - * 테스트 용 보드 컨텐츠 간단 출력 모듈입니다. (추후 단계에서 구현 예정) - */ -@Composable -private fun BoardContents( - modifier: Modifier = Modifier, - cards: List -) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(cards.size) {item -> - Card(cardData = cards[item]) - } - } -} \ No newline at end of file