diff --git a/app/src/main/java/com/poti/android/core/common/extension/ModifierExt.kt b/app/src/main/java/com/poti/android/core/common/extension/ModifierExt.kt index 938e4217..1ed3d2fe 100644 --- a/app/src/main/java/com/poti/android/core/common/extension/ModifierExt.kt +++ b/app/src/main/java/com/poti/android/core/common/extension/ModifierExt.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Paint @@ -136,3 +137,24 @@ fun Modifier.topRoundedBorder( style = Stroke(width = strokeWidthPx), ) } + +fun Modifier.bottomBorder( + strokeWidth: Dp, + color: Color, + isVisible: Boolean = true, +): Modifier { + if (!isVisible) return this + + return this.drawBehind { + val strokeWidthPx = strokeWidth.toPx() + val width = size.width + val height = size.height - strokeWidthPx / 2 + + drawLine( + color = color, + start = Offset(x = 0f, y = height), + end = Offset(x = width, y = height), + strokeWidth = strokeWidthPx, + ) + } +} diff --git a/app/src/main/java/com/poti/android/core/designsystem/component/field/DummyFieldComponent.kt b/app/src/main/java/com/poti/android/core/designsystem/component/field/DummyFieldComponent.kt deleted file mode 100644 index 970e11c3..00000000 --- a/app/src/main/java/com/poti/android/core/designsystem/component/field/DummyFieldComponent.kt +++ /dev/null @@ -1 +0,0 @@ -package com.poti.android.core.designsystem.component.field diff --git a/app/src/main/java/com/poti/android/core/designsystem/component/field/FieldErrorMessage.kt b/app/src/main/java/com/poti/android/core/designsystem/component/field/FieldErrorMessage.kt new file mode 100644 index 00000000..611039e9 --- /dev/null +++ b/app/src/main/java/com/poti/android/core/designsystem/component/field/FieldErrorMessage.kt @@ -0,0 +1,28 @@ +package com.poti.android.core.designsystem.component.field + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.poti.android.core.designsystem.theme.PotiTheme + +// TODO: [도연] Display>ErrorMessage 병합 시 삭제 +@Composable +fun FieldErrorMessage( + error: String, + modifier: Modifier = Modifier, +) { + if (error.isNotEmpty()) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = error, + color = PotiTheme.colors.sementicRed, + style = PotiTheme.typography.body14m, + ) + } + } +} diff --git a/app/src/main/java/com/poti/android/core/designsystem/component/field/FieldLabel.kt b/app/src/main/java/com/poti/android/core/designsystem/component/field/FieldLabel.kt new file mode 100644 index 00000000..437d7b92 --- /dev/null +++ b/app/src/main/java/com/poti/android/core/designsystem/component/field/FieldLabel.kt @@ -0,0 +1,18 @@ +package com.poti.android.core.designsystem.component.field + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.poti.android.core.designsystem.theme.PotiTheme + +@Composable +internal fun FieldLabel( + label: String, +) { + if (label.isNotEmpty()) { + Text( + text = label, + color = PotiTheme.colors.black, + style = PotiTheme.typography.body14sb, + ) + } +} diff --git a/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiBasicField.kt b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiBasicField.kt new file mode 100644 index 00000000..31da0faf --- /dev/null +++ b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiBasicField.kt @@ -0,0 +1,135 @@ +package com.poti.android.core.designsystem.component.field + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.poti.android.core.common.extension.roundedBackgroundWithBorder +import com.poti.android.core.designsystem.theme.PotiTheme + +@Composable +internal fun PotiBasicField( + value: String, + onValueChanged: (String) -> Unit, + placeholder: String, + borderColor: Color, + backgroundColor: Color, + modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Text, + imeAction: ImeAction = ImeAction.Done, + onDoneAction: () -> Unit = {}, + onNextAction: () -> Unit = {}, + onSearchAction: () -> Unit = {}, + onFocusChanged: (Boolean) -> Unit = {}, + focusRequester: FocusRequester? = null, + singleLine: Boolean = true, + trailingIcon: @Composable () -> Unit = {}, + enabled: Boolean = true, +) { + var isFocused by remember { mutableStateOf(false) } + val requester = remember { + focusRequester ?: FocusRequester() + } + + BasicTextField( + value = value, + onValueChange = onValueChanged, + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .roundedBackgroundWithBorder( + cornerRadius = 8.dp, + backgroundColor = backgroundColor, + borderColor = borderColor, + borderWidth = 1.dp, + ) + .focusRequester(requester) + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + onFocusChanged(focusState.isFocused) + }, + singleLine = singleLine, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + imeAction = imeAction, + ), + keyboardActions = KeyboardActions( + onDone = { onDoneAction() }, + onSearch = { onSearchAction() }, + onNext = { onNextAction() }, + ), + enabled = enabled, + textStyle = PotiTheme.typography.body16m.copy( + color = PotiTheme.colors.black, + ), + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top, + ) { + Box( + modifier = Modifier + .weight(1f), + contentAlignment = Alignment.CenterStart, + ) { + innerTextField() + + if (value.isEmpty()) { + Text( + text = placeholder, + color = PotiTheme.colors.gray700, + style = PotiTheme.typography.body16m, + ) + } + + if (singleLine && !isFocused && value.isNotEmpty()) { + Text( + text = value, + modifier = Modifier + .background(backgroundColor), + color = PotiTheme.colors.black, + style = PotiTheme.typography.body16m, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + trailingIcon() + } + }, + ) +} + +enum class FieldStatus { + DEFAULT, + FOCUS, + ERROR, +} + +val FieldStatus.borderColor: Color + @Composable get() = when (this) { + FieldStatus.DEFAULT -> PotiTheme.colors.gray300 + FieldStatus.FOCUS -> PotiTheme.colors.gray700 + FieldStatus.ERROR -> PotiTheme.colors.sementicRed + } diff --git a/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiCountField.kt b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiCountField.kt new file mode 100644 index 00000000..6ced36c0 --- /dev/null +++ b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiCountField.kt @@ -0,0 +1,143 @@ +package com.poti.android.core.designsystem.component.field + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +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.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.poti.android.core.designsystem.theme.PotiTheme + +/** + * 우측에 글자 수 카운트가 표시되는 텍스트 필드입니다. + * 최대 한 줄로 노출되며, 길게 작성된 경우 포커스 아웃 시 말줄임 처리 됩니다. + * maxLength는 UI 표시 용이며, 내부에 글자 수를 제어하는 로직은 없으므로 글자 수 제한은 외부에서 제어해 value로 넣어줍니다. + * + * @param value 필드 입력값입니다. + * @param onValueChanged 필드에 입력된 값을 전달합니다. + * @param placeholder 입력값이 없을 때 표시됩니다. + * @param maxLength 최대 글자 수로 표시됩니다. + * @param modifier + * @param keyboardType 키보드 입력 타입으로, 기본값은 Text입니다. + * @param label 필드 상단에 표시됩니다. + * @param error 에러가 emptyString이 아닌 경우에만 필드 하단에 표시되며, borderColor가 red로 변경됩니다. + * @param imeAction 키보드 액션 타입으로, 기본값은 Done 입니다. Next로 변경 시 아래 위치한 필드로 포커스 이동시킬 수 있습니다. + * @param focusRequester 필드 포커스를 외부에서 제어하고 싶을 때 사용합니다. + * @param enabled 입력 및 포커스, 터치 이벤트 차단하고 싶다면 false로 설정합니다. 기본값 true입니다. + * + * @author 도연 + * @sample PotiCountFieldWithLabelPreview + */ +@Composable +fun PotiCountField( + value: String, + onValueChanged: (String) -> Unit, + placeholder: String, + maxLength: Int, + modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Text, + label: String = "", + error: String = "", + imeAction: ImeAction = ImeAction.Done, + focusRequester: FocusRequester? = null, + enabled: Boolean = true, +) { + var isFocused by remember { mutableStateOf(false) } + + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + val status = when { + error.isNotEmpty() -> FieldStatus.ERROR + isFocused -> FieldStatus.FOCUS + else -> FieldStatus.DEFAULT + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FieldLabel(label) + + PotiBasicField( + value = value, + onValueChanged = onValueChanged, + placeholder = placeholder, + borderColor = status.borderColor, + backgroundColor = PotiTheme.colors.white, + modifier = Modifier + .fillMaxWidth() + .heightIn(52.dp), + keyboardType = keyboardType, + imeAction = imeAction, + onDoneAction = { + keyboardController?.hide() + focusManager.clearFocus() + }, + onNextAction = { + focusManager.moveFocus(FocusDirection.Down) + }, + onFocusChanged = { isFocused = it }, + focusRequester = focusRequester, + singleLine = true, + trailingIcon = { + Text( + text = "${value.length}/$maxLength", + modifier = Modifier.padding(bottom = 3.dp), + color = PotiTheme.colors.gray700, + style = PotiTheme.typography.body14m, + ) + }, + enabled = enabled, + ) + + // TODO: [도연] Display>ErrorMessage으로 대체 + FieldErrorMessage(error) + } +} + +@Preview +@Composable +private fun PotiCountFieldWithErrorPreview() { + var text by remember { mutableStateOf("") } + + PotiTheme { + PotiCountField( + value = text, + onValueChanged = { text = it }, + placeholder = "플레이스홀더", + error = "에러 메시지", + maxLength = 10, + ) + } +} + +@Preview +@Composable +private fun PotiCountFieldWithLabelPreview() { + var text by remember { mutableStateOf("") } + + PotiTheme { + PotiCountField( + value = text, + onValueChanged = { text = it }, + placeholder = "플레이스홀더", + label = "라벨", + maxLength = 10, + ) + } +} diff --git a/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiDropdownField.kt b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiDropdownField.kt new file mode 100644 index 00000000..eb691b8e --- /dev/null +++ b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiDropdownField.kt @@ -0,0 +1,264 @@ +package com.poti.android.core.designsystem.component.field + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import com.poti.android.R +import com.poti.android.core.designsystem.model.FieldMenuItem +import com.poti.android.core.designsystem.theme.PotiTheme + +/** + * 필드 하단에 드롭다운 메뉴가 제공되는 컴포넌트입니다. + * 필드에는 텍스트 입력이 불가하며, 필드 터치로 메뉴가 여닫힙니다. + * + * @param value 필드에 표시되는 값으로, 유저가 선택한 옵션을 넣어줍니다. + * @param placeholder 선택한 옵션이 없을 때 필드에 표시됩니다. + * @param onItemClick 메뉴에서 아이템 클릭 시 호출되는 콜백으로, 클릭한 아이템 객체를 전달합니다. + * @param menuItems 메뉴에 노출되는 아이템 데이터 리스트입니다. + * @param selectedIds 메뉴 아이템의 selected 상태 표시에 쓰이며, 외부에서 제어합니다. 선택된 아이템 객체의 id 프로퍼티를 넣어줍니다. + * @param modifier + * @param label 필드 상단에 표시됩니다. + * @param error emptyString이 아닌 경우 필드 하단에 에러 메시지를 노출하고, borderColor를 변경합니다. + * @param initialOpenState 메뉴의 초기 열림 상태를 설정합니다. 기본값 false로, 열린 상태를 초기값으로 하고 싶을 때에만 true로 설정합니다. + * @param closeOnItemClick 메뉴 아이템 클릭 시 메뉴를 닫는 옵션입니다. 기본값 true로, 다중 선택 필요하다면 false로 설정합니다. + * @param maxHeight 메뉴 최대 높이를 제한합니다. 기본값 422dp입니다. + * @param scrollState 메뉴 스크롤을 외부에서 제어하고 싶을 때 사용합니다. + * @param offset 필드 하단으로부터 메뉴까지의 간격입니다. 기본값 12dp이며, y값만 조정 가능합니다. + * @param shape 메뉴 전체 모양입니다. + * @param border 메뉴 전체 테두리입니다. + * + * @author 도연 + * @sample PotiDropdownFieldPreview + */ +@Composable +fun PotiDropdownField( + value: String, + placeholder: String, + onItemClick: (FieldMenuItem) -> Unit, + menuItems: List, + selectedIds: Set, + modifier: Modifier = Modifier, + label: String = "", + error: String = "", + initialOpenState: Boolean = false, + closeOnItemClick: Boolean = true, + maxHeight: Dp = 422.dp, + scrollState: LazyListState = rememberLazyListState(), + offset: DpOffset = DpOffset(x = 0.dp, y = 12.dp), + shape: Shape = RoundedCornerShape(8.dp), + border: BorderStroke = BorderStroke(1.dp, PotiTheme.colors.gray700), +) { + val expandedState = remember { MutableTransitionState(initialOpenState) } + var parentWidth by remember { mutableIntStateOf(0) } + + val density = LocalDensity.current + var calculatedMaxHeight by remember { mutableStateOf(0.dp) } + var isCalculateFinished by remember { mutableStateOf(false) } + + val status = when { + error.isNotEmpty() -> FieldStatus.ERROR + expandedState.targetState -> FieldStatus.FOCUS + else -> FieldStatus.DEFAULT + } + + Box( + modifier = modifier + .fillMaxWidth(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FieldLabel(label) + + PotiBasicField( + value = value, + onValueChanged = {}, + placeholder = placeholder, + modifier = Modifier + .fillMaxWidth() + .heightIn(52.dp) + .onGloballyPositioned { coordinates -> + parentWidth = coordinates.size.width + } + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { + expandedState.targetState = !expandedState.currentState + }, + borderColor = status.borderColor, + backgroundColor = PotiTheme.colors.white, + trailingIcon = { + Crossfade( + targetState = expandedState.targetState, + ) { opened -> + Icon( + imageVector = ImageVector.vectorResource(if (opened) R.drawable.ic_arrow_up_lg else R.drawable.ic_arrow_down_lg), + contentDescription = null, + modifier = Modifier + .size(24.dp), + tint = PotiTheme.colors.gray700, + ) + } + }, + enabled = false, + ) + + // TODO: [도연] Display>errorMessage로 대체 + FieldErrorMessage(error) + } + + PotiDropdownMenu( + expandedState = expandedState, + onDismissRequest = { + expandedState.targetState = false + }, + scrollState = scrollState, + parentWidth = parentWidth, + offset = offset, + shape = shape, + border = border, + maxHeight = if (isCalculateFinished) calculatedMaxHeight else maxHeight, + popupProperties = PopupProperties( + focusable = true, + ), + ) { + itemsIndexed(menuItems) { index, item -> + PotiMenuItem( + option = item.option, + onClick = { + onItemClick(item) + if (closeOnItemClick) { + expandedState.targetState = false + } + }, + isSelected = item.id in selectedIds, + modifier = when (isCalculateFinished) { + true -> Modifier + else -> + Modifier + .onGloballyPositioned { coordinates -> + val heightPx = coordinates.size.height + val heightDp = with(density) { heightPx.toDp() } + if (heightDp + calculatedMaxHeight > maxHeight) { + isCalculateFinished = true + return@onGloballyPositioned + } + calculatedMaxHeight += heightDp + } + }, + price = item.price, + disabled = item.disabled, + showBottomBorder = index < menuItems.size, + ) + } + } + } +} + +@Preview +@Composable +private fun PotiDropdownFieldPreview() { + var text by remember { mutableStateOf("") } + val selectedIds = remember { mutableStateSetOf() } + val menuItems = listOf( + FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션".repeat(50)), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), FieldMenuItem("옵션"), + ) + + PotiTheme { + PotiDropdownField( + value = text, + placeholder = "플레이스 홀더", + initialOpenState = true, + onItemClick = { + if (it.id in selectedIds) { + selectedIds.remove(it.id) + text = "" + } else { + selectedIds.clear() + selectedIds.add(it.id) + text = it.option + } + }, + menuItems = menuItems, + selectedIds = selectedIds, + modifier = Modifier + .padding(horizontal = 20.dp) + .padding(top = 40.dp), + maxHeight = 600.dp, + ) + } +} + +@Preview +@Composable +private fun PotiDropdownFieldWithPriceWithMutlipleSelectPreview() { + var text by remember { mutableStateOf("") } + val selectedIds = remember { mutableStateSetOf() } + val menuItems = listOf( + FieldMenuItem("옵션", "1,000원"), + FieldMenuItem("옵션", "1,000원"), + FieldMenuItem("옵션", "1,000원"), + FieldMenuItem("옵션", "1,000원"), + FieldMenuItem("옵션", "1,000원"), + FieldMenuItem("옵션", "1,000원"), + FieldMenuItem("옵션", "1,000원"), + FieldMenuItem("옵션", "1,000원"), + ) + + PotiTheme { + PotiDropdownField( + value = text, + placeholder = "플레이스 홀더", + initialOpenState = true, + onItemClick = { + if (it.id in selectedIds) { + selectedIds.remove(it.id) + text = "" + } else { + selectedIds.add(it.id) + text = it.option + } + }, + menuItems = menuItems, + selectedIds = selectedIds, + modifier = Modifier + .padding(horizontal = 20.dp) + .padding(top = 40.dp), + error = "에러 메시지", + closeOnItemClick = false, + ) + } +} diff --git a/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiDropdownMenu.kt b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiDropdownMenu.kt new file mode 100644 index 00000000..7c9a717b --- /dev/null +++ b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiDropdownMenu.kt @@ -0,0 +1,124 @@ +package com.poti.android.core.designsystem.component.field + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties + +@Composable +internal fun PotiDropdownMenu( + expandedState: MutableTransitionState, + onDismissRequest: () -> Unit, + parentWidth: Int, + offset: DpOffset, + scrollState: LazyListState, + shape: Shape, + border: BorderStroke, + maxHeight: Dp?, + popupProperties: PopupProperties = PopupProperties(), + content: LazyListScope.() -> Unit, +) { + if (expandedState.currentState || expandedState.targetState) { + val density = LocalDensity.current + val popupPositionProvider = remember(offset, density) { + val offsetYPx = with(density) { offset.y.roundToPx() } + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + return IntOffset( + x = anchorBounds.left, + y = anchorBounds.bottom + offsetYPx, + ) + } + } + } + + Popup( + onDismissRequest = onDismissRequest, + popupPositionProvider = popupPositionProvider, + properties = popupProperties, + ) { + PotiDropdownMenuContent( + expandedState = expandedState, + scrollState = scrollState, + shape = shape, + border = border, + parentWidth = parentWidth, + maxHeight = maxHeight, + content = content, + ) + } + } +} + +@Composable +private fun PotiDropdownMenuContent( + expandedState: MutableTransitionState, + scrollState: LazyListState, + shape: Shape, + border: BorderStroke?, + parentWidth: Int, + maxHeight: Dp?, + content: LazyListScope.() -> Unit, +) { + val density = LocalDensity.current + + AnimatedVisibility( + visibleState = expandedState, + enter = slideInVertically( + initialOffsetY = { -it }, + animationSpec = tween(120, easing = LinearOutSlowInEasing), + ), + exit = slideOutVertically( + targetOffsetY = { -it }, + animationSpec = tween(75, easing = LinearOutSlowInEasing), + ), + ) { + Surface( + modifier = Modifier + .width(with(density) { parentWidth.toDp() }), + shape = shape, + border = border, + ) { + LazyColumn( + state = scrollState, + modifier = + Modifier + .then( + when (maxHeight) { + null -> Modifier + else -> Modifier.heightIn(max = maxHeight) + }, + ), + content = content, + ) + } + } +} diff --git a/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiLongTextField.kt b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiLongTextField.kt new file mode 100644 index 00000000..fd7dfe90 --- /dev/null +++ b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiLongTextField.kt @@ -0,0 +1,119 @@ +package com.poti.android.core.designsystem.component.field + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.poti.android.core.designsystem.theme.PotiTheme + +/** + * Long 타입 텍스트 필드입니다. 최소 높이 160이며, 컨텍츠에 따라 높이가 늘어납니다. + * + * @param value 필드 입력값입니다. + * @param onValueChanged 필드에 입력된 값을 전달합니다. + * @param placeholder 입력값이 없을 때 표시됩니다. + * @param modifier + * @param label 필드 상단에 표시됩니다. + * @param error 에러가 emptyString이 아닌 경우에만 필드 하단에 표시되며, borderColor가 red로 변경됩니다. + * @param imeAction 키보드 액션 타입으로, 기본값은 Default 입니다. Done 설정 시 키보드 닫힘, Next 설정 시 아래 위치한 필드로 포커스 이동시킬 수 있습니다. + * @param focusRequester 필드 포커스를 외부에서 제어하고 싶을 때 사용합니다. + * @param enabled 입력 및 포커스, 터치 이벤트 차단하고 싶다면 false로 설정합니다. 기본값 true입니다. + */ +@Composable +fun PotiLongTextField( + value: String, + onValueChanged: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + label: String = "", + error: String = "", + imeAction: ImeAction = ImeAction.Default, + focusRequester: FocusRequester = remember { FocusRequester() }, + enabled: Boolean = true, +) { + var isFocused by remember { mutableStateOf(false) } + + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + val status = when { + error.isNotEmpty() -> FieldStatus.ERROR + isFocused -> FieldStatus.FOCUS + else -> FieldStatus.DEFAULT + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FieldLabel(label) + + PotiBasicField( + value = value, + onValueChanged = onValueChanged, + placeholder = placeholder, + borderColor = status.borderColor, + backgroundColor = PotiTheme.colors.white, + modifier = Modifier + .fillMaxWidth() + .heightIn(160.dp), + enabled = enabled, + imeAction = imeAction, + onDoneAction = { + keyboardController?.hide() + focusManager.clearFocus() + }, + onNextAction = { + focusManager.moveFocus(FocusDirection.Down) + }, + onFocusChanged = { isFocused = it }, + focusRequester = focusRequester, + singleLine = false, + ) + + FieldErrorMessage(error) + } +} + +@Preview +@Composable +private fun PotiLongTextFieldWithErrorPreview() { + var text by remember { mutableStateOf("") } + + PotiTheme { + PotiLongTextField( + value = text, + onValueChanged = { text = it }, + placeholder = "플레이스홀더", + error = "에러 메시지", + ) + } +} + +@Preview +@Composable +private fun PotiLongTextFieldWithLabelPreview() { + var text by remember { mutableStateOf("") } + + PotiTheme { + PotiLongTextField( + value = text, + onValueChanged = { text = it }, + placeholder = "플레이스홀더", + label = "라벨", + ) + } +} diff --git a/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiMenuItem.kt b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiMenuItem.kt new file mode 100644 index 00000000..38bbb41a --- /dev/null +++ b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiMenuItem.kt @@ -0,0 +1,76 @@ +package com.poti.android.core.designsystem.component.field + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.poti.android.core.common.extension.bottomBorder +import com.poti.android.core.common.extension.noRippleClickable +import com.poti.android.core.designsystem.theme.PotiTheme + +@Composable +fun PotiMenuItem( + option: String, + onClick: () -> Unit, + isSelected: Boolean, + modifier: Modifier = Modifier, + price: String? = null, + disabled: Boolean = false, + showBottomBorder: Boolean = true, + interactionSource: InteractionSource = remember { MutableInteractionSource() }, +) { + val isPressed by interactionSource.collectIsPressedAsState() + + val backgroundColor = when { + !disabled && isPressed -> PotiTheme.colors.gray100 + else -> PotiTheme.colors.white + } + + val textColor = when { + isSelected || disabled -> PotiTheme.colors.gray700 + else -> PotiTheme.colors.black + } + + Row( + modifier = modifier + .heightIn(52.dp) + .background(backgroundColor) + .bottomBorder( + strokeWidth = 1.dp, + color = PotiTheme.colors.gray300, + isVisible = showBottomBorder, + ) + .noRippleClickable( + onClick = onClick, + enabled = !disabled, + interactionSource = remember { MutableInteractionSource() }, + ) + .padding(horizontal = 16.dp, vertical = 15.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = option, + color = textColor, + style = PotiTheme.typography.body14m, + modifier = Modifier.weight(1f), + ) + + if (price != null) { + Text( + text = price, + color = textColor, + style = PotiTheme.typography.body14m, + ) + } + } +} diff --git a/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiSearchField.kt b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiSearchField.kt new file mode 100644 index 00000000..bff7492e --- /dev/null +++ b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiSearchField.kt @@ -0,0 +1,261 @@ +package com.poti.android.core.designsystem.component.field + +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.poti.android.R +import com.poti.android.core.designsystem.model.FieldMenuItem +import com.poti.android.core.designsystem.theme.PotiTheme +import com.poti.android.core.designsystem.theme.White +import kotlinx.coroutines.delay + +/** + * 필드에 검색어 입력 시, 검색 결과가 드롭다운 메뉴로 제공되는 컴포넌트입니다. + * 입력에 따른 자동 검색인 경우 외부에서 적절한 menuItems를 넣어주며 제어하며, 명시적인 검색 콜백은 onSearchClick으로 전달합니다. + * + * + * @param value 필드 입력값입니다. 메뉴에서 유저가 아이템 선택 시 선택한 옵션으로 대체합니다. + * @param onValueChange 필드에 입력된 값을 전달합니다. + * @param placeholder 입력값 및 선택한 옵션이 없을 때 필드에 표시됩니다. + * @param onSearchClick 검색 콜백입니다. + * @param onItemClick 메뉴에서 아이템 클릭 시 호출되는 콜백으로, 클릭한 아이템 객체를 전달합니다. + * @param menuItems 메뉴에 노출되는 아이템 데이터 리스트로, 검색 결과를 넣어줍니다. + * @param selectedIds 메뉴 아이템의 selected 상태 표시에 쓰이며, 외부에서 제어합니다. 선택된 아이템 객체의 id 프로퍼티를 넣어줍니다. + * @param searchType 서치 타입에 따라 메뉴 최대 길이가 조정됩니다. 아티스트 검색 시 ARTIST, 상품 등록을 위한 상품명 검색 시 PRODUCT를 사용합니다. + * @param modifier + * @param label 필드 상단에 표시됩니다. + * @param error emptyString이 아닌 경우 필드 하단에 에러 메시지를 노출하고, borderColor를 변경합니다. + * @param focusRequester 포커스를 외부에서 제어하고 싶을 때 사용합니다. 예: 화면 진입 시 필드에 포커스 가도록 + * @param scrollState 메뉴 스크롤을 외부에서 제어하고 싶을 때 사용합니다. + * @param offset 필드 하단으로부터 메뉴까지의 간격입니다. 기본값 12dp이며, y값만 조정 가능합니다. + * @param shape 메뉴 전체 모양입니다. + * @param border 메뉴 전체 테두리입니다. + * + * @author 도연 + * @sample PotiSearchFieldPreview + */ +@Composable +fun PotiSearchField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + onSearchClick: (String) -> Unit, + onItemClick: (FieldMenuItem) -> Unit, + menuItems: List, + selectedIds: Set, + searchType: SearchType, + modifier: Modifier = Modifier, + label: String = "", + error: String = "", + focusRequester: FocusRequester? = null, + scrollState: LazyListState = rememberLazyListState(), + offset: DpOffset = DpOffset(x = 0.dp, y = 12.dp), + shape: Shape = RoundedCornerShape(8.dp), + border: BorderStroke = BorderStroke(1.dp, PotiTheme.colors.gray700), +) { + val expandedState = remember { MutableTransitionState(false) } + var parentWidth by remember { mutableIntStateOf(0) } + + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + var isFieldFocused by remember { mutableStateOf(false) } + var searchActionDone by remember { mutableStateOf(false) } + var dismissRequestDone by remember { mutableStateOf(false) } + var isTyping by remember { mutableStateOf(false) } + + fun onSearch() { + onSearchClick(value) + searchActionDone = true + } + + fun clearFocusAndHideKeyboard() { + focusManager.clearFocus() + keyboardController?.hide() + } + + LaunchedEffect(isFieldFocused, menuItems.size) { + if (isFieldFocused) { + expandedState.targetState = menuItems.isNotEmpty() + } + } + + LaunchedEffect(dismissRequestDone) { + if (dismissRequestDone) { + delay(100) + if (searchActionDone || isTyping) { + searchActionDone = false + isTyping = false + } else { + expandedState.targetState = false + clearFocusAndHideKeyboard() + } + dismissRequestDone = false + } + } + + val status = when { + error.isNotEmpty() -> FieldStatus.ERROR + isFieldFocused -> FieldStatus.FOCUS + else -> FieldStatus.DEFAULT + } + + Box( + modifier = modifier + .fillMaxWidth(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FieldLabel(label) + + PotiBasicField( + value = value, + onValueChanged = { + isTyping = true + onValueChange(it) + }, + placeholder = placeholder, + modifier = Modifier + .fillMaxWidth() + .heightIn(52.dp) + .onGloballyPositioned { coordinates -> + parentWidth = coordinates.size.width + }, + onFocusChanged = { isFieldFocused = it }, + borderColor = status.borderColor, + backgroundColor = White, + imeAction = ImeAction.Search, + onSearchAction = { onSearch() }, + trailingIcon = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_search), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onSearch() }, + tint = PotiTheme.colors.gray700, + ) + }, + focusRequester = focusRequester, + ) + + // TODO: [도연] Display>errorMessage로 대체 + FieldErrorMessage(error) + } + + PotiDropdownMenu( + expandedState = expandedState, + onDismissRequest = { + dismissRequestDone = true + }, + scrollState = scrollState, + offset = offset, + shape = shape, + border = border, + maxHeight = searchType.maxHeight, + parentWidth = parentWidth, + ) { + itemsIndexed(menuItems) { index, item -> + PotiMenuItem( + option = item.option, + onClick = { + onItemClick(item) + expandedState.targetState = false + clearFocusAndHideKeyboard() + }, + isSelected = item.id in selectedIds, + modifier = Modifier.fillMaxWidth(), + price = item.price, + disabled = item.disabled, + showBottomBorder = index < menuItems.size, + ) + } + } + } +} + +enum class SearchType(val maxHeight: Dp) { + ARTIST(500.dp), + PRODUCT(156.dp), +} + +@Preview +@Composable +private fun PotiSearchFieldPreview() { + var text by remember { mutableStateOf("") } + val selectedIds = remember { mutableStateSetOf() } + val menuItems = remember { mutableStateListOf() } + + PotiTheme { + PotiSearchField( + value = text, + onValueChange = { + text = it + menuItems.clear() + if (text == "hih") { + menuItems.add(FieldMenuItem("hih")) + } else { + menuItems.clear() + } + }, + placeholder = "플레이스홀더", + onItemClick = { + if (it.id in selectedIds) { + selectedIds.remove(it.id) + text = "" + } else { + selectedIds.clear() + selectedIds.add(it.id) + text = it.option + } + }, + menuItems = menuItems, + selectedIds = selectedIds, + modifier = Modifier + .padding(horizontal = 20.dp) + .padding(top = 40.dp), + onSearchClick = { }, + searchType = SearchType.ARTIST, + error = "에러 메시지", + ) + } +} diff --git a/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiShortTextField.kt b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiShortTextField.kt new file mode 100644 index 00000000..68ee4789 --- /dev/null +++ b/app/src/main/java/com/poti/android/core/designsystem/component/field/PotiShortTextField.kt @@ -0,0 +1,124 @@ +package com.poti.android.core.designsystem.component.field + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.poti.android.core.designsystem.theme.PotiTheme + +/** + * Short 타입 텍스트 필드입니다. + * 최대 한 줄로 노출되며, 길게 작성된 경우 포커스 아웃 시 말줄임 처리 됩니다. + * + * @param value 필드 입력값입니다. + * @param onValueChanged 필드에 입력된 값을 전달합니다. + * @param placeholder 입력값이 없을 때 표시됩니다. + * @param modifier + * @param keyboardType 키보드 입력 타입으로, 기본값은 Text입니다. + * @param enabled 입력 및 포커스, 터치 이벤트 차단하고 싶다면 false로 설정합니다. 기본값 true입니다. + * @param label 필드 상단에 표시됩니다. + * @param error 에러가 emptyString이 아닌 경우에만 필드 하단에 표시되며, borderColor가 red로 변경됩니다. + * @param imeAction 키보드 액션 타입으로, 기본값은 Done 입니다. Next 설정 시 아래 위치한 필드로 포커스 이동시킬 수 있습니다. + * @param focusRequester 필드 포커스를 외부에서 제어하고 싶을 때 사용합니다. + */ +@Composable +fun PotiShortTextField( + value: String, + onValueChanged: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Text, + enabled: Boolean = true, + label: String = "", + error: String = "", + imeAction: ImeAction = ImeAction.Done, + focusRequester: FocusRequester? = null, +) { + var isFocused by remember { mutableStateOf(false) } + + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + val status = when { + error.isNotEmpty() -> FieldStatus.ERROR + isFocused -> FieldStatus.FOCUS + else -> FieldStatus.DEFAULT + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FieldLabel(label) + + PotiBasicField( + value = value, + onValueChanged = onValueChanged, + placeholder = placeholder, + borderColor = status.borderColor, + backgroundColor = PotiTheme.colors.white, + modifier = Modifier + .fillMaxWidth() + .heightIn(52.dp), + keyboardType = keyboardType, + enabled = enabled, + imeAction = imeAction, + onDoneAction = { + keyboardController?.hide() + focusManager.clearFocus() + }, + onNextAction = { + focusManager.moveFocus(FocusDirection.Down) + }, + onFocusChanged = { isFocused = it }, + focusRequester = focusRequester, + singleLine = true, + ) + + FieldErrorMessage(error) + } +} + +@Preview +@Composable +private fun PotiShortTextFieldWithErrorPreview() { + var text by remember { mutableStateOf("") } + + PotiTheme { + PotiShortTextField( + value = text, + onValueChanged = { text = it }, + placeholder = "플레이스홀더", + error = text, + ) + } +} + +@Preview +@Composable +private fun PotiShortTextFieldWithLabelPreview() { + var text by remember { mutableStateOf("") } + + PotiTheme { + PotiShortTextField( + value = text, + onValueChanged = { text = it }, + placeholder = "플레이스홀더", + label = "라벨", + ) + } +} diff --git a/app/src/main/java/com/poti/android/core/designsystem/model/FieldMenuItem.kt b/app/src/main/java/com/poti/android/core/designsystem/model/FieldMenuItem.kt new file mode 100644 index 00000000..5bc6c6ab --- /dev/null +++ b/app/src/main/java/com/poti/android/core/designsystem/model/FieldMenuItem.kt @@ -0,0 +1,10 @@ +package com.poti.android.core.designsystem.model + +import java.util.UUID + +data class FieldMenuItem( + val option: String, + val price: String? = null, + val disabled: Boolean = false, + val id: String = UUID.randomUUID().toString(), +)