From f4bac27290c4c0aef5bfe1393b27c6d7372c1748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A7=84=EC=9A=B0?= <85734140+jinuemong@users.noreply.github.com> Date: Sat, 3 Feb 2024 16:56:06 +0900 Subject: [PATCH] =?UTF-8?q?[Setting]:=20=EA=B8=88=EC=95=A1=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Feat]: 금액 입력 텍스트 필드 구현 * [fix]: QA 후 수정 --- .../expansion/NumberCommaTransformation.kt | 33 +++ .../view/textfield/PriceChipComponent.kt | 95 ++++++++ .../common/view/textfield/TypingPriceField.kt | 224 ++++++++++++++++++ .../main/res/drawable/ic_essential_field.xml | 9 + 4 files changed, 361 insertions(+) create mode 100644 presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/util/expansion/NumberCommaTransformation.kt create mode 100644 presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/view/textfield/PriceChipComponent.kt create mode 100644 presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/view/textfield/TypingPriceField.kt create mode 100644 presentation/src/main/res/drawable/ic_essential_field.xml diff --git a/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/util/expansion/NumberCommaTransformation.kt b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/util/expansion/NumberCommaTransformation.kt new file mode 100644 index 00000000..64b539b4 --- /dev/null +++ b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/util/expansion/NumberCommaTransformation.kt @@ -0,0 +1,33 @@ +package ac.dnd.bookkeeping.android.presentation.common.util.expansion + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import java.text.NumberFormat +import java.util.Locale + +class NumberCommaTransformation : VisualTransformation { + + private fun Long?.formatWithComma(): String = + NumberFormat.getNumberInstance(Locale.US).format(this ?: 0) + + override fun filter(text: AnnotatedString): TransformedText { + val newText = AnnotatedString( + text = if (text.text.isEmpty()) "" else text.text.toLongOrNull().formatWithComma() + "원" + ) + return TransformedText( + text = newText, + offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + return if (offset != newText.length) newText.length else text.length + + } + + override fun transformedToOriginal(offset: Int): Int { + return text.length + } + } + ) + } +} diff --git a/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/view/textfield/PriceChipComponent.kt b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/view/textfield/PriceChipComponent.kt new file mode 100644 index 00000000..b6815794 --- /dev/null +++ b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/view/textfield/PriceChipComponent.kt @@ -0,0 +1,95 @@ +package ac.dnd.bookkeeping.android.presentation.common.view.textfield + +import ac.dnd.bookkeeping.android.presentation.R +import ac.dnd.bookkeeping.android.presentation.common.theme.Body1 +import ac.dnd.bookkeeping.android.presentation.common.theme.Gray150 +import ac.dnd.bookkeeping.android.presentation.common.theme.Gray300 +import ac.dnd.bookkeeping.android.presentation.common.theme.Gray700 +import ac.dnd.bookkeeping.android.presentation.common.theme.Shapes +import ac.dnd.bookkeeping.android.presentation.common.theme.Space16 +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun PriceChipComponent( + scope: CoroutineScope, + chipPressColor: Color = Gray300, + chipUnPressColor: Color = Gray150, + chipContentColor: Color = Gray700, + chipList: Map = mapOf( + "1만" to 10_000, + "5만" to 50_000, + "10만" to 100_000, + "50만" to 500_000 + ), + onClickChip: (Long) -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + chipList.forEach { (text, money) -> + var isChipClick by remember { mutableStateOf(false) } + val backgroundColorState = animateColorAsState( + targetValue = if (isChipClick) chipPressColor else chipUnPressColor, + label = "chip press state color" + ) + Row( + modifier = Modifier + .background( + color = backgroundColorState.value, + shape = Shapes.medium + ) + .clickable { + onClickChip(money) + scope.launch { + isChipClick = true + Thread.sleep(100L) + isChipClick = false + } + } + .padding( + horizontal = 8.dp, + vertical = 6.5.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.ic_plus), + contentDescription = null, + modifier = Modifier.size(Space16) + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = text, + style = Body1.merge( + color = chipContentColor, + fontWeight = FontWeight.SemiBold + ) + ) + } + } + } +} diff --git a/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/view/textfield/TypingPriceField.kt b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/view/textfield/TypingPriceField.kt new file mode 100644 index 00000000..ab98ea26 --- /dev/null +++ b/presentation/src/main/kotlin/ac/dnd/bookkeeping/android/presentation/common/view/textfield/TypingPriceField.kt @@ -0,0 +1,224 @@ +package ac.dnd.bookkeeping.android.presentation.common.view.textfield + +import ac.dnd.bookkeeping.android.presentation.R +import ac.dnd.bookkeeping.android.presentation.common.theme.Body1 +import ac.dnd.bookkeeping.android.presentation.common.theme.Gray500 +import ac.dnd.bookkeeping.android.presentation.common.theme.Gray700 +import ac.dnd.bookkeeping.android.presentation.common.theme.Gray800 +import ac.dnd.bookkeeping.android.presentation.common.theme.Headline3 +import ac.dnd.bookkeeping.android.presentation.common.theme.Negative +import ac.dnd.bookkeeping.android.presentation.common.theme.Primary3 +import ac.dnd.bookkeeping.android.presentation.common.util.expansion.NumberCommaTransformation +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun TypingPriceField( + modifier: Modifier = Modifier, + textValue: String, + onValueChange: (String) -> Unit, + hintText: String = "지출하신 금액을 입력해주세요", + isError: Boolean = false, + isEnabled: Boolean = true, + innerPadding: PaddingValues = PaddingValues(0.dp), + textFieldHeight: Dp = 35.dp, + keyboardOptions: KeyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + textFormat : VisualTransformation = NumberCommaTransformation(), + fieldSubjectContent: (@Composable () -> Unit) = { FieldSubject() }, + leadingIconContent: (@Composable () -> Unit)? = null, + trailingIconContent: (@Composable () -> Unit)? = null, + errorMessageContent: (@Composable () -> Unit) = { }, +) { + val interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } + var isTextFieldFocused by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val currentColor = if (isError) Negative else if (isTextFieldFocused) Primary3 else Gray500 + val currentColorState = animateColorAsState( + targetValue = currentColor, + label = "color state" + ) + + fun makeFilterText(text: String): String = text.filter { it.isDigit() } + + fun addPriceFormat(addPrice: Long): String { + val prevPrice = if (textValue.isEmpty()) 0L else makeFilterText(textValue).toLong() + return (addPrice + prevPrice).toString() + } + + Column( + modifier = modifier + .background(color = Color.Transparent) + .onFocusChanged { + isTextFieldFocused = it.isFocused + } + ) { + fieldSubjectContent() + BasicTextField( + value = TextFieldValue( + text = textValue, + selection = TextRange(textValue.length) + ), + onValueChange = { + val newText = makeFilterText(it.text) + onValueChange(newText) + }, + enabled = isEnabled, + modifier = Modifier + .fillMaxWidth() + .height(textFieldHeight), + textStyle = Headline3.merge( + color = Gray800, + fontWeight = FontWeight.SemiBold + ), + singleLine = true, + keyboardOptions = keyboardOptions, + cursorBrush = SolidColor(value = currentColorState.value), + visualTransformation = textFormat, + interactionSource = interactionSource + ) { textField -> + TextFieldDefaults.TextFieldDecorationBox( + value = TextFieldValue( + text = textValue, + selection = TextRange(textValue.length) + ).text, + innerTextField = textField, + enabled = isEnabled, + singleLine = true, + placeholder = { + Text( + text = hintText, + style = Headline3.merge( + color = Gray500, + fontWeight = FontWeight.SemiBold + ) + ) + }, + visualTransformation = textFormat, + interactionSource = interactionSource, + leadingIcon = leadingIconContent, + trailingIcon = trailingIconContent, + contentPadding = innerPadding + ) + } + Divider( + modifier = Modifier + .fillMaxWidth() + .height(1.dp), + color = currentColorState.value + ) + Spacer(modifier = Modifier.height(6.dp)) + PriceChipComponent( + scope = scope, + onClickChip = { addPrice -> + val newText = addPriceFormat(addPrice) + onValueChange(newText) + } + ) + errorMessageContent() + } + +} + +@Composable +private fun FieldSubject() { + Column { + Row { + Text( + text = "금액", + style = Body1.merge( + color = Gray700, + fontWeight = FontWeight.SemiBold + ) + ) + Spacer(modifier = Modifier.width(1.dp)) + Box( + modifier = Modifier + .height(21.dp) + .padding(bottom = 3.dp), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.ic_essential_field), + contentDescription = null + ) + } + } + Spacer( + modifier = Modifier.height(18.dp) + ) + } +} + +@Composable +@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true) +fun EnterMoneyField1Preivew() { + TypingPriceField( + textValue = "1111", + modifier = Modifier.fillMaxWidth(), + onValueChange = {}, + ) +} + +@Composable +@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true) +fun EnterMoneyField2Preivew() { + TypingPriceField( + textValue = "", + modifier = Modifier.fillMaxWidth(), + onValueChange = {}, + ) +} + + +@Composable +@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true) +fun EnterMoneyField3Preview() { + var input by remember { mutableStateOf("10000000") } + TypingPriceField( + textValue = input, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp), + onValueChange = { + input = it + }, + ) +} diff --git a/presentation/src/main/res/drawable/ic_essential_field.xml b/presentation/src/main/res/drawable/ic_essential_field.xml new file mode 100644 index 00000000..6b2ded75 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_essential_field.xml @@ -0,0 +1,9 @@ + + +