Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/designsystem/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ plugins {

android {
namespace = "com.gamss.android.core.designsystem"
}

dependencies {
implementation(libs.lottie.compose)
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ private fun gamssButtonColors(
)

GamssButtonVariant.Destructive -> GamssButtonColors(
containerColor = GamssTheme.colors.red,
containerColor = GamssTheme.colors.apricot,
contentColor = GamssTheme.colors.white,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ object GamssIcons {

@DrawableRes
val ClearButton: Int = R.drawable.ic_close

@DrawableRes
val ScrollDown: Int = R.drawable.ic_scroll_down
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.gamss.android.core.designsystem.component

import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
Expand All @@ -13,6 +16,7 @@ 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.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand All @@ -21,27 +25,37 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.gamss.android.core.designsystem.R
import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote
import com.gamss.android.core.designsystem.theme.GamssTheme

/** 입력바 아래에 무언가를 띄우는 화면이 있어 공개한다. */
val GamssInputBarHeight = 53.dp

private val InputBarHeight = GamssInputBarHeight
private val InputBarBorderWidth = 1.5.dp
private val InputBarStartPadding = 21.dp
private val InputBarStartPadding = 16.dp

/** 전송 버튼은 배경까지 담긴 32x32 에셋이라 tint 하지 않고 그대로 그린다. */
private val SendButtonSize = 32.dp

// 버튼을 48dp 터치 영역으로 감싸면 좌우로 8dp 씩 남는다. 디자인값 18dp 에서 그만큼 빼야
// 버튼의 보이는 가장자리가 18dp 자리에 온다.
private val SendButtonTouchSize = 48.dp
private val InputBarEndPadding = 10.dp
private val InputBarEndPadding = 8.dp

// 답장 미리보기가 있을 때만 쓰는 세로 여백/간격. 없을 때는 기존처럼 최소 높이 + 가운데 정렬로 채운다.
private val InputBarReplyVerticalPadding = 14.dp
private val InputBarReplyGap = 14.dp

private val ReplyClearIconSize = 20.dp
private val ReplyClearTouchSize = 32.dp

// 140자 상한을 이 폭에서 담으려면 6줄이면 넉넉하다. 넘치면 입력칸 안에서 스크롤된다.
private const val INPUT_MAX_LINES = 6

@Suppress("LongParameterList")
@Composable
fun GamssInputBar(
value: String,
Expand All @@ -51,63 +65,206 @@ fun GamssInputBar(
placeholder: String = "",
trailingContentDescription: String? = null,
enabled: Boolean = true,
/**
* 전송 버튼(및 키보드 전송 액션)만 따로 잠글 때 쓴다. 기본은 [enabled]를 그대로 따른다.
* 입력칸은 계속 켜둔 채 전송만 잠깐 막고 싶을 때(예: 메시지 전송 중 포커스·키보드는
* 유지하되 중복 전송만 막는 경우) [enabled]와 분리해 넘긴다.
*/
sendEnabled: Boolean = enabled,
/** 후행 아이콘 앞에 놓이는 자리. 홈은 여기에 감정 선택 토글을 단다. */
trailingAction: @Composable (() -> Unit)? = null,
/** 이 줄 수까지 늘어나고, 넘는 내용은 입력칸 안에서 스크롤된다. 화면별 글자 상한에 맞춰 조정한다. */
maxLines: Int = INPUT_MAX_LINES,
/** 답장 대상 미리보기. null 이 아니면 입력칸 위에 같은 테두리 안에서 보여준다. */
replyQuote: ChatReplyQuote? = null,
onReplyClear: () -> Unit = {},
replyClearContentDescription: String? = null,
) {
val canSubmit = enabled && value.isNotBlank()
val canSubmit = sendEnabled && value.isNotBlank()
val verticalPadding = if (replyQuote != null) InputBarReplyVerticalPadding else 0.dp

Row(
Column(
modifier = modifier
// 140자를 채우면 여러 줄이 된다. 한 줄일 때의 높이를 최소치로만 잡고 아래로 늘어나게 둔다.
.heightIn(min = InputBarHeight)
.background(GamssTheme.colors.white, RectangleShape)
.border(InputBarBorderWidth, GamssTheme.colors.black, RectangleShape)
.padding(start = InputBarStartPadding, end = InputBarEndPadding),
.padding(start = InputBarStartPadding, top = verticalPadding, bottom = verticalPadding),
) {
if (replyQuote != null) {
ReplyPreviewRow(
replyQuote = replyQuote,
onClear = onReplyClear,
clearContentDescription = replyClearContentDescription,
modifier = Modifier
.fillMaxWidth()
.padding(end = InputBarStartPadding, bottom = InputBarReplyGap),
)
}
Row(
modifier = Modifier
// 140자를 채우면 여러 줄이 된다. 한 줄일 때의 높이를 최소치로만 잡고 아래로 늘어나게 둔다.
.heightIn(min = InputBarHeight)
.padding(end = InputBarEndPadding),
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.weight(1f),
enabled = enabled,
// 한 줄로 묶으면 140자 상한에 걸려도 가로로 스크롤만 되어 잘린 게 보이지 않는다.
// 상한을 안 걸면 남은 세로 공간을 전부 채워 버리므로 줄 수로 묶는다.
singleLine = false,
minLines = 1,
maxLines = maxLines,
textStyle = GamssTheme.typography.body4Medium.copy(color = GamssTheme.colors.gray900),
cursorBrush = SolidColor(GamssTheme.colors.gray900),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { if (canSubmit) onTrailingClick() }),
decorationBox = { innerTextField ->
// 자리표시자와 입력칸을 겹쳐 놓아야 한다. 컨테이너 없이 나란히 두면 입력칸이 밀려난다.
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty()) {
GamssText(
text = placeholder,
style = GamssTheme.typography.body4Medium,
color = GamssTheme.colors.gray500,
)
}
innerTextField()
}
},
)
trailingAction?.invoke()
Box(
modifier = Modifier
.size(SendButtonTouchSize)
.clickable(enabled = canSubmit, role = Role.Button, onClick = onTrailingClick),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(if (canSubmit) GamssIcons.SendButtonOn else GamssIcons.SendButtonOff),
contentDescription = trailingContentDescription,
modifier = Modifier.size(SendButtonSize),
)
}
}
}
}

@Composable
private fun ReplyPreviewRow(
replyQuote: ChatReplyQuote,
onClear: () -> Unit,
modifier: Modifier = Modifier,
clearContentDescription: String? = null,
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
Column(
modifier = Modifier.weight(1f),
enabled = enabled,
// 한 줄로 묶으면 140자 상한에 걸려도 가로로 스크롤만 되어 잘린 게 보이지 않는다.
// 상한을 안 걸면 남은 세로 공간을 전부 채워 버리므로 줄 수로 묶는다.
singleLine = false,
minLines = 1,
maxLines = INPUT_MAX_LINES,
textStyle = GamssTheme.typography.body4Medium.copy(color = GamssTheme.colors.gray900),
cursorBrush = SolidColor(GamssTheme.colors.gray900),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { if (canSubmit) onTrailingClick() }),
decorationBox = { innerTextField ->
// 자리표시자와 입력칸을 겹쳐 놓아야 한다. 컨테이너 없이 나란히 두면 입력칸이 밀려난다.
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart,
) {
if (value.isEmpty()) {
GamssText(
text = placeholder,
style = GamssTheme.typography.body4Medium,
color = GamssTheme.colors.gray500,
)
}
innerTextField()
}
},
)
trailingAction?.invoke()
verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing025),
) {
GamssText(
text = replyQuote.senderLabel,
style = GamssTheme.typography.subtitle4,
color = GamssTheme.colors.gray900,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
GamssText(
text = replyQuote.message,
style = GamssTheme.typography.body5Medium,
color = GamssTheme.colors.gray900,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Box(
modifier = Modifier
.size(SendButtonTouchSize)
.clickable(enabled = canSubmit, role = Role.Button, onClick = onTrailingClick),
.size(ReplyClearTouchSize)
.clickable(role = Role.Button, onClick = onClear),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(if (canSubmit) GamssIcons.SendButtonOn else GamssIcons.SendButtonOff),
contentDescription = trailingContentDescription,
modifier = Modifier.size(SendButtonSize),
Icon(
painter = painterResource(R.drawable.ic_close),
contentDescription = clearContentDescription,
modifier = Modifier.size(ReplyClearIconSize),
tint = GamssTheme.colors.gray300,
)
}
}
}

@Preview(name = "Light", showBackground = true, widthDp = 402)
@Suppress("UnusedPrivateMember")
@Composable
private fun GamssInputBarLightPreview() {
GamssTheme(darkTheme = false) {
GamssInputBarPreviewContent()
}
}

@Preview(
name = "Dark",
showBackground = true,
backgroundColor = 0xFF000000,
uiMode = Configuration.UI_MODE_NIGHT_YES,
widthDp = 402,
)
@Suppress("UnusedPrivateMember")
@Composable
private fun GamssInputBarDarkPreview() {
GamssTheme(darkTheme = true) {
GamssInputBarPreviewContent()
}
}

@Composable
private fun GamssInputBarPreviewContent() {
Column(
modifier = Modifier.padding(GamssTheme.spacing.spacing400),
verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing400),
) {
// 빈 상태 — 자리표시자가 보인다.
GamssInputBar(
value = "",
onValueChange = {},
onTrailingClick = {},
placeholder = "무슨 이야기를 버려볼까요?",
modifier = Modifier.fillMaxWidth(),
)
// 채워진 상태 — 전송 버튼이 활성화된다.
GamssInputBar(
value = "오늘 발표가 너무 떨려요",
onValueChange = {},
onTrailingClick = {},
modifier = Modifier.fillMaxWidth(),
)
// 비활성 상태 — 입력도, 전송도 막힌다.
GamssInputBar(
value = "",
onValueChange = {},
onTrailingClick = {},
placeholder = "지금은 입력할 수 없어요",
enabled = false,
modifier = Modifier.fillMaxWidth(),
)
// 답장 상태 — 입력칸 위에 답장 미리보기가 같은 테두리 안에서 보인다.
GamssInputBar(
value = "",
onValueChange = {},
onTrailingClick = {},
placeholder = "메세지 입력",
replyQuote = ChatReplyQuote(senderLabel = "불안이에게 답장", message = "안녕"),
onReplyClear = {},
replyClearContentDescription = "답장 취소",
modifier = Modifier.fillMaxWidth(),
)
}
}
Loading
Loading