diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssBottomBar.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssBottomBar.kt index d1772b32..87d3af55 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssBottomBar.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssBottomBar.kt @@ -1,38 +1,35 @@ package com.gamss.android.core.designsystem.component -import androidx.compose.foundation.Image +import androidx.compose.foundation.background 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.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.selectable import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp -import com.gamss.android.core.designsystem.R import com.gamss.android.core.designsystem.theme.GamssTheme -// 손그림 테두리는 path 조각이 500여 개라 VectorDrawable 로 넣으면 aapt 문자열 한도를 넘겨 STRING_TOO_LARGE 로 잘린다. 배경만 3배율 PNG 로 둔다. -// 높이는 디자인 62dp 에 에셋의 stroke 여백 2dp 를 더한 값. -private val BottomBarHeight = 64.dp -private val BottomBarHorizontalMargin = 20.dp -private val BottomBarBottomMargin = 8.dp -private val ItemRowHorizontalPadding = 53.dp -private val ItemIconSize = 24.dp +private val BottomBarHeight = 66.dp +private val ItemRowWidth = 255.dp +private val ItemRowHeight = 44.dp +private val ItemIconSize = 26.dp private val ItemLabelGap = 4.dp +private val BottomBarStrokeWidth = 1.dp /** 화면이 edge-to-edge 라 시스템 내비게이션 영역은 이 컴포넌트가 직접 피한다. */ @Composable @@ -42,23 +39,24 @@ fun GamssBottomBar( onItemClick: (T) -> Unit, modifier: Modifier = Modifier, ) { + val strokeColor = GamssTheme.colors.gray200 Box( modifier = modifier .fillMaxWidth() + // 배경과 구분선을 인셋보다 바깥에 둬야 내비게이션 영역까지 흰색이 깔린다. + .background(GamssTheme.colors.white) + .drawBehind { + // 화면 폭을 꽉 채우는 바라 좌우·아래 선은 화면 테두리에 붙어 버린다. 위쪽만 긋는다. + drawRect(color = strokeColor, size = Size(size.width, BottomBarStrokeWidth.toPx())) + } .navigationBarsPadding() - .padding(horizontal = BottomBarHorizontalMargin, vertical = BottomBarBottomMargin) .height(BottomBarHeight), ) { - Image( - painter = painterResource(R.drawable.bg_tab_bar), - contentDescription = null, - contentScale = ContentScale.FillBounds, - modifier = Modifier.fillMaxSize(), - ) Row( modifier = Modifier - .fillMaxSize() - .padding(horizontal = ItemRowHorizontalPadding), + .width(ItemRowWidth) + .height(ItemRowHeight) + .align(Alignment.Center), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -80,7 +78,7 @@ private fun BottomBarItem( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - val contentColor = if (selected) GamssTheme.colors.gray900 else GamssTheme.colors.gray300 + val contentColor = if (selected) GamssTheme.colors.gray900 else GamssTheme.colors.gray400 val interactionSource = remember { MutableInteractionSource() } Column( diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssCharacterPicker.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssCharacterPicker.kt index 6b32c123..4601df81 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssCharacterPicker.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssCharacterPicker.kt @@ -1,8 +1,6 @@ package com.gamss.android.core.designsystem.component import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -12,16 +10,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp -import com.gamss.android.core.designsystem.theme.GamssRadius +import com.gamss.android.core.designsystem.R import com.gamss.android.core.designsystem.theme.GamssTheme // Figma 노드 3331:3950 의 auto layout 값. 패널 211x94(hug), 패딩 20, 3x2 그리드, 간격 18. @@ -35,7 +31,6 @@ private val ItemWidth = 45.dp private val ItemMinHeight = 18.dp private val IndicatorSize = 18.dp private val IndicatorLabelGap = 2.dp -private val PanelBorderWidth = 1.dp private const val COLUMNS = 3 /** 반응할 캐릭터를 고르는 패널. 한 줄에 [COLUMNS] 개씩 끊어 놓는다. */ @@ -47,9 +42,8 @@ fun GamssCharacterPicker( ) { Column( modifier = modifier - .clip(RoundedCornerShape(GamssRadius.radius050)) - .background(GamssTheme.colors.white) - .border(PanelBorderWidth, GamssTheme.colors.gray200, RoundedCornerShape(GamssRadius.radius050)) + // 입력창과 같은 손그림 프레임을 쓴다. + .gamssSketchyBox(R.drawable.bg_expanding_input_box) .padding(PickerPadding), verticalArrangement = Arrangement.spacedBy(PickerGap), ) { diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssComponentPreviews.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssComponentPreviews.kt index cb75fae1..732d7b05 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssComponentPreviews.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssComponentPreviews.kt @@ -29,16 +29,16 @@ private fun GamssTopBarPreview() { } } -@Preview(name = "InputBar - Empty", showBackground = true, widthDp = 402) +@Preview(name = "ExpandingInputBar - Empty", showBackground = true, widthDp = 402) @Composable @Suppress("UnusedPrivateMember") -private fun GamssInputBarEmptyPreview() { +private fun GamssExpandingInputBarEmptyPreview() { GamssTheme { Box(modifier = Modifier.padding(20.dp)) { - GamssInputBar( + GamssExpandingInputBar( value = "", onValueChange = {}, - onTrailingClick = {}, + onSend = {}, placeholder = "무슨 이야기를 버려볼까요?", modifier = Modifier.fillMaxWidth(), ) @@ -46,16 +46,16 @@ private fun GamssInputBarEmptyPreview() { } } -@Preview(name = "InputBar - Filled", showBackground = true, widthDp = 402) +@Preview(name = "ExpandingInputBar - Filled", showBackground = true, widthDp = 402) @Composable @Suppress("UnusedPrivateMember") -private fun GamssInputBarFilledPreview() { +private fun GamssExpandingInputBarFilledPreview() { GamssTheme { Box(modifier = Modifier.padding(20.dp)) { - GamssInputBar( + GamssExpandingInputBar( value = "오늘 발표가 너무 떨려요", onValueChange = {}, - onTrailingClick = {}, + onSend = {}, modifier = Modifier.fillMaxWidth(), ) } diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssDisclosureToggle.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssDisclosureToggle.kt new file mode 100644 index 00000000..f1f37381 --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssDisclosureToggle.kt @@ -0,0 +1,67 @@ +package com.gamss.android.core.designsystem.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +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.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.modifier.noRippleClickable +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.core.designsystem.theme.GamssTouchTarget + +private val LabelChevronGap = 4.dp +private val ChevronSize = 16.dp +private const val CHEVRON_ROTATION = 90f + +// ic_right_chevron 글리프는 24 뷰포트 안에서 치우쳐 있다. 박스 중심으로 돌리면 펼칠 때 위아래로 튄다. +private const val CHEVRON_CENTER_X = 16.85f / 24f +private const val CHEVRON_CENTER_Y = 12.03f / 24f + +/** 아래위 셰브론 에셋이 없어 오른쪽 셰브론을 돌려 쓴다. */ +@Composable +fun GamssDisclosureToggle( + label: String, + expanded: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentColor: Color = GamssTheme.colors.gray900, +) { + // 잠긴 상태의 회색은 같은 입력바의 placeholder 와 맞춘다. 다크에서도 반전되지 않는 중간 회색이다. + val resolvedColor = if (enabled) contentColor else GamssTheme.colors.gray500 + + Row( + modifier = modifier + .heightIn(min = GamssTouchTarget.minimum) + .noRippleClickable(enabled = enabled, role = Role.DropdownList, onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(LabelChevronGap), + ) { + GamssText( + text = label, + style = GamssTheme.typography.body4Medium, + color = resolvedColor, + maxLines = 1, + ) + Icon( + painter = painterResource(GamssIcons.RightChevron), + contentDescription = null, + tint = resolvedColor, + modifier = Modifier + .size(ChevronSize) + .graphicsLayer { + rotationZ = if (expanded) -CHEVRON_ROTATION else CHEVRON_ROTATION + transformOrigin = TransformOrigin(CHEVRON_CENTER_X, CHEVRON_CENTER_Y) + }, + ) + } +} diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssExpandingInputBar.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssExpandingInputBar.kt new file mode 100644 index 00000000..d6dee809 --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssExpandingInputBar.kt @@ -0,0 +1,210 @@ +package com.gamss.android.core.designsystem.component + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.R +import com.gamss.android.core.designsystem.modifier.noRippleClickable +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.core.designsystem.theme.GamssTouchTarget + +// Figma default 4001:8200, focus 3489:4923. +private val CollapsedInputBarHeight = 112.dp +private val ExpandedInputBarHeight = 150.dp +private val InputBarTopPadding = 16.dp +private val InputBarBottomPadding = 16.dp +private val ControlsRowHeight = 32.dp + +private val InputBarHorizontalPadding = 20.dp + +private val SendButtonSize = 32.dp + +private val ControlsTouchOverhang = (GamssTouchTarget.minimum - ControlsRowHeight) / 2 + +// 140자 상한을 이 폭에서 담으려면 6줄이면 넉넉하다. 넘치면 입력칸 안에서 스크롤된다. +private const val INPUT_MAX_LINES = 6 + +private const val HEIGHT_ANIMATION_DURATION_MS = 350 + +/** + * 포커스에 따라 높이가 늘어나는 손그림 입력창. 홈이 쓴다. + * 평면 테두리에 답장 미리보기를 얹는 채팅 입력창은 [GamssInputBar] 쪽이다. + * + * Row/Column 을 갈아 끼우면 BasicTextField 가 다시 만들어져 포커스를 잡자마자 키보드가 닫힌다. + */ +@Composable +fun GamssExpandingInputBar( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + modifier: Modifier = Modifier, + placeholder: String = "", + sendContentDescription: String? = null, + enabled: Boolean = true, + beforeSendSlot: @Composable (() -> Unit)? = null, +) { + val canSubmit = enabled && value.isNotBlank() + var isExpanded by remember { mutableStateOf(false) } + + val baseHeight = if (isExpanded) ExpandedInputBarHeight else CollapsedInputBarHeight + // 포커스로 바뀌는 기준 높이만 애니메이션한다. 늘어난 줄까지 태우면 방금 친 줄이 끝날 때까지 잘려 보인다. + val animatedBaseHeight by animateDpAsState( + targetValue = baseHeight, + animationSpec = tween(durationMillis = HEIGHT_ANIMATION_DURATION_MS), + label = "GamssExpandingInputBarHeight", + ) + + Layout( + // 두 상태의 손그림이 별개로 그려져 있어 아트도 함께 갈아 끼운다. + modifier = modifier.gamssSketchyBox( + artRes = if (isExpanded) R.drawable.bg_expanding_input_box_focus else R.drawable.bg_expanding_input_box, + ), + content = { + InputTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + placeholder = placeholder, + canSubmit = canSubmit, + onSubmit = onSend, + onFocusChanged = { isExpanded = it }, + ) + ControlsRow( + canSubmit = canSubmit, + sendContentDescription = sendContentDescription, + onSend = onSend, + beforeSendSlot = beforeSendSlot, + ) + }, + ) { measurables, constraints -> + val (textFieldMeasurable, controlsMeasurable) = measurables + val sidePad = InputBarHorizontalPadding.roundToPx() + // 폭이 열려 있는 부모(가로 스크롤 등) 아래에서는 maxWidth 가 Infinity 라 그대로 쓰면 터진다. + val barWidth = constraints.constrainWidth( + if (constraints.hasBoundedWidth) constraints.maxWidth else constraints.minWidth, + ) + val availableWidth = (barWidth - sidePad * 2).coerceAtLeast(0) + val topPad = InputBarTopPadding.roundToPx() + val bottomPad = InputBarBottomPadding.roundToPx() + val controlsHeight = ControlsRowHeight.roundToPx() + + // 기준 높이는 텍스트 자리의 최소값으로만 쓴다. 줄이 넘치면 텍스트가 재는 만큼 바가 자란다. + val minTextHeight = + (animatedBaseHeight.roundToPx() - topPad - bottomPad - controlsHeight).coerceAtLeast(0) + val text = textFieldMeasurable.measure( + Constraints(minWidth = availableWidth, maxWidth = availableWidth, minHeight = minTextHeight), + ) + val overhang = ControlsTouchOverhang.roundToPx() + val controls = controlsMeasurable.measure( + Constraints.fixed(availableWidth + overhang, GamssTouchTarget.minimum.roundToPx()), + ) + + val barHeight = constraints.constrainHeight(topPad + text.height + controlsHeight + bottomPad) + + layout(barWidth, barHeight) { + text.place(sidePad, topPad) + controls.place(sidePad, barHeight - bottomPad - controlsHeight - overhang) + } + } +} + +@Composable +private fun InputTextField( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + placeholder: String, + canSubmit: Boolean, + onSubmit: () -> Unit, + onFocusChanged: (Boolean) -> Unit, +) { + val textStyle = GamssTheme.typography.body4Medium + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.onFocusChanged { onFocusChanged(it.isFocused) }, + enabled = enabled, + singleLine = false, + minLines = 1, + maxLines = INPUT_MAX_LINES, + // 입력바 SVG는 라이트 배경이므로 시스템 다크 모드와 무관하게 전경을 검정으로 고정한다. + textStyle = textStyle.copy(color = GamssTheme.colors.black), + cursorBrush = SolidColor(GamssTheme.colors.black), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { if (canSubmit) onSubmit() }), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.TopStart, + ) { + if (value.isEmpty()) { + GamssText( + text = placeholder, + style = textStyle, + color = GamssTheme.colors.gray500, + ) + } + innerTextField() + } + }, + ) +} + +@Composable +private fun ControlsRow( + canSubmit: Boolean, + sendContentDescription: String?, + onSend: () -> Unit, + beforeSendSlot: @Composable (() -> Unit)?, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + beforeSendSlot?.invoke() + Spacer(modifier = Modifier.weight(1f)) + SendButton(canSubmit = canSubmit, contentDescription = sendContentDescription, onClick = onSend) + } +} + +@Composable +private fun SendButton( + canSubmit: Boolean, + contentDescription: String?, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .size(GamssTouchTarget.minimum) + .noRippleClickable(enabled = canSubmit, role = Role.Button, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(if (canSubmit) GamssIcons.SendButtonOn else GamssIcons.SendButtonOff), + contentDescription = contentDescription, + modifier = Modifier.size(SendButtonSize), + ) + } +} diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssSketchyBox.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssSketchyBox.kt new file mode 100644 index 00000000..e6f03f01 --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssSketchyBox.kt @@ -0,0 +1,75 @@ +package com.gamss.android.core.designsystem.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.isUnspecified +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.theme.GamssTheme + +private val ArtCorner = 12.dp + +// 아트의 모서리가 둥글어, 배경을 직각으로 칠하면 흰 모서리가 선 밖으로 삐져나온다. +private val BackgroundCorner = 4.dp + +private val NineSliceCells = (0..2).flatMap { col -> (0..2).map { row -> col to row } } - (1 to 1) + +/** + * 손그림 테두리 아트를 9-slice 로 깐다. 모서리는 원본 크기로 두고 직선 구간만 늘려, + * 어떤 크기에서도 선 굵기가 변하지 않는다. + * + * 아트는 호출부가 명시한다. 기본값을 두면 다른 기능이 같은 drawable 을 손볼 때 + * 무엇이 함께 바뀌는지 드러나지 않는다. 흰 배경은 여기서 칠하므로 아트에는 없어야 한다. + */ +@Composable +fun Modifier.gamssSketchyBox( + @DrawableRes artRes: Int, + background: Color = GamssTheme.colors.white, +): Modifier { + val art = painterResource(artRes) + return this + .background(background, RoundedCornerShape(BackgroundCorner)) + .drawBehind { drawNineSlice(art) } +} + +private fun DrawScope.drawNineSlice(art: Painter) { + val artSize = art.intrinsicSize + if (artSize.isUnspecified) return + val corner = ArtCorner.toPx() + + val srcX = floatArrayOf(0f, corner, artSize.width - corner, artSize.width) + val srcY = floatArrayOf(0f, corner, artSize.height - corner, artSize.height) + val dstX = floatArrayOf(0f, corner, size.width - corner, size.width) + val dstY = floatArrayOf(0f, corner, size.height - corner, size.height) + + NineSliceCells.forEach { (col, row) -> + val scaleX = sliceScale(srcX, dstX, col) + val scaleY = sliceScale(srcY, dstY, row) + if (scaleX <= 0f || scaleY <= 0f) return@forEach + + clipRect(dstX[col], dstY[row], dstX[col + 1], dstY[row + 1]) { + translate(dstX[col] - srcX[col] * scaleX, dstY[row] - srcY[row] * scaleY) { + scale(scaleX, scaleY, pivot = Offset.Zero) { + with(art) { draw(artSize) } + } + } + } + } +} + +private fun sliceScale(src: FloatArray, dst: FloatArray, index: Int): Float { + val srcLength = src[index + 1] - src[index] + val dstLength = dst[index + 1] - dst[index] + return if (srcLength <= 0f || dstLength <= 0f) 0f else dstLength / srcLength +} diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/Clickable.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/Clickable.kt index 98e4b57b..35f4f9ab 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/Clickable.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/Clickable.kt @@ -3,6 +3,20 @@ package com.gamss.android.core.designsystem.modifier import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role + +/** 기본 indication 을 두면 손그림 배경 위에 회색 사각형이 덧그려집니다. */ +fun Modifier.noRippleClickable( + enabled: Boolean = true, + role: Role? = null, + onClick: () -> Unit, +): Modifier = clickable( + enabled = enabled, + role = role, + onClick = onClick, + indication = null, + interactionSource = null, +) /** * [onClick]이 null이 아닐 때만, ripple 없이 [Modifier.clickable]을 적용합니다. diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/theme/GamssTouchTarget.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/theme/GamssTouchTarget.kt new file mode 100644 index 00000000..6ab45aa6 --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/theme/GamssTouchTarget.kt @@ -0,0 +1,8 @@ +package com.gamss.android.core.designsystem.theme + +import androidx.compose.ui.unit.dp + +/** 시안이 이보다 작게 그려져 있어도 터치 영역은 [minimum] 아래로 내려가지 않게 한다. */ +object GamssTouchTarget { + val minimum = 48.dp +} diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/theme/Theme.kt index e45f6519..78806016 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/theme/Theme.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/theme/Theme.kt @@ -64,6 +64,9 @@ object GamssTheme { val radius: GamssRadius get() = GamssRadius + val touchTarget: GamssTouchTarget + get() = GamssTouchTarget + val sticker: GamssStickerColors get() = GamssStickerColors } diff --git a/core/designsystem/src/main/res/drawable-nodpi/bg_tab_bar.png b/core/designsystem/src/main/res/drawable-nodpi/bg_tab_bar.png deleted file mode 100644 index e30d3a0a..00000000 Binary files a/core/designsystem/src/main/res/drawable-nodpi/bg_tab_bar.png and /dev/null differ diff --git a/core/designsystem/src/main/res/drawable/bg_expanding_input_box.xml b/core/designsystem/src/main/res/drawable/bg_expanding_input_box.xml new file mode 100644 index 00000000..f01910e7 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/bg_expanding_input_box.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/bg_expanding_input_box_focus.xml b/core/designsystem/src/main/res/drawable/bg_expanding_input_box_focus.xml new file mode 100644 index 00000000..93333ed0 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/bg_expanding_input_box_focus.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt index 26f9aaed..a8cf23f7 100644 --- a/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt +++ b/feature/archive/src/main/java/com/gamss/android/feature/archive/component/CardDetailDialog.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.window.DialogProperties import com.gamss.android.core.designsystem.card.GamssEmotionCard import com.gamss.android.core.designsystem.card.GamssEmotionCardCharacter import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.core.designsystem.theme.GamssTouchTarget import com.gamss.android.core.ui.card.cardTitleRes import com.gamss.android.core.ui.card.toGamssEmotionCardCharacter import com.gamss.android.domain.card.Card @@ -77,7 +78,7 @@ private fun CardCloseButton(onClick: () -> Unit) { Box( modifier = Modifier .offset(x = CloseButtonCenteringInset, y = -CloseButtonCenteringInset) - .size(CloseButtonTouchSize) + .size(GamssTouchTarget.minimum) .clickable(role = Role.Button, onClick = onClick), contentAlignment = Alignment.Center, ) { @@ -92,8 +93,7 @@ private fun CardCloseButton(onClick: () -> Unit) { /** Figma 우상단 닫기 아이콘 크기. */ private val CloseIconSize = 20.dp -private val CloseButtonTouchSize = 48.dp -private val CloseButtonCenteringInset = (CloseButtonTouchSize - CloseIconSize) / 2 +private val CloseButtonCenteringInset = (GamssTouchTarget.minimum - CloseIconSize) / 2 private val CardDetailDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yy.MM.dd") diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index ee3693f0..e207d45a 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -10,6 +10,9 @@ dependencies { implementation(projects.core.common) implementation(projects.domain) + // 캐릭터 패널을 뒤로가기로 닫기 위한 BackHandler. compose-ui 에는 없는 API 다. + implementation(libs.androidx.activity.compose) + testImplementation(libs.mockk) // orbit-test 가 전이로 가져오지만, 직접 쓰는 API 라 명시한다. diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt index 022aa648..bc9d57e9 100644 --- a/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeScreen.kt @@ -1,7 +1,8 @@ package com.gamss.android.feature.home import android.widget.Toast -import androidx.compose.foundation.clickable +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -9,39 +10,43 @@ 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.WindowInsets +import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf 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.alpha -import androidx.compose.ui.graphics.TransformOrigin -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup import androidx.hilt.navigation.compose.hiltViewModel import com.gamss.android.core.designsystem.component.GamssCharacterPicker import com.gamss.android.core.designsystem.component.GamssCharacterPickerItem +import com.gamss.android.core.designsystem.component.GamssDisclosureToggle +import com.gamss.android.core.designsystem.component.GamssExpandingInputBar import com.gamss.android.core.designsystem.component.GamssIconButton import com.gamss.android.core.designsystem.component.GamssIcons -import com.gamss.android.core.designsystem.component.GamssInputBar -import com.gamss.android.core.designsystem.component.GamssInputBarHeight import com.gamss.android.core.designsystem.component.GamssLogo import com.gamss.android.core.designsystem.component.GamssMarkerHighlight import com.gamss.android.core.designsystem.component.GamssPaperSlip @@ -53,6 +58,7 @@ import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.domain.emotion.EmotionCharacter import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect +import kotlin.math.roundToInt @Composable fun HomeScreen( @@ -79,10 +85,14 @@ fun HomeScreen( onInputChange = viewModel::onInputChange, onSubmit = viewModel::onSubmit, onEmotionPickerToggle = viewModel::onEmotionPickerToggle, + onEmotionPickerDismiss = viewModel::onEmotionPickerDismiss, onEmotionToggle = viewModel::onEmotionToggle, ) } + // BackHandler 는 프리뷰에서 터지므로 HomeContent 밖에 둔다. + BackHandler(enabled = state.isEmotionPickerExpanded, onBack = actions.onEmotionPickerDismiss) + HomeContent(state = state, actions = actions, modifier = modifier) } @@ -92,6 +102,7 @@ private data class HomeActions( val onInputChange: (String) -> Unit, val onSubmit: () -> Unit, val onEmotionPickerToggle: () -> Unit, + val onEmotionPickerDismiss: () -> Unit, val onEmotionToggle: (EmotionCharacter) -> Unit, ) @@ -101,9 +112,24 @@ private fun HomeContent( actions: HomeActions, modifier: Modifier = Modifier, ) { - val pickerItems = remember(state.selectedCharacters) { state.selectedCharacters.toPickerItems() } + // 피커는 흐름 밖에서 입력바 아래에 겹쳐 놓는다. 두 기준점은 창 좌표로 재고 루트 기준으로 환산한다. + var rootLeftInWindow by remember { mutableFloatStateOf(0f) } + var rootTopInWindow by remember { mutableFloatStateOf(0f) } + var toggleLeftInWindow by remember { mutableFloatStateOf(0f) } + var inputBarBottomInWindow by remember { mutableFloatStateOf(0f) } - Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = modifier + .fillMaxSize() + // 상위 Scaffold 가 내비게이션 바를 이미 소비했다. 남은 키보드 높이만 피한다. + .windowInsetsPadding(WindowInsets.ime.exclude(WindowInsets.navigationBars)) + .dismissOnTapOutside(actions.onEmotionPickerDismiss) + .onGloballyPositioned { + val bounds = it.boundsInWindow() + rootLeftInWindow = bounds.left + rootTopInWindow = bounds.top + }, + ) { HomeDecorations() Column(modifier = Modifier.fillMaxSize()) { @@ -131,79 +157,103 @@ private fun HomeContent( Spacer(modifier = Modifier.height(GreetingToInputGap)) - // 패널은 Popup 으로 띄운다. 흐름에 넣으면 펼칠 때마다 인사말과 입력바가 위로 밀린다. - Box(modifier = Modifier.fillMaxWidth().padding(horizontal = InputBarHorizontalPadding)) { - GamssInputBar( - value = state.input, - onValueChange = actions.onInputChange, - onTrailingClick = actions.onSubmit, - placeholder = stringResource(R.string.home_input_placeholder), - trailingContentDescription = stringResource(R.string.home_input_submit_description), - enabled = !state.isSending, - trailingAction = { - CharacterPickerToggle( - expanded = state.isEmotionPickerExpanded, - onClick = actions.onEmotionPickerToggle, - ) - }, - modifier = Modifier.fillMaxWidth(), - ) - - if (state.isEmotionPickerExpanded) { - val pickerOffset = with(LocalDensity.current) { - IntOffset(x = 0, y = (GamssInputBarHeight + PickerTopGap).roundToPx()) - } - Popup( - alignment = Alignment.TopEnd, - offset = pickerOffset, - onDismissRequest = actions.onEmotionPickerToggle, - ) { - GamssCharacterPicker( - items = pickerItems, - onToggle = actions.onEmotionToggle, - ) - } - } - } + HomeInputSection( + state = state, + actions = actions, + onToggleLeftChange = { toggleLeftInWindow = it }, + onInputBarBottomChange = { inputBarBottomInWindow = it }, + ) Spacer(modifier = Modifier.weight(GREETING_BOTTOM_WEIGHT)) } + + // 같은 화면 안에 얹어야 탭 전환 페이드에 함께 사라진다. Popup 은 별도 창이라 나가는 화면의 + // 알파를 받지 않아, 전환이 끝날 때까지 새 탭 위에 불투명하게 남는다. + // 흐름이 아니라 루트 Box 의 마지막 자식이므로 인사말과 입력바를 밀지 않는다. + if (state.isEmotionPickerExpanded) { + EmotionPickerOverlay( + selectedCharacters = state.selectedCharacters, + onToggle = actions.onEmotionToggle, + leftInWindow = toggleLeftInWindow, + topInWindow = inputBarBottomInWindow, + rootLeftInWindow = rootLeftInWindow, + rootTopInWindow = rootTopInWindow, + ) + } } } @Composable -private fun CharacterPickerToggle( - expanded: Boolean, - onClick: () -> Unit, +private fun EmotionPickerOverlay( + selectedCharacters: Set, + onToggle: (EmotionCharacter) -> Unit, + leftInWindow: Float, + topInWindow: Float, + rootLeftInWindow: Float, + rootTopInWindow: Float, ) { - Row( - // fillMaxHeight 를 쓰면 안 된다. 입력바 높이가 내용 기반이라 자식이 최대 제약을 먹고 화면을 채운다. - // 터치 영역은 세로 여백으로 벌린다. + val items = remember(selectedCharacters) { selectedCharacters.toPickerItems() } + val overlapPx = with(LocalDensity.current) { PickerOverlapHeight.roundToPx() } + + GamssCharacterPicker( + items = items, + onToggle = onToggle, modifier = Modifier - .clickable(role = Role.DropdownList, onClick = onClick) - .padding(horizontal = PickerToggleHitPadding, vertical = PickerToggleVerticalPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(PickerToggleGap), - ) { - GamssText( - text = stringResource(R.string.home_character_picker), - style = GamssTheme.typography.body4Medium, - color = GamssTheme.colors.gray900, - maxLines = 1, - ) - Icon( - painter = painterResource(GamssIcons.RightChevron), - contentDescription = null, - tint = GamssTheme.colors.gray900, - // 아래위 셰브론 에셋이 없어 오른쪽 셰브론을 돌려 쓴다. 글리프가 뷰포트 중심에서 벗어나 있어 - // 회전축을 글리프 자신의 중심으로 옮긴다. 그러지 않으면 펼칠 때마다 위아래로 튄다. - modifier = Modifier - .size(PickerToggleChevronSize) - .graphicsLayer { - rotationZ = if (expanded) -CHEVRON_ROTATION else CHEVRON_ROTATION - transformOrigin = TransformOrigin(CHEVRON_CENTER_X, CHEVRON_CENTER_Y) + .offset { + IntOffset( + (leftInWindow - rootLeftInWindow).roundToInt(), + (topInWindow - rootTopInWindow).roundToInt() - overlapPx, + ) + } + // 패널 여백을 눌렀을 때 바깥 탭으로 새어 나가 닫히지 않게 막는다. + .pointerInput(Unit) { detectTapGestures {} }, + ) +} + +@Composable +private fun HomeInputSection( + state: HomeState, + actions: HomeActions, + onToggleLeftChange: (Float) -> Unit, + onInputBarBottomChange: (Float) -> Unit, +) { + GamssExpandingInputBar( + value = state.input, + onValueChange = actions.onInputChange, + onSend = actions.onSubmit, + placeholder = stringResource(R.string.home_input_placeholder), + sendContentDescription = stringResource(R.string.home_input_submit_description), + enabled = !state.isSending, + beforeSendSlot = { + GamssDisclosureToggle( + label = stringResource(R.string.home_character_picker), + expanded = state.isEmotionPickerExpanded, + onClick = actions.onEmotionPickerToggle, + modifier = Modifier.onGloballyPositioned { + onToggleLeftChange(it.boundsInWindow().left) }, - ) + // 전송 중 바꾼 선택은 이미 나간 요청에 닿지 않는다. 바와 함께 잠근다. + enabled = !state.isSending, + // 입력바 아트가 라이트 전용이라 다크에서도 전경을 검정으로 둔다. + contentColor = GamssTheme.colors.black, + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = InputBarHorizontalPadding) + .onGloballyPositioned { onInputBarBottomChange(it.boundsInWindow().bottom) }, + ) +} + +@Composable +private fun Modifier.dismissOnTapOutside(onDismiss: () -> Unit): Modifier { + val focusManager = LocalFocusManager.current + // clickable 을 쓰면 레이블 없는 클릭 노드가 화면 전체 크기로 시맨틱 트리에 들어간다. + return pointerInput(onDismiss) { + detectTapGestures { + focusManager.clearFocus() + onDismiss() + } } } @@ -270,9 +320,9 @@ private fun BoxScope.HomeDecorations() { ) } -// 헤더 아래(111) ~ 인사말(281), 입력바 아래(418) ~ 탭바 위(769) 간격 비율을 그대로 옮긴 값. +// Figma 3483:16376 기준. 헤더 아래(111) ~ 인사말(281), 입력창 아래(479) ~ 탭바 위(776). private const val GREETING_TOP_WEIGHT = 170f -private const val GREETING_BOTTOM_WEIGHT = 351f +private const val GREETING_BOTTOM_WEIGHT = 297f private val HeaderStartPadding = 20.dp @@ -281,20 +331,12 @@ private val HeaderEndPadding = 8.dp private val GreetingStartPadding = 27.dp private val GreetingWordGap = 6.dp private val GreetingToInputGap = 22.dp -private val InputBarHorizontalPadding: Dp = 20.dp - -private val PickerTopGap = 4.dp -private val PickerToggleGap = 4.dp -private val PickerToggleHitPadding = 4.dp -// 라벨 줄높이가 20dp 라 위아래 14dp 씩 더하면 터치 영역이 48dp 가 된다. -private val PickerToggleVerticalPadding = 14.dp -private val PickerToggleChevronSize = 16.dp -private const val CHEVRON_ROTATION = 90f +// Figma 입력창은 402dp 화면에서 366dp 폭이므로 좌우 여백은 각각 18dp다. +private val InputBarHorizontalPadding: Dp = 18.dp -// ic_right_chevron 글리프의 실제 중심. 24 뷰포트에서 stroke 포함 x 12.4~21.3, y 3.9~20.2 다. -private const val CHEVRON_CENTER_X = 16.85f / 24f -private const val CHEVRON_CENTER_Y = 12.03f / 24f +// 112dp/150dp 두 입력창 높이 모두에서 Figma가 보여 준 9~10dp 겹침 폭. +private val PickerOverlapHeight = 9.dp @Preview(name = "Home - Light", showBackground = true, widthDp = 402, heightDp = 720) @Composable @@ -333,5 +375,6 @@ private fun previewActions() = HomeActions( onInputChange = {}, onSubmit = {}, onEmotionPickerToggle = {}, + onEmotionPickerDismiss = {}, onEmotionToggle = {}, ) diff --git a/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt b/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt index ee8ab696..39504024 100644 --- a/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/gamss/android/feature/home/HomeViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.gamss.android.core.common.AppResult import com.gamss.android.domain.conversation.ConversationSession +import com.gamss.android.domain.conversation.MAX_MESSAGE_LENGTH import com.gamss.android.domain.conversation.takeWithinMessageLimit import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.domain.user.GetUserInfoUseCase @@ -17,6 +18,7 @@ import javax.inject.Inject internal const val LAST_CHARACTER_BLOCKED = "한 명은 남겨 주세요" internal const val SEND_FAILED = "보내지 못했어요. 잠시 후 다시 시도해 주세요." +internal val MESSAGE_LENGTH_EXCEEDED = "메시지는 ${MAX_MESSAGE_LENGTH}자까지 입력할 수 있어요." @HiltViewModel class HomeViewModel @Inject constructor( @@ -32,13 +34,23 @@ class HomeViewModel @Inject constructor( } fun onInputChange(text: String) = blockingIntent { - reduce { state.copy(input = text.takeWithinMessageLimit()) } + val limited = text.takeWithinMessageLimit() + // 잘린 결과가 넣어 둔 값과 같으면 상한에 붙은 채 계속 치는 중이다. 넘어서는 순간에만 알린다. + val crossedLimit = limited != text && limited != state.input + reduce { state.copy(input = limited) } + + if (crossedLimit) postSideEffect(HomeSideEffect.ShowToast(MESSAGE_LENGTH_EXCEEDED)) } fun onEmotionPickerToggle() = intent { reduce { state.copy(isEmotionPickerExpanded = !state.isEmotionPickerExpanded) } } + // 닫기는 토글과 나눠 둔다. 바깥 탭과 뒤로가기가 겹쳐 들어와도 다시 열리면 안 된다. + fun onEmotionPickerDismiss() = intent { + reduce { state.copy(isEmotionPickerExpanded = false) } + } + fun onEmotionToggle(character: EmotionCharacter) = intent { // 검사와 갱신을 한 reduce 안에서 끝낸다. 연타로 마지막 하나까지 빠지면 서버가 거부한다. var blocked = false diff --git a/feature/home/src/test/java/com/gamss/android/feature/home/HomeViewModelTest.kt b/feature/home/src/test/java/com/gamss/android/feature/home/HomeViewModelTest.kt index 68e029ae..e8ef53dd 100644 --- a/feature/home/src/test/java/com/gamss/android/feature/home/HomeViewModelTest.kt +++ b/feature/home/src/test/java/com/gamss/android/feature/home/HomeViewModelTest.kt @@ -119,6 +119,21 @@ class HomeViewModelTest { } } + @Test + fun `감정 목록 닫기는 여러 번 들어와도 다시 열리지 않는다`() = runTest { + viewModel().test(this) { + containerHost.onEmotionPickerToggle() + expectState { copy(isEmotionPickerExpanded = true) } + + // 바깥 탭과 Popup dismiss 가 함께 들어오는 경우다. + containerHost.onEmotionPickerDismiss() + expectState { copy(isEmotionPickerExpanded = false) } + + containerHost.onEmotionPickerDismiss() + expectNoItems() + } + } + @Test fun `보내면 감정 목록이 닫힌다`() = runTest { viewModel().test(this) { @@ -137,10 +152,62 @@ class HomeViewModelTest { } @Test - fun `상한을 넘긴 입력은 140자까지만 남는다`() = runTest { + fun `상한을 넘긴 입력은 140자까지만 남고 한 번 알린다`() = runTest { viewModel().test(this) { containerHost.onInputChange("가".repeat(MAX_MESSAGE_LENGTH + 20)) expectState { copy(input = "가".repeat(MAX_MESSAGE_LENGTH)) } + expectSideEffect(HomeSideEffect.ShowToast(MESSAGE_LENGTH_EXCEEDED)) + } + } + + @Test + fun `상한을 넘긴 채로 이어 쳐도 다시 알리지 않는다`() = runTest { + viewModel().test(this) { + val filled = "가".repeat(MAX_MESSAGE_LENGTH) + containerHost.onInputChange(filled + "가") + expectState { copy(input = filled) } + expectSideEffect(HomeSideEffect.ShowToast(MESSAGE_LENGTH_EXCEEDED)) + + // 상태가 그대로라 expectState 는 없고, 안내도 다시 오지 않는다. + containerHost.onInputChange(filled + "나") + } + } + + @Test + fun `상한 아래로 줄였다 다시 넘기면 또 알린다`() = runTest { + viewModel().test(this) { + val filled = "가".repeat(MAX_MESSAGE_LENGTH) + containerHost.onInputChange(filled + "가") + expectState { copy(input = filled) } + expectSideEffect(HomeSideEffect.ShowToast(MESSAGE_LENGTH_EXCEEDED)) + + val shorter = "가".repeat(MAX_MESSAGE_LENGTH - 1) + containerHost.onInputChange(shorter) + expectState { copy(input = shorter) } + + containerHost.onInputChange(shorter + "나다") + expectState { copy(input = shorter + "나") } + expectSideEffect(HomeSideEffect.ShowToast(MESSAGE_LENGTH_EXCEEDED)) + } + } + + @Test + fun `보내서 입력을 비운 뒤 다시 넘기면 또 알린다`() = runTest { + viewModel().test(this) { + val filled = "가".repeat(MAX_MESSAGE_LENGTH) + containerHost.onInputChange(filled + "가") + expectState { copy(input = filled) } + expectSideEffect(HomeSideEffect.ShowToast(MESSAGE_LENGTH_EXCEEDED)) + + containerHost.onSubmit() + expectState { copy(isSending = true) } + expectState { copy(isSending = false) } + expectState { copy(input = "") } + expectSideEffect(HomeSideEffect.OpenConversation(NEW_ROOM_ID)) + + containerHost.onInputChange(filled + "나") + expectState { copy(input = filled) } + expectSideEffect(HomeSideEffect.ShowToast(MESSAGE_LENGTH_EXCEEDED)) } }