Skip to content
Closed
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
package `in`.koreatech.koin.feature.callvan.ui.create.component

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import `in`.koreatech.koin.core.designsystem.theme.RebrandKoinTheme
import `in`.koreatech.koin.feature.callvan.R
import java.time.LocalDate
import java.time.YearMonth
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList

@Composable
fun CallvanDateField(

Check warning on line 39 in feature/callvan/src/main/java/in/koreatech/koin/feature/callvan/ui/create/component/CallvanDateField.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This function has 12 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=BCSDLab_KOIN_ANDROID&issues=AZzhjn4lOaMNmCfjz7oO&open=AZzhjn4lOaMNmCfjz7oO&pullRequest=1324
formattedDate: String,
isPickerVisible: Boolean,
selectedYear: Int,
selectedMonth: Int,
selectedDay: Int,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

modifier 없어도 되나요

modifier: Modifier = Modifier,
onFieldClick: () -> Unit = {},
onYearChange: (Int) -> Unit = {},
onMonthChange: (Int) -> Unit = {},
onDayChange: (Int) -> Unit = {},
onReset: () -> Unit = {},
onConfirm: () -> Unit = {}
) {
val arrowRotation by animateFloatAsState(
targetValue = if (isPickerVisible) 180f else 0f,
label = "arrowRotation"
)

Column(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
CallvanSectionHeader(
label = stringResource(R.string.callvan_create_date_label),
hint = stringResource(R.string.callvan_create_date_hint)
)
Row(
modifier = Modifier
.fillMaxWidth()
.background(RebrandKoinTheme.colors.neutral100, RebrandKoinTheme.shapes.small)
.clickable(onClick = onFieldClick)
Comment on lines +69 to +73
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] 접근성 — 클릭 가능한 Row에 시맨틱 역할 누락

clickable 수식어에 role이 지정되지 않아 TalkBack 사용자가 이 요소의 역할을 알 수 없습니다. 날짜 선택 필드 전체가 클릭 가능한 버튼처럼 동작하므로 Role.Button을 지정하고, 무엇을 활성화하는지 설명하는 onClickLabel도 추가하는 것이 좋습니다.

Suggested change
Row(
modifier = Modifier
.fillMaxWidth()
.background(RebrandKoinTheme.colors.neutral100, RebrandKoinTheme.shapes.small)
.clickable(onClick = onFieldClick)
modifier = Modifier
.fillMaxWidth()
.background(RebrandKoinTheme.colors.neutral100, RebrandKoinTheme.shapes.small)
.clickable(
role = Role.Button,
onClickLabel = stringResource(R.string.callvan_create_date_picker_open),
onClick = onFieldClick
)
.padding(horizontal = 12.dp, vertical = 8.dp),

.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formattedDate,
style = RebrandKoinTheme.typography.regular14,
color = RebrandKoinTheme.colors.neutral800
)
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] 접근성(Accessibility) - contentDescription 누락

contentDescription = null로 설정하면 TalkBack 등 스크린 리더 사용자가 이 아이콘의 역할을 알 수 없습니다. 피커의 열림/닫힘 상태를 설명하는 문자열을 제공해야 합니다.

Icon(
    imageVector = Icons.Default.KeyboardArrowDown,
    contentDescription = if (isPickerVisible) {
        stringResource(R.string.callvan_create_date_picker_collapse)
    } else {
        stringResource(R.string.callvan_create_date_picker_expand)
    },
    ...
)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] 접근성(Accessibility): 피커 열림/닫힘 상태를 나타내는 아이콘에 contentDescription이 없습니다.

이 아이콘은 단순 장식이 아니라 UI 상태(펼침/접힘)를 시각적으로 전달하므로 스크린 리더 사용자를 위해 동적 설명을 제공해야 합니다.

Suggested change
contentDescription = null,
contentDescription = if (isPickerVisible) "날짜 선택 닫기" else "날짜 선택 열기",

tint = RebrandKoinTheme.colors.neutral800,
modifier = Modifier
.size(24.dp)
.rotate(arrowRotation)
)
}
}
AnimatedVisibility(
visible = isPickerVisible,
enter = expandVertically(),
exit = shrinkVertically()
) {
CallvanDatePickerCard(
selectedYear = selectedYear,
selectedMonth = selectedMonth,
selectedDay = selectedDay,
onYearChange = onYearChange,
onMonthChange = onMonthChange,
onDayChange = onDayChange,
onReset = onReset,
onConfirm = onConfirm
)
}
}
}

@Composable
private fun CallvanDatePickerCard(

Check warning on line 113 in feature/callvan/src/main/java/in/koreatech/koin/feature/callvan/ui/create/component/CallvanDateField.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This function has 9 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=BCSDLab_KOIN_ANDROID&issues=AZzhjn4lOaMNmCfjz7oP&open=AZzhjn4lOaMNmCfjz7oP&pullRequest=1324

Check failure on line 113 in feature/callvan/src/main/java/in/koreatech/koin/feature/callvan/ui/create/component/CallvanDateField.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 27 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=BCSDLab_KOIN_ANDROID&issues=AZz6WPLQACzsL9rDCAwG&open=AZz6WPLQACzsL9rDCAwG&pullRequest=1324
selectedYear: Int,
selectedMonth: Int,
selectedDay: Int,
modifier: Modifier = Modifier,
onYearChange: (Int) -> Unit = {},
onMonthChange: (Int) -> Unit = {},
onDayChange: (Int) -> Unit = {},
onReset: () -> Unit = {},
onConfirm: () -> Unit = {}
) {
val today = remember { LocalDate.now() }
val currentYear = remember { today.year }
val currentMonth = remember { today.monthValue }
val currentDay = remember { today.dayOfMonth }
val years = remember { persistentListOf("${currentYear}년", "${currentYear + 1}년") }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] 과거 날짜 선택 가능 - 비즈니스 로직 검증 누락

현재 연도와 다음 연도만 표시하지만, 같은 연도 내에서 이미 지난 날짜를 선택하는 것을 막는 로직이 없습니다. 콜밴 출발 날짜를 오늘 이전으로 설정하면 서버 요청 실패 또는 잘못된 데이터가 저장될 수 있습니다.

최소한 onConfirm 시점에 상위 ViewModel/UseCase에서 날짜 유효성 검증을 수행하거나, 피커 자체에서 과거 날짜 선택을 비활성화해야 합니다. 예시:

// ViewModel 또는 UseCase에서
val today = LocalDate.now()
val selectedDate = LocalDate.of(selectedYear, selectedMonth, selectedDay)
require(selectedDate >= today) { "출발 날짜는 오늘 이후여야 합니다." }

UI 레벨에서도 현재 연도를 선택했을 때 지난 달을 선택하지 못하도록 months를 동적으로 제한하는 것을 고려해보세요.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수정이 필요해보입니다.

val startMonth = remember(selectedYear) {
if (selectedYear == currentYear) currentMonth else 1
}
val months = remember(selectedYear) {
(startMonth..12).map { "${it}월" }.toPersistentList()
}
val startDay = remember(selectedYear, selectedMonth) {
if (selectedYear == currentYear && selectedMonth == currentMonth) currentDay else 1
}
val days = remember(selectedYear, selectedMonth) {
val daysCount = YearMonth.of(selectedYear, selectedMonth).lengthOfMonth()
(startDay..daysCount).map { "${it}일" }.toPersistentList()
}

val yearIndex = remember(selectedYear, currentYear) {
(selectedYear - currentYear).coerceIn(0, years.size - 1)
}
val monthIndex = remember(selectedMonth, startMonth) {
(selectedMonth - startMonth).coerceIn(0, months.size - 1)
}
val dayIndex = remember(selectedDay, startDay, days.size) {
(selectedDay - startDay).coerceIn(0, days.size - 1)
}
Comment on lines +143 to +151
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] remember + 키 대신 derivedStateOf 사용 권장

yearIndex, monthIndex, dayIndex는 다른 상태로부터 파생된 값입니다. remember(key) { ... } 패턴도 동작하지만, Compose 공식 가이드라인에서는 이처럼 다른 상태 값에서 파생되는 값에는 derivedStateOf를 권장합니다. derivedStateOf는 결과값이 실제로 변경된 경우에만 리컴포지션을 발생시켜 불필요한 재계산을 방지합니다.

Suggested change
val yearIndex = remember(selectedYear, currentYear) {
(selectedYear - currentYear).coerceIn(0, years.size - 1)
}
val monthIndex = remember(selectedMonth) {
(selectedMonth - 1).coerceIn(0, months.size - 1)
}
val dayIndex = remember(selectedDay, days.size) {
(selectedDay - 1).coerceIn(0, days.size - 1)
}
val yearIndex by remember { derivedStateOf { (selectedYear - currentYear).coerceIn(0, years.size - 1) } }
val monthIndex by remember { derivedStateOf { (selectedMonth - 1).coerceIn(0, months.size - 1) } }
val dayIndex by remember { derivedStateOf { (selectedDay - 1).coerceIn(0, days.size - 1) } }


Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
shape = RebrandKoinTheme.shapes.small,
colors = CardDefaults.cardColors(containerColor = RebrandKoinTheme.colors.neutral100),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth()
) {
CallvanScrollPicker(
items = years,
selectedIndex = yearIndex,
onIndexChange = { index ->
val newYear = currentYear + index
onYearChange(newYear)
val newStartDay = if (newYear == currentYear && selectedMonth == currentMonth) currentDay else 1
if (selectedDay < newStartDay) onDayChange(newStartDay)
},
modifier = Modifier.weight(1f)
)
CallvanScrollPicker(
items = months,
selectedIndex = monthIndex,
onIndexChange = { index ->
val newMonth = startMonth + index
onMonthChange(newMonth)
val newStartDay = if (selectedYear == currentYear && newMonth == currentMonth) currentDay else 1
if (selectedDay < newStartDay) onDayChange(newStartDay)
},
modifier = Modifier.weight(1f)
)
CallvanScrollPicker(
items = days,
selectedIndex = dayIndex,
onIndexChange = { index -> onDayChange(startDay + index) },
modifier = Modifier.weight(1f)
)
}
HorizontalDivider(color = RebrandKoinTheme.colors.neutral200)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.End)
) {
Text(
text = stringResource(R.string.callvan_create_picker_reset),
style = RebrandKoinTheme.typography.medium14,
color = RebrandKoinTheme.colors.primary500,
modifier = Modifier.clickable(onClick = onReset)
)
Text(
text = stringResource(R.string.callvan_create_picker_confirm),
style = RebrandKoinTheme.typography.medium14,
color = RebrandKoinTheme.colors.primary500,
modifier = Modifier.clickable(onClick = onConfirm)
)
Comment on lines +204 to +215
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Text에 직접 .clickable을 사용하면 터치 영역이 텍스트 크기로 제한되어 Material Design 최소 터치 타겟(48dp)을 충족하지 못합니다.

clickable 전에 padding을 추가하거나 TextButton을 사용하는 것이 권장됩니다.

// 개선 예시
Text(
    text = stringResource(R.string.callvan_create_picker_reset),
    style = RebrandKoinTheme.typography.medium14,
    color = RebrandKoinTheme.colors.primary500,
    modifier = Modifier
        .clickable(onClick = onReset)
        .padding(horizontal = 8.dp, vertical = 12.dp)
)

Comment on lines +204 to +215
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] 접근성 — Text 버튼에 Role.Button 시맨틱 누락

Text 위에 clickable만 적용하면 TalkBack은 이것을 버튼이 아닌 일반 텍스트로 인식합니다. Role.Button을 추가해야 "활성화하려면 두 번 탭하세요" 대신 "버튼"으로 올바르게 안내됩니다.

Suggested change
Text(
text = stringResource(R.string.callvan_create_picker_reset),
style = RebrandKoinTheme.typography.medium14,
color = RebrandKoinTheme.colors.primary500,
modifier = Modifier.clickable(onClick = onReset)
)
Text(
text = stringResource(R.string.callvan_create_picker_confirm),
style = RebrandKoinTheme.typography.medium14,
color = RebrandKoinTheme.colors.primary500,
modifier = Modifier.clickable(onClick = onConfirm)
)
Text(
text = stringResource(R.string.callvan_create_picker_reset),
style = RebrandKoinTheme.typography.medium14,
color = RebrandKoinTheme.colors.primary500,
modifier = Modifier.clickable(role = Role.Button, onClick = onReset)
)
Text(
text = stringResource(R.string.callvan_create_picker_confirm),
style = RebrandKoinTheme.typography.medium14,
color = RebrandKoinTheme.colors.primary500,
modifier = Modifier.clickable(role = Role.Button, onClick = onConfirm)
)

}
}
}
}

@Preview(showBackground = true)
@Composable
private fun CallvanDateFieldPreview() {
CallvanDateField(
formattedDate = "2026년 03월 07일",
isPickerVisible = false,
selectedYear = 2026,
selectedMonth = 3,
selectedDay = 7,
onFieldClick = {},
onYearChange = {},
onMonthChange = {},
onDayChange = {},
onReset = {},
onConfirm = {}
)
}

@Preview(showBackground = true)
@Composable
private fun CallvanDateFieldPickerVisiblePreview() {
CallvanDateField(
formattedDate = "2026년 03월 07일",
isPickerVisible = true,
selectedYear = 2026,
selectedMonth = 3,
selectedDay = 7,
onFieldClick = {},
onYearChange = {},
onMonthChange = {},
onDayChange = {},
onReset = {},
onConfirm = {}
)
}
Loading