diff --git a/README.md b/README.md index 98d7e7e..9403660 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,49 @@ -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] 필수 입력 란과 선택 입력 란을 구분한다. + - [x] 필수 입력: 제목, 상태, 담당자 + - [x] 선택 입력: 설명, 태그 +- [x] 상태와 담당자는 첫 번째 항목으로 기본 선택되어 있고, 한 항목만 선택 가능하다. +- [x] 유효성 검사가 실패하면 생성 버튼을 누를 수 없다. +- [x] 기존 코드를 마이그레이션한 뒤 하나의 커밋으로 합친다. +- [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 +- [x] 헤더 섹션 작성 +- [x] 제목 입력 필드 작성 +- [x] 설명 입력 필드 작성 +- [x] 태그 입력 필드 작성 +- [x] 상태 선택 버튼 작성 +- [x] 담당자 선택 버튼 작성 +- [x] 생성, 취소 버튼 작성 +- [x] KanbanBoard 새 태스크 생성 버튼 작성 +- [x] Card 입력 UI 내 비즈니즈 로직 분리 +- [x] 디자인 가이드에 따른 용어 변경 +- [x] 제목 유효성 검증 결과에 따른 시각적 표현 추가 +- [x] 태그 유효성 검증 결과에 따른 시각적 표현 추가 +- [x] 테스크 유효성 검증 결과에 따른 버튼 활성화 로직 추가 -### Build and Run Desktop (JVM) Application +### Domain +- [x] 상태 로직 추가 +- [x] 제목 유효성 로직 작성 +- [x] 태그 파싱 로직 작성 +- [x] 정규화 태그 유효성 검증 로직 작성 +- [x] 태그 검증 결과에 따른 에러 메시지 반환 로직 작성 -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 - ``` - ---- - -Learn more about [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html)… \ No newline at end of file +## 리팩토링 +### 1단계 +- 프로덕션 +- 테스트 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..9b9b296 100644 --- a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/App.kt @@ -1,44 +1,12 @@ package woowacourse.kanban.board -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -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.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.Devices.DESKTOP 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 @Composable -@Preview +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF, device = DESKTOP) fun App() { - MaterialTheme { - var showContent by remember { mutableStateOf(false) } - Column( - modifier = Modifier - .background(MaterialTheme.colorScheme.primaryContainer) - .safeContentPadding() - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Button(onClick = { showContent = !showContent }) { - Text("Click me!") - } - AnimatedVisibility(showContent) { - Image(painterResource(Res.drawable.compose_multiplatform), null) - } - } - } + MaterialTheme {} } diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt new file mode 100644 index 0000000..f31762d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/domain/CardData.kt @@ -0,0 +1,96 @@ +package woowacourse.kanban.board.domain + +/** + * Card 도메인 모델입니다. + * 카드 생성 규칙을 적용합니다. + * 생성은 [create] 팩토리 메서드로 수행합니다. + */ +class CardData private constructor( + val title: String, + val content: String, + val tags: List, + 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개까지만 등록할 수 있습니다." + + fun isValidText(rawText: String): Boolean { + return rawText.trim().isNotBlank() + } + + fun getTitleInfo(): String { + return TITLE_INVALID_FORMAT_MSG + } + + fun parseTag(tempTags: String): List { + 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) + } + + 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 필수 | 제목 + * @param content 본문 + * @param tags 태그 + * @param accountName 필수 | 계정명 + * @throws IllegalArgumentException 기능 요구사항을 충족하지 않을 경우 예외를 던집니다. + */ + fun create( + title: String, + content: String, + tags: List, + accountName: String, + ): CardData { + require(title.isNotBlank()) { "[Card] 제목은 필수 입력 항목입니다." } + require(accountName.isNotBlank()) { "[Card] 계정명은 필수 입력 항목입니다." } + + val normalizedTags = tags + .map { it.trim() } + .filter { it.isNotEmpty() } + + require(normalizedTags.size <= MAX_TAG_COUNT) { "[Card] 태그는 최대 ${MAX_TAG_COUNT}개까지 가능합니다." } + require(normalizedTags.all { it.length <= MAX_TAG_LENGTH }) { "[Card] 태그는 최대 ${MAX_TAG_LENGTH}자까지 가능합니다." } + + return CardData( + title = title, + content = content, + tags = normalizedTags, + manager = 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/Card.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Card.kt new file mode 100644 index 0000000..7ee90cf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/Card.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.CardData + +/** + * Card UI입니다. + * @param cardData Card의 데이터입니다. + * @param modifier Modifier + */ +@Composable +fun Card( + cardData: CardData, + 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 = cardData.title, + modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Kanban Card Title" }, + ) + + if (cardData.hasContent()) { + CardContent( + modifier = Modifier.fillMaxWidth().semantics { contentDescription = "Kanban Card Content" }, + content = cardData.content + ) + } + + if (cardData.hasTag()) CardTagsSection( + tags = cardData.tags + ) + + HorizontalDivider() + + CardAccountInfo( + accountName = cardData.manager, + 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/CardCreationPanel.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt new file mode 100644 index 0000000..d01b4c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardCreationPanel.kt @@ -0,0 +1,442 @@ +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 +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.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.material.icons.filled.Error +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 +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 +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 +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 tempTags by remember { mutableStateOf("") } + 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, + ) { + Column( + modifier = Modifier.background(Color.White).width(672.dp), + ) { + 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 + }, + showAdditionalInfo = !CardData.isValidText(taskTitle), + infoText = CardData.getTitleInfo(), + isError = !CardData.isValidText(taskTitle), + ) + + CardCreationPanelSection( + title = "설명", + placeholder = "태스크에 대한 자세한 설명을 입력하세요", + value = contents, + onTextChange = { contents = it }, + ) + + CardCreationPanelSection( + title = "태그", + placeholder = "태그를 쉼표로 구분하여 입력하세요 (예: 버그, 긴급)", + value = tempTags, + onTextChange = { + tempTags = it + tagInfoText = CardData.isValidTagInfo(tempTags) + }, + showAdditionalInfo = true, + infoText = tagInfoText, + isError = !CardData.isValidTag(tempTags), + ) + + CardCreationPanelStateSection( + selectedState = state, + onStateChange = { state = it }, + ) + + CardCreationPanelManagerSection( + selectedManager = manager, + onManagerChange = { manager = it }, + ) + + HorizontalDivider(modifier = Modifier.fillMaxWidth()) + + ActionButtonSection( + createEnabled = createEnabled, + onClick = { onShowCardCreationPanel(false) }, + onCreate = { + onAddItem( + CardData.create( + taskTitle, + contents, + tags, + manager, + ), + ) + }, + ) + } + } + } +} + +@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, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Icon( + imageVector = Icons.Default.Close, + contentDescription = "닫기 아이콘", + 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 = "", + isError: Boolean = false, +) { + val errorColor = Color(0xFFB3261E) + Column( + modifier = modifier, + ) { + TitleText(title) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = value, + onValueChange = { onTextChange(it) }, + trailingIcon = { + if (isError) Icon( + imageVector = Icons.Default.Error, + contentDescription = "에러 아이콘", + tint = errorColor, + ) + }, + isError = isError, + placeholder = { + Text( + text = placeholder, + color = Color(0xFFAAAAAA), + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 1.sp, + ) + }, + textStyle = TextStyle( + color = if (isError) errorColor else Color.Black, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 1.sp, + ), + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) + if (showAdditionalInfo) { + Text( + text = infoText, + color = if (isError) errorColor else Color(0xFF49454F), + fontSize = 12.sp, + fontWeight = FontWeight.W400, + lineHeight = 16.sp, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} + + +@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 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 ActionButtonSection( + createEnabled: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + onCreate: () -> Unit = {}, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + ActionButton( + buttonText = "취소", + enabled = true, + onClick = { onClick() }, + ) + Spacer(modifier = Modifier.width(12.dp)) + ActionButton( + buttonText = "생성", + enabled = createEnabled, + onClick = { + onClick() + onCreate() + }, + ) + } +} + +@Composable +private fun ActionButton( + buttonText: String, + enabled: Boolean, + 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() }, + enabled = enabled, + 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, +) { + 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 diff --git a/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardPreview.kt b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardPreview.kt new file mode 100644 index 0000000..b84244a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/woowacourse/kanban/board/ui/CardPreview.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.CardData + +/** + * 여러 케이스에 따른 KanbanCard의 Preview를 모아볼 수 있습니다. + */ +private class CardPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + CardData.create( + title = "Lazy Column 컴포넌트 구현", + content = "세로 스크롤 가능한 리스트 컴포넌트를 만들고 성능 최적화를 적용합니다.", + tags = listOf("컴포넌트", "성능"), + accountName = "구름", + ), + CardData.create( + title = "Lazy Column 컴포넌트 구현", + content = "", + tags = listOf("컴포넌트", "성능"), + accountName = "구름", + ), + CardData.create( + title = "Lazy Column 컴포넌트 구현", + content = "세로 스크롤 가능한 리스트 컴포넌트를 만들고 성능 최적화를 적용합니다.", + tags = emptyList(), + accountName = "구름", + ), + CardData.create( + title = "Lazy Column 컴포넌트 구현", + content = "", + tags = emptyList(), + accountName = "구름", + ), + CardData.create( + title = "너무너무 긴 제목은 한 줄까지만 노출너무너무 긴 제목은 한 줄까지만 노출", + content = "너무너무너무 긴 설명은 두 줄까지만 노출하고 말줄임표로 처리합니다 두 줄까지만 노너무너무너무 긴 설명은 두 줄까지만 노출하고 말줄임표로 처리합니다 두 줄까지만 노", + tags = listOf("너무너무", "긴 태그", "최대로", "5자까지진짜로", "5개제한임", "6개"), + accountName = "너무너무너무 긴 담당자도 한 줄너무너무너무 긴 담당자도 한 줄", + ), + ) +} + +@Preview(showBackground = true, name = "KanbanBoardCard") +@Composable +private fun CardPreview( + @PreviewParameter(CardPreviewParameterProvider::class) cardData: CardData, +) { + Card( + modifier = Modifier.width(286.dp), + cardData = cardData, + ) +} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/CardDataTest.kt b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/CardDataTest.kt new file mode 100644 index 0000000..cf69816 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/domain/CardDataTest.kt @@ -0,0 +1,159 @@ +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 + +/** + * [CardData] Unit 테스트 클래스입니다. + */ +class CardDataTest { + @Test + fun `제목이 공백만 있으면 생성할 수 없다`() { + assertFailsWith { + CardData.create( + title = " ", + content = "내용", + tags = listOf("태그1"), + accountName = "테스트 계정", + ) + } + } + + @Test + fun `계정명이 공백만 있으면 생성할 수 없다`() { + assertFailsWith { + CardData.create( + title = "제목", + content = "내용", + tags = listOf("태그1"), + accountName = " ", + ) + } + } + + @Test + fun `내용이 있으면 hasContent 리턴 값은 true이다`() { + val cardData = CardData.create( + title = "제목", + content = "내용", + tags = emptyList(), + accountName = "테스트 계정", + ) + + assertTrue(cardData.hasContent()) + } + + @Test + fun `내용이 공백이면 hasContent 리턴 값은 false이다`() { + val cardData = CardData.create( + title = "제목", + content = " ", + tags = emptyList(), + accountName = "테스트 계정", + ) + + assertFalse(cardData.hasContent()) + } + + @Test + fun `태그의 앞뒤 공백은 제거된다`() { + val cardData = CardData.create( + title = "제목", + content = "내용", + tags = listOf(" 태그1 ", " 태그2 "), + accountName = "테스트 계정", + ) + + assertEquals(listOf("태그1", "태그2"), cardData.tags) + } + + @Test + fun `공백으로만 구성된 태그는 제거된다`() { + val cardData = CardData.create( + title = "제목", + content = "내용", + tags = listOf("태그1", " ", "", " "), + accountName = "테스트 계정", + ) + + assertEquals(listOf("태그1"), cardData.tags) + } + + @Test + fun `태그가 5개를 초과하면 생성할 수 없다`() { + assertFailsWith { + CardData.create( + title = "제목", + content = "내용", + tags = listOf("태그1", "태그2", "태그3", "태그4", "태그5", "태그6"), + accountName = "테스트 계정", + ) + } + } + + @Test + fun `태그 내용이 5글자를 초과하면 5글자까지만 유지된다`() { + assertFailsWith { + CardData.create( + title = "제목", + content = "내용", + tags = listOf("우아한테크코스", "안드로이드8기", "칸반보드리팩터링"), + accountName = "테스트 계정", + ) + } + } + + @Test + fun `태그가 있으면 hasTag 리턴 값은 true이다`() { + val cardData = CardData.create( + title = "제목", + content = "내용", + tags = listOf("태그1", " "), + accountName = "테스트 계정", + ) + + assertTrue(cardData.hasTag()) + } + + @Test + fun `태그가 비어 있으면 hasTag 리턴 값은 false이다`() { + val cardData = CardData.create( + title = "제목", + content = "내용", + tags = listOf(" ", ""), + accountName = "테스트 계정", + ) + + 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..c38bd70 --- /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 woowacourse.kanban.board.domain.CardData +import kotlin.test.Test + +@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 diff --git a/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardTest.kt b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardTest.kt new file mode 100644 index 0000000..e50e65c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/woowacourse/kanban/board/ui/CardTest.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.CardData +import kotlin.test.Test + +/** + * [Card] UI 테스트 클래스입니다. + */ +@OptIn(ExperimentalTestApi::class) +class CardTest { + + @Test + fun `KanbanBoardCard의 제목, 내용, 태그, 계정명이 입력되면, 해당 필드가 모두 표시된다`() = runComposeUiTest { + val cardData = CardData.create( + title = "제목", + content = "내용", + tags = listOf("태그1", "태그2", "태그3", "태그4", "태그5"), + accountName = "테스트 계정", + ) + + setContent { + Card(cardData = 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 = CardData.create( + title = "제목", + content = "", + tags = emptyList(), + accountName = "테스트 계정", + ) + + setContent { + Card(cardData = cardData) + } + 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 = CardData.create( + title = "제목", + content = "", + tags = tags, + accountName = "테스트 계정", + ) + + setContent { + Card(cardData = cardData) + } + onNodeWithContentDescription("Card Title").assertIsDisplayed() + onNodeWithContentDescription("Card Content").assertDoesNotExist() + onAllNodesWithContentDescription("Card Tag").assertCountEquals(tags.size) + onNodeWithContentDescription("Card Account Info").assertIsDisplayed() + } + + @Test + fun `태그가 없는 경우, 태그가 출력되지 않는다`() = runComposeUiTest { + val cardData = CardData.create( + title = "제목", + content = "내용", + tags = emptyList(), + accountName = "테스트 계정", + ) + + setContent { + Card(cardData = cardData) + } + + onNodeWithContentDescription("Card Title").assertIsDisplayed() + onNodeWithContentDescription("Card Content").assertIsDisplayed() + onAllNodesWithContentDescription("Card Tag").assertCountEquals(0) + onNodeWithContentDescription("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