diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index 50e82432..06872aeb 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -5,4 +5,8 @@ plugins { android { namespace = "com.gamss.android.core.designsystem" +} + +dependencies { + implementation(libs.lottie.compose) } \ No newline at end of file diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt index 2a29d3d4..ff000c20 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/button/GamssButton.kt @@ -98,7 +98,7 @@ private fun gamssButtonColors( ) GamssButtonVariant.Destructive -> GamssButtonColors( - containerColor = GamssTheme.colors.red, + containerColor = GamssTheme.colors.apricot, contentColor = GamssTheme.colors.white, ) } diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssIcons.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssIcons.kt index b1b3d119..e00b0bbd 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssIcons.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssIcons.kt @@ -39,4 +39,7 @@ object GamssIcons { @DrawableRes val ClearButton: Int = R.drawable.ic_close + + @DrawableRes + val ScrollDown: Int = R.drawable.ic_scroll_down } diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssInputBar.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssInputBar.kt index f51a62cd..671fe8e3 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssInputBar.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssInputBar.kt @@ -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 @@ -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 @@ -21,7 +25,11 @@ 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 /** 입력바 아래에 무언가를 띄우는 화면이 있어 공개한다. */ @@ -29,19 +37,25 @@ 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, @@ -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(), + ) + } +} diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssTokenUsageTooltip.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssTokenUsageTooltip.kt new file mode 100644 index 00000000..a268ba2d --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssTokenUsageTooltip.kt @@ -0,0 +1,153 @@ +package com.gamss.android.core.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxHeight +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.modifier.gamssShadow +import com.gamss.android.core.designsystem.modifier.noRippleClickableIfNotNull +import com.gamss.android.core.designsystem.theme.GamssTheme + +private val TooltipWidth = 211.dp +private val TooltipPadding = 12.dp +private val TooltipContentGap = 6.dp +private val TooltipBorderWidth = 1.dp +private val ProgressBarHeight = 9.dp +private val TooltipShadowElevation = 40.dp + +/** + * 상단 내비게이션의 토큰 사용량 아이콘을 눌렀을 때 그 아래 뜨는 툴팁. + * + * [usagePercent]는 0~100 사이 값을 그대로 받는다 — 계산(도메인 값 유무 판단 등)은 호출부 책임이다. + * null이면 조회에 실패한 것으로 보고 [failMessage]와 재시도 액션([retryLabel]/[onRetryClick])을 대신 보여준다. + */ +@Composable +fun GamssTokenUsageTooltip( + title: String, + usagePercent: Int?, + usagePercentLabel: String, + resetTimeLabel: String, + failMessage: String, + retryLabel: String, + onRetryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .width(TooltipWidth) + .background(GamssTheme.colors.white) + .border(TooltipBorderWidth, GamssTheme.colors.gray950) + .gamssShadow(shape = RectangleShape, elevation = TooltipShadowElevation) + .padding(TooltipPadding), + verticalArrangement = Arrangement.spacedBy(TooltipContentGap), + ) { + if (usagePercent != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = GamssTheme.typography.subtitle4, + color = GamssTheme.colors.gray900, + ) + Text( + text = usagePercentLabel, + style = GamssTheme.typography.body4Medium, + color = GamssTheme.colors.gray900, + ) + } + + TokenUsageProgressBar(percent = usagePercent) + + GamssText( + text = resetTimeLabel, + style = GamssTheme.typography.body6Regular, + color = GamssTheme.colors.gray500, + ) + } else { + Text( + text = title, + style = GamssTheme.typography.subtitle4, + color = GamssTheme.colors.gray900, + ) + GamssText( + text = failMessage, + style = GamssTheme.typography.body6Regular, + color = GamssTheme.colors.gray500, + ) + // 툴팁 톤에 맞춰 큰 CTA 버튼 대신 텍스트 링크 형태로 작게 둔다. + GamssText( + text = retryLabel, + style = GamssTheme.typography.body5Medium, + color = GamssTheme.colors.gray900, + modifier = Modifier + .align(Alignment.End) + .noRippleClickableIfNotNull(onRetryClick), + ) + } + } +} + +@Composable +private fun TokenUsageProgressBar(percent: Int, modifier: Modifier = Modifier) { + val fraction = (percent / PERCENT_DENOMINATOR).coerceIn(0f, 1f) + Box( + modifier = modifier + .fillMaxWidth() + .height(ProgressBarHeight) + .background(GamssTheme.colors.gray075), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(fraction) + .background(GamssTheme.colors.gray900), + ) + } +} + +private const val PERCENT_DENOMINATOR = 100f + +@Preview(name = "TokenUsageTooltip - Light", showBackground = true) +@Suppress("UnusedPrivateMember") +@Composable +private fun GamssTokenUsageTooltipPreview() { + GamssTheme(darkTheme = false) { + Column { + GamssTokenUsageTooltip( + title = "토큰 사용량", + usagePercent = 60, + usagePercentLabel = "60% 사용됨", + resetTimeLabel = "오전 5:00에 초기화됩니다", + failMessage = "", + retryLabel = "", + onRetryClick = {}, + ) + GamssTokenUsageTooltip( + title = "토큰 사용량", + usagePercent = null, + usagePercentLabel = "", + resetTimeLabel = "", + failMessage = "사용량을 불러오지 못했어요", + retryLabel = "재시도", + onRetryClick = {}, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/chat/GamssChatBubble.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/chat/GamssChatBubble.kt index 80142913..4e8f5db2 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/chat/GamssChatBubble.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/chat/GamssChatBubble.kt @@ -1,5 +1,8 @@ package com.gamss.android.core.designsystem.component.chat +import android.graphics.drawable.Drawable +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement @@ -17,13 +20,32 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import com.gamss.android.core.designsystem.R import com.gamss.android.core.designsystem.theme.GamssTheme +import kotlin.math.roundToInt /** * [GamssSentChatBubble], [GamssReceivedChatBubble] 말풍선 안에 표시할 답장 인용 정보. @@ -56,7 +78,17 @@ object GamssChatBubbleDefaults { val OppositeWallGap: Dp = 84.dp } -private val AvatarSize = 24.dp +private val AvatarSize = 26.dp +private val AvatarStrokeWidth = 0.5.dp + +// 로딩 bubble(54x36) 안에 정확히 맞도록 두 값 다 고정 크기로 지정한다. typing_loading.json +private val TypingLottieWidth = 46.dp +private val TypingLottieHeight = 32.dp + +// 46x32 Lottie 를 54x36 bubble 안에 정확히 담기 위한 여백. 실제 메시지 bubble 의 +// spacing200/spacing100 패딩과는 다른, 로딩 bubble 전용 값이다. +private val LoadingBubbleHorizontalPadding = 4.dp +private val LoadingBubbleVerticalPadding = 2.dp /** * 내가 보낸 채팅 메시지 말풍선. 시간이 왼쪽, 말풍선이 오른쪽에 정렬된다. @@ -99,8 +131,6 @@ fun GamssReceivedChatBubble( replyQuote: ChatReplyQuote? = null, ) { BoxWithConstraints(modifier = modifier) { - // 아바타와 그 옆 간격은 말풍선보다 먼저 고정폭을 차지하므로, 반대쪽 벽 여백을 뺀 - // 나머지 몫에서 그만큼을 한 번 더 제해야 말풍선 자체의 최대 너비가 나온다. val reservedWidth = GamssChatBubbleDefaults.OppositeWallGap + AvatarSize + GamssTheme.spacing.spacing100 val bubbleMaxWidth = (maxWidth - reservedWidth).coerceAtLeast(0.dp) Row( @@ -133,30 +163,128 @@ fun GamssReceivedChatBubble( } } +/** + * 상대가 입력중일때 말풍선. 아바타 + 이름 + 말풍선이 왼쪽에 정렬된다. + */ +@Composable +fun GamssLoadingMessageBubble( + senderStatus: String +) { + Row( + horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), + verticalAlignment = Alignment.Bottom, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), + verticalAlignment = Alignment.Top, + ) { + ChatAvatar(avatar = null) + Column(verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing075)) { + Text( + text = senderStatus, + style = GamssTheme.typography.body4Medium, + color = GamssTheme.colors.gray800, + ) + ChatBubbleSurface( + isMine = false, + horizontalPadding = LoadingBubbleHorizontalPadding, + verticalPadding = LoadingBubbleVerticalPadding, + ) { + TypingLottie() + } + } + } + } +} + @Composable private fun ChatBubbleSurface( isMine: Boolean, modifier: Modifier = Modifier, + horizontalPadding: Dp = GamssTheme.spacing.spacing200, + verticalPadding: Dp = GamssTheme.spacing.spacing100, content: @Composable () -> Unit, ) { - Column( - // 답장 인용문의 구분선(HorizontalDivider)이 본문과 같은 너비로 맞춰지도록 - // 가장 넓은 자식의 고유 너비에 맞춰 hug 하되, widthIn(max) 로 넘어오는 상한은 그대로 유지한다. - modifier = modifier - .width(IntrinsicSize.Max) - .background(if (isMine) GamssTheme.colors.gray900 else GamssTheme.colors.gray025) - .border(width = 1.dp, color = GamssTheme.colors.gray950) - .padding( - horizontal = GamssTheme.spacing.spacing200, - vertical = GamssTheme.spacing.spacing100 - ), - verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing075), - horizontalAlignment = if (isMine) Alignment.End else Alignment.Start, - ) { - content() + // painterResource() 는 9-patch를 지원하지 않아 + // rememberNinePatchPainter 로 직접 그린다. fill(배경)·stroke(테두리)는 같은 손그림 윤곽에서 + // 나온 한 쌍이라 어떤 크기로 늘어나도 모서리가 서로 어긋나지 않는다. + val bubbleColor = if (isMine) GamssTheme.colors.gray900 else GamssTheme.colors.gray025 + Box(modifier = modifier.width(IntrinsicSize.Max)) { + Image( + painter = rememberNinePatchPainter(id = R.drawable.chatmessage_fill, tint = bubbleColor), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier.matchParentSize(), + ) + Image( + painter = rememberNinePatchPainter(id = R.drawable.chatmessage_stroke, tint = GamssTheme.colors.gray950), + contentDescription = null, + contentScale = ContentScale.FillBounds, + modifier = Modifier.matchParentSize(), + ) + Column( + // 답장 인용문의 구분선(HorizontalDivider)이 본문과 같은 너비로 맞춰지도록 + // 가장 넓은 자식의 고유 너비에 맞춰 hug 하되, widthIn(max) 로 넘어오는 상한은 그대로 유지한다. + modifier = Modifier.padding(horizontal = horizontalPadding, vertical = verticalPadding), + verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing075), + horizontalAlignment = if (isMine) Alignment.End else Alignment.Start, + ) { + content() + } } } +/** + * 9-patch(.9.png) 전용 [Painter]. `painterResource()` 는 NinePatchDrawable을 BitmapDrawable로 + * 강제 캐스팅해 크래시가 나므로, [Drawable]을 직접 canvas에 그려서 우회한다. + * + * @param tint 지정하면 SRC_IN 방식으로 색만 덧칠한다(투명 영역·모양은 그대로 유지). + */ +@Composable +private fun rememberNinePatchPainter(@DrawableRes id: Int, tint: Color? = null): Painter { + val context = LocalContext.current + return remember(context, id, tint) { + val drawable = requireNotNull(ContextCompat.getDrawable(context, id)) { + "리소스를 찾을 수 없습니다: $id" + }.mutate() + if (tint != null) { + drawable.setTint(tint.toArgb()) + } + NinePatchPainter(drawable) + } +} + +private class NinePatchPainter(private val drawable: Drawable) : Painter() { + override val intrinsicSize: Size + get() = if (drawable.intrinsicWidth >= 0 && drawable.intrinsicHeight >= 0) { + Size(drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat()) + } else { + Size.Unspecified + } + + override fun DrawScope.onDraw() { + drawIntoCanvas { canvas -> + drawable.setBounds(0, 0, size.width.roundToInt(), size.height.roundToInt()) + drawable.draw(canvas.nativeCanvas) + } + } +} + +/** 말풍선 안에서 무한 반복 재생되는 타이핑 로티. 54x36 로딩 bubble 안에 정확히 맞도록 고정 크기로 그린다. */ +@Composable +private fun TypingLottie(modifier: Modifier = Modifier) { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.typing_loading)) + val progress by animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever, + ) + LottieAnimation( + composition = composition, + progress = { progress }, + modifier = modifier.size(width = TypingLottieWidth, height = TypingLottieHeight), + ) +} + @Composable private fun ChatBubbleContent( message: String, @@ -202,19 +330,8 @@ private fun ChatAvatar( modifier = modifier .size(AvatarSize) .clip(CircleShape) - .then( - if (avatar == null) { - Modifier - .background(GamssTheme.colors.gray025) - .border( - width = 1.dp, - color = GamssTheme.colors.gray200, - shape = CircleShape - ) - } else { - Modifier - }, - ), + .background(GamssTheme.colors.gray025) + .border(width = AvatarStrokeWidth, color = GamssTheme.colors.gray200, shape = CircleShape), contentAlignment = Alignment.Center, ) { avatar?.invoke() diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/Shadow.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/Shadow.kt new file mode 100644 index 00000000..66e49242 --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/Shadow.kt @@ -0,0 +1,24 @@ +package com.gamss.android.core.designsystem.modifier + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * 검정 25% 불투명도 그림자를 얹는다. 플로팅 토스트·버튼처럼 리스트 위에 떠 있는 요소에 쓴다. + */ +fun Modifier.gamssShadow( + shape: Shape = CircleShape, + elevation: Dp = 8.dp, +): Modifier = shadow( + elevation = elevation, + shape = shape, + ambientColor = ShadowColor, + spotColor = ShadowColor, +) + +private val ShadowColor = Color.Black.copy(alpha = 0.25f) 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 0b52eb6b..9fd7893b 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 @@ -6,6 +6,7 @@ import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf @Composable fun GamssTheme( @@ -19,16 +20,31 @@ fun GamssTheme( lightColorScheme(background = colors.gray025) } - CompositionLocalProvider(LocalGamssColors provides colors) { + CompositionLocalProvider( + LocalGamssColors provides colors, + LocalIsGamssDarkTheme provides darkTheme, + ) { MaterialTheme(colorScheme = materialColorScheme, content = content) } } +/** + * [isSystemInDarkTheme]는 [GamssTheme]의 [darkTheme] 오버라이드와 무관하게 실제 기기 설정을 그대로 + * 반환한다. `GamssTheme(darkTheme = false)`로 라이트를 강제한 화면에서도 컴포넌트가 직접 + * [isSystemInDarkTheme]를 부르면 그 강제가 씹힌다 — 다크/라이트로 갈리는 에셋(로고 등)을 고르는 + * 컴포넌트는 이 값 대신 이걸 읽어야 한다. + */ +private val LocalIsGamssDarkTheme = compositionLocalOf { false } + object GamssTheme { val colors: GamssColors @Composable get() = LocalGamssColors.current + val isDarkTheme: Boolean + @Composable + get() = LocalIsGamssDarkTheme.current + val typography: GamssTypography get() = GamssTypography diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/topnavigation/GamssTopNavigation.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/topnavigation/GamssTopNavigation.kt index 4c11ea12..28e04bc7 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/topnavigation/GamssTopNavigation.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/topnavigation/GamssTopNavigation.kt @@ -4,16 +4,19 @@ import android.content.res.Configuration import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme +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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -35,10 +38,32 @@ enum class GamssTopNavigationTitleAlignment { */ enum class GamssTopNavigationIcon(@DrawableRes internal val drawableRes: Int) { LeftChevron(R.drawable.ic_left_chevron), - RightChevron(R.drawable.ic_right_chevron), + + CreateCard(R.drawable.ic_create_card), Menu(R.drawable.ic_menu), + + CheckToken(R.drawable.ic_token_check), + + Search(R.drawable.ic_search) } +/** + * 오른쪽 끝에 놓이는 아이콘 하나를 나타냅니다. [GamssTopNavigation]의 `rightActions`에 순서대로 + * 담으면 오른쪽 끝부터 [TopNavigationRightActionSpacing] 간격으로 나열되고, 각각 자기 [onClick]으로 + * 클릭 이벤트를 받습니다. + * + * [isLoading]을 true로 두면 같은 자리에 아이콘 대신 로딩 인디케이터를 그리고 클릭을 막습니다. + * 서버 왕복이 진행 중이라 이 자리의 동작을 잠시 멈춰야 할 때 씁니다 — 아이콘을 리스트에서 + * 아예 빼면 자리가 사라져 사용자에게 아무 피드백도 남지 않습니다. + */ +@Immutable +data class GamssTopNavigationIconAction( + val icon: GamssTopNavigationIcon, + val onClick: () -> Unit, + val contentDescription: String? = null, + val isLoading: Boolean = false, +) + sealed interface GamssTopNavigationContent { /** * 로고도 제목도 두지 않는 형태입니다. 선택 모드처럼 좌우 아이콘만 남기는 화면에 사용합니다. @@ -63,25 +88,23 @@ sealed interface GamssTopNavigationContent { * [Color.Transparent]를 전달해 사용할 수 있습니다. * * [content]로 로고, 왼쪽 정렬 제목, 중앙 정렬 날짜/제목 형태를 선택할 수 있습니다. - * 오른쪽 아이콘의 글리프는 [rightIcon]으로 바꿀 수 있습니다. + * 오른쪽 끝에는 [rightActions]에 담은 아이콘들이 오른쪽부터 순서대로 놓입니다. 각 아이콘은 + * 서로 다른 글리프와 클릭 이벤트를 가질 수 있습니다. */ @Suppress("LongParameterList") @Composable fun GamssTopNavigation( content: GamssTopNavigationContent, modifier: Modifier = Modifier, - backgroundColor: Color = if (isSystemInDarkTheme()) { + backgroundColor: Color = if (GamssTheme.isDarkTheme) { GamssTheme.colors.black } else { GamssTheme.colors.white }, showLeftIcon: Boolean = false, - showRightIcon: Boolean = false, leftIconContentDescription: String? = null, - rightIconContentDescription: String? = null, onLeftIconClick: () -> Unit = {}, - onRightIconClick: () -> Unit = {}, - rightIcon: GamssTopNavigationIcon = GamssTopNavigationIcon.RightChevron, + rightActions: List = emptyList(), ) { Box( modifier = modifier @@ -130,15 +153,24 @@ fun GamssTopNavigation( content = content, ) - TopNavigationIconSlot( - modifier = Modifier - .padding(end = TopNavigationContentHorizontalPadding) - .align(Alignment.CenterEnd), - visible = showRightIcon, - icon = rightIcon, - contentDescription = rightIconContentDescription, - onClick = onRightIconClick, - ) + if (rightActions.isNotEmpty()) { + Row( + modifier = Modifier + .padding(end = TopNavigationContentHorizontalPadding) + .align(Alignment.CenterEnd), + horizontalArrangement = Arrangement.spacedBy(TopNavigationRightActionSpacing), + ) { + rightActions.forEach { action -> + TopNavigationIconSlot( + visible = true, + icon = action.icon, + contentDescription = action.contentDescription, + onClick = action.onClick, + isLoading = action.isLoading, + ) + } + } + } } } @@ -151,18 +183,12 @@ fun GamssTopNavigation( title: String, modifier: Modifier = Modifier, titleAlignment: GamssTopNavigationTitleAlignment = GamssTopNavigationTitleAlignment.Start, - backgroundColor: Color = if (isSystemInDarkTheme()) { - GamssTheme.colors.black - } else { - GamssTheme.colors.white - }, + // 다크 테마 시안 나오기 전까지 배경색 고정 + backgroundColor: Color = GamssTheme.colors.white, showLeftIcon: Boolean = false, - showRightIcon: Boolean = false, leftIconContentDescription: String? = null, - rightIconContentDescription: String? = null, onLeftIconClick: () -> Unit = {}, - onRightIconClick: () -> Unit = {}, - rightIcon: GamssTopNavigationIcon = GamssTopNavigationIcon.RightChevron, + rightActions: List = emptyList(), ) { GamssTopNavigation( content = GamssTopNavigationContent.Title( @@ -172,12 +198,9 @@ fun GamssTopNavigation( modifier = modifier, backgroundColor = backgroundColor, showLeftIcon = showLeftIcon, - showRightIcon = showRightIcon, leftIconContentDescription = leftIconContentDescription, - rightIconContentDescription = rightIconContentDescription, onLeftIconClick = onLeftIconClick, - onRightIconClick = onRightIconClick, - rightIcon = rightIcon, + rightActions = rightActions, ) } @@ -192,7 +215,7 @@ private fun TopNavigationContent( GamssTopNavigationContent.Logo -> Image( modifier = modifier.size(width = LogoWidth, height = LogoHeight), painter = painterResource( - if (isSystemInDarkTheme()) { + if (GamssTheme.isDarkTheme) { R.drawable.ic_logo_dark } else { R.drawable.ic_logo_light @@ -219,20 +242,29 @@ private fun TopNavigationIconSlot( onClick: () -> Unit, modifier: Modifier = Modifier, contentDescription: String? = null, + isLoading: Boolean = false, ) { if (visible) { Box( modifier = modifier .size(IconTouchTargetSize) - .noRippleClickableIfNotNull(onClick), + .noRippleClickableIfNotNull(onClick.takeUnless { isLoading }), contentAlignment = Alignment.Center, ) { - Icon( - modifier = Modifier.size(IconSize), - painter = painterResource(icon.drawableRes), - contentDescription = contentDescription, - tint = GamssTheme.colors.gray900, - ) + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(IconSize), + color = GamssTheme.colors.gray900, + strokeWidth = IconLoadingStrokeWidth, + ) + } else { + Icon( + modifier = Modifier.size(IconSize), + painter = painterResource(icon.drawableRes), + contentDescription = contentDescription, + tint = GamssTheme.colors.gray900, + ) + } } } } @@ -265,32 +297,52 @@ private fun TopNavigationPreviewContent() { Column { GamssTopNavigation( content = GamssTopNavigationContent.Logo, - showRightIcon = true, + rightActions = listOf( + GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.CreateCard, onClick = {}), + ), ) GamssTopNavigation( content = GamssTopNavigationContent.Logo, - showRightIcon = true, - rightIcon = GamssTopNavigationIcon.Menu, + rightActions = listOf( + GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Menu, onClick = {}), + ), ) GamssTopNavigation( title = "YY.MM.DD", titleAlignment = GamssTopNavigationTitleAlignment.Center, showLeftIcon = true, - showRightIcon = true, + rightActions = listOf( + GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.CreateCard, onClick = {}), + GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Menu, onClick = {}), + ), ) GamssTopNavigation( title = "Title", showLeftIcon = true, - showRightIcon = true, + rightActions = listOf( + GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.CreateCard, onClick = {}), + ), ) } } -private val TopNavigationHeight = 64.dp -private val TopNavigationContentHorizontalPadding = 18.dp +/** + * 오른쪽 아이콘 아래에 팝업/툴팁을 띄우는 화면이 있어 공개한다. [GamssTopNavigation] 자체 높이는 + * 이 값이지만, 컴포넌트 위에 얹히는 safety area 간격([GamssTheme.spacing.spacing075])까지 더해야 + * 화면에 실제로 차지하는 전체 높이가 나온다. + */ +val GamssTopNavigationHeight = 64.dp + +/** 오른쪽 아이콘이 화면 끝에서 떨어진 여백. 그 아이콘에 맞춰 팝업 위치를 잡을 때 같이 쓴다. */ +val GamssTopNavigationHorizontalPadding = 18.dp + +private val TopNavigationHeight = GamssTopNavigationHeight +private val TopNavigationContentHorizontalPadding = GamssTopNavigationHorizontalPadding private val TopNavigationTitleStartWithIcon = 60.dp private val TopNavigationTitleEndPadding = 60.dp +private val TopNavigationRightActionSpacing = 20.dp private val IconTouchTargetSize = 24.dp private val IconSize = 24.dp +private val IconLoadingStrokeWidth = 2.dp private val LogoWidth = 79.dp private val LogoHeight = 26.dp diff --git a/core/designsystem/src/main/res/drawable-nodpi/chatmessage_fill.9.png b/core/designsystem/src/main/res/drawable-nodpi/chatmessage_fill.9.png new file mode 100644 index 00000000..a30a7785 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-nodpi/chatmessage_fill.9.png differ diff --git a/core/designsystem/src/main/res/drawable-nodpi/chatmessage_stroke.9.png b/core/designsystem/src/main/res/drawable-nodpi/chatmessage_stroke.9.png new file mode 100644 index 00000000..59993d4a Binary files /dev/null and b/core/designsystem/src/main/res/drawable-nodpi/chatmessage_stroke.9.png differ diff --git a/core/designsystem/src/main/res/drawable/ic_avatar_anger.xml b/core/designsystem/src/main/res/drawable/ic_avatar_anger.xml new file mode 100644 index 00000000..45b53a10 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_avatar_anger.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_avatar_anxiety.xml b/core/designsystem/src/main/res/drawable/ic_avatar_anxiety.xml new file mode 100644 index 00000000..8e318bbf --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_avatar_anxiety.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_avatar_joy.xml b/core/designsystem/src/main/res/drawable/ic_avatar_joy.xml new file mode 100644 index 00000000..89cad57f --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_avatar_joy.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_avatar_prickly.xml b/core/designsystem/src/main/res/drawable/ic_avatar_prickly.xml new file mode 100644 index 00000000..44840149 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_avatar_prickly.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_avatar_quirky.xml b/core/designsystem/src/main/res/drawable/ic_avatar_quirky.xml new file mode 100644 index 00000000..f274c24d --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_avatar_quirky.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_avatar_sadness.xml b/core/designsystem/src/main/res/drawable/ic_avatar_sadness.xml new file mode 100644 index 00000000..60afb958 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_avatar_sadness.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_create_card.xml b/core/designsystem/src/main/res/drawable/ic_create_card.xml index 2de35c0a..4359c396 100644 --- a/core/designsystem/src/main/res/drawable/ic_create_card.xml +++ b/core/designsystem/src/main/res/drawable/ic_create_card.xml @@ -4,10 +4,28 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:pathData="M20.06,17.5V6.44C20.06,4.784 18.716,3.44 17.06,3.44H6.94C5.284,3.44 3.94,4.784 3.94,6.44V16.56C3.94,18.216 5.284,19.56 6.94,19.56H14.5" + android:strokeLineJoin="round" + android:strokeWidth="1.5" + android:fillColor="#00000000" + android:strokeColor="#303136" + android:strokeLineCap="round"/> + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_search.xml b/core/designsystem/src/main/res/drawable/ic_search.xml new file mode 100644 index 00000000..5755f354 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_search.xml @@ -0,0 +1,20 @@ + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_token_check.xml b/core/designsystem/src/main/res/drawable/ic_token_check.xml new file mode 100644 index 00000000..bb07a16a --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_token_check.xml @@ -0,0 +1,20 @@ + + + + diff --git a/core/designsystem/src/main/res/raw/typing_loading.json b/core/designsystem/src/main/res/raw/typing_loading.json new file mode 100644 index 00000000..46123937 --- /dev/null +++ b/core/designsystem/src/main/res/raw/typing_loading.json @@ -0,0 +1 @@ +{"nm":"Main Scene","ddd":0,"h":514,"w":745,"meta":{"g":"@lottiefiles/creator@1.105.0"},"layers":[{"ty":4,"nm":"Last","sr":1,"st":0,"op":45.0000018328876,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[38.735,38.735,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[514.68,262.568,0],"t":10,"ti":[0,0,0],"to":[0,-21.333,0]},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[514.68,134.568,0],"t":20,"ti":[0,-21.333,0],"to":[0,0,0]},{"s":[514.68,262.568,0],"t":30.0000012219251}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-21.255,0],[0,-21.255],[21.255,0],[0,21.255]],"o":[[21.255,0],[0,21.255],[-21.255,0],[0,-21.255]],"v":[[0,-38.485],[38.485,0],[0,38.485],[-38.485,0]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.7608,0.7804,0.8196],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[38.735,38.735],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":1},{"ty":4,"nm":"Second","sr":1,"st":0,"op":45.0000018328876,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[38.735,38.735,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[372.023,262.568,0],"t":5,"ti":[0,0,0],"to":[0,-21.333,0]},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[372.023,134.568,0],"t":15,"ti":[0,-21.333,0],"to":[0,0,0]},{"s":[372.023,262.568,0],"t":24.00000097754}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-21.255,0],[0,-21.255],[21.255,0],[0,21.255]],"o":[[21.255,0],[0,21.255],[-21.255,0],[0,-21.255]],"v":[[0,-38.485],[38.485,0],[0,38.485],[-38.485,0]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.7608,0.7804,0.8196],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[38.735,38.735],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":2},{"ty":4,"nm":"First","sr":1,"st":0,"op":45.0000018328876,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[38.735,38.735,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[229.365,262.568,0],"t":0,"ti":[0,0,0],"to":[0,-21.333,0]},{"o":{"x":0.333,"y":0},"i":{"x":0.667,"y":1},"s":[229.365,134.568,0],"t":10,"ti":[0,-21.333,0],"to":[0,0,0]},{"s":[229.365,262.568,0],"t":20.0000008146167}],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":2,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[-21.255,0],[0,-21.255],[21.255,0],[0,21.255]],"o":[[21.255,0],[0,21.255],[-21.255,0],[0,-21.255]],"v":[[0,-38.485],[38.485,0],[0,38.485],[-38.485,0]]},"ix":2}},{"ty":"fl","bm":0,"hd":false,"mn":"ADBE Vector Graphic - Fill","nm":"Fill 1","c":{"a":0,"k":[0.7608,0.7804,0.8196],"ix":4},"r":1,"o":{"a":0,"k":100,"ix":5}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[38.735,38.735],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":3},{"ty":4,"nm":"bubble","sr":1,"st":0,"op":45.0000018328876,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[372.272,256.818,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[372.97804718436015,257.43303106877033],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Group 1","ix":1,"cix":2,"np":4,"it":[{"ty":"sh","bm":0,"hd":false,"mn":"ADBE Vector Shape - Group","nm":"Path 1","ix":1,"d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,0],[-113.359,0],[0,0],[0,-113.359],[0,0],[113.359,0],[0,0],[0,28.339]],"o":[[0,-113.359],[0,0],[113.359,0],[0,0],[0,113.359],[0,0],[-28.339,0],[0,0]],"v":[[-372.022,-51.313],[-166.769,-256.567],[166.769,-256.567],[372.022,-51.313],[372.022,51.314],[166.769,256.568],[-320.71,256.568],[-372.022,205.254]]},"ix":2}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[372.272,256.817],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":4}],"v":"5.7.0","fr":29.9700012207031,"op":45.0000018328876,"ip":0,"assets":[]} \ No newline at end of file diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingListTopBar.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingListTopBar.kt index 533ba66c..197ea8e5 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingListTopBar.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingListTopBar.kt @@ -16,6 +16,7 @@ import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationContent import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationIcon +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationIconAction import com.gamss.android.feature.chat.R /** @@ -42,12 +43,15 @@ internal fun ChattingListTopBar( }, backgroundColor = GamssTheme.colors.background, showLeftIcon = isSelectionMode, - showRightIcon = true, leftIconContentDescription = stringResource(R.string.chatting_list_selection_cancel), - rightIconContentDescription = stringResource(R.string.chatting_list_menu_content_description), onLeftIconClick = onSelectionCancel, - onRightIconClick = onMenuClick, - rightIcon = GamssTopNavigationIcon.Menu, + rightActions = listOf( + GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.Menu, + onClick = onMenuClick, + contentDescription = stringResource(R.string.chatting_list_menu_content_description), + ), + ), ) if (!isSelectionMode) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b395eb11..2cea86db 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ retrofit = "3.0.0" okhttp = "5.4.0" room = "3.0.0-alpha06" coil = "3.5.0" +lottie = "6.7.1" calendar = "2.10.0" firebase-bom = "34.15.0" firebase-crashlytics-gradle = "3.0.7" @@ -99,6 +100,8 @@ coil-bom = { module = "io.coil-kt.coil3:coil-bom", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose" } coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp" } +lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie" } + calendar-compose = { module = "com.kizitonwose.calendar:compose", version.ref = "calendar" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }