From 8ed6ad1d3c675ad249b22f8b51ef033a89fe6ca7 Mon Sep 17 00:00:00 2001 From: seunghee Date: Sat, 15 Aug 2026 10:18:28 +0900 Subject: [PATCH 01/43] =?UTF-8?q?feat:=20=EB=8C=80=ED=99=94=EB=B0=A9=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=B0=BD=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 1 + .../designsystem/component/GamssInputBar.kt | 4 +- .../android/feature/chat/ChatRoomScreen.kt | 217 ++++++++++++------ 3 files changed, 157 insertions(+), 65 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 286c3b05..6cb34373 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,6 +51,7 @@ android:exported="true" android:screenOrientation="portrait" android:theme="@style/Theme.GAMSS" + android:windowSoftInputMode="adjustResize" tools:ignore="DiscouragedApi,LockedOrientationActivity"> 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..578423c9 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 @@ -53,6 +53,8 @@ fun GamssInputBar( enabled: Boolean = true, /** 후행 아이콘 앞에 놓이는 자리. 홈은 여기에 감정 선택 토글을 단다. */ trailingAction: @Composable (() -> Unit)? = null, + /** 이 줄 수까지 늘어나고, 넘는 내용은 입력칸 안에서 스크롤된다. 화면별 글자 상한에 맞춰 조정한다. */ + maxLines: Int = INPUT_MAX_LINES, ) { val canSubmit = enabled && value.isNotBlank() @@ -74,7 +76,7 @@ fun GamssInputBar( // 상한을 안 걸면 남은 세로 공간을 전부 채워 버리므로 줄 수로 묶는다. singleLine = false, minLines = 1, - maxLines = INPUT_MAX_LINES, + maxLines = maxLines, textStyle = GamssTheme.typography.body4Medium.copy(color = GamssTheme.colors.gray900), cursorBrush = SolidColor(GamssTheme.colors.gray900), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 8afc0cd3..2fdf4e60 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -3,10 +3,12 @@ package com.gamss.android.feature.chat import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.content.res.Configuration import android.net.Uri import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -25,7 +27,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -35,7 +36,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -43,23 +43,30 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.gamss.android.core.designsystem.component.GamssInputBar import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote import com.gamss.android.core.designsystem.component.chat.ChatSender import com.gamss.android.core.designsystem.component.chat.GamssReceivedChatBubble import com.gamss.android.core.designsystem.component.chat.GamssSentChatBubble import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationTitleAlignment import com.gamss.android.domain.card.Card -import com.gamss.android.domain.conversation.MAX_MESSAGE_LENGTH import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.feature.chat.component.SupportAgencyDialog import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @@ -203,15 +210,34 @@ private fun ChatRoomContent( } } + // 답장할 메시지까지 스크롤해 둔 채로 입력창을 탭하면, 키보드가 올라오며 줄어드는 높이만큼 + // 뷰포트 아래쪽이 잘려 그 메시지가 화면 밖으로 밀려난다. 키보드가 커진 만큼 같이 스크롤해 + // 같은 메시지가 입력창 위에 계속 보이게 한다. 키보드가 내려갈 때도 같은 식으로 되돌아온다. + val imeBottomPx = WindowInsets.ime.getBottom(LocalDensity.current) + var previousImeBottomPx by remember { mutableIntStateOf(imeBottomPx) } + LaunchedEffect(imeBottomPx) { + val delta = imeBottomPx - previousImeBottomPx + previousImeBottomPx = imeBottomPx + if (delta != 0) { + listState.scrollBy(delta.toFloat()) + } + } + Scaffold( modifier = modifier, topBar = { - // 디자인 컴포넌트 적용 예정 - ChatRoomTopBar( - endFlow = state.endFlow, - canEnd = state.canEnd, - showEndButton = state.useChatEndFeature, - onEndClick = actions.onEndClick, + /// FIXME: 디자인 컴포넌트 적용 예정 +// ChatRoomTopBar( +// endFlow = state.endFlow, +// canEnd = state.canEnd, +// showEndButton = state.useChatEndFeature, +// onEndClick = actions.onEndClick, +// ) + GamssTopNavigation( + title = "YY.MM.DD", + titleAlignment = GamssTopNavigationTitleAlignment.Center, + showLeftIcon = true, + showRightIcon = true, ) }, // 상위 Scaffold 가 인셋을 이미 적용해, imePadding 을 그대로 쓰면 이중 적용된다. @@ -267,7 +293,10 @@ private fun ChatRoomContent( ChatRoomInputSection( endFlow = state.endFlow, input = state.input, - canSend = state.canSend, + // 전송 가능 여부(글자 유무)는 GamssInputBar 내부에서 계산한다. 여기서는 그 앞단 + // 조건(전송 중·로딩 중·종료 흐름 진입)만 넘겨 입력칸 자체를 잠근다. + isInputEnabled = !state.isSending && !state.isLoading && + state.endFlow == EndFlow.NotStarted, replyTarget = state.replyTarget, actions = actions, ) @@ -275,47 +304,47 @@ private fun ChatRoomContent( } } -@Composable -private fun ChatRoomTopBar( - endFlow: EndFlow, - canEnd: Boolean, - showEndButton: Boolean, - onEndClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "대화", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f), - ) - if (showEndButton) { - if (endFlow.isBusy) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - } else { - TextButton(onClick = onEndClick, enabled = canEnd) { - Text( - when { - endFlow == EndFlow.CardFailedRetryable -> "카드 다시 만들기" - endFlow is EndFlow.Ended -> "끝난 대화" - else -> "대화 끝내기" - }, - ) - } - } - } - } -} +//@Composable +//private fun ChatRoomTopBar( +// endFlow: EndFlow, +// canEnd: Boolean, +// showEndButton: Boolean, +// onEndClick: () -> Unit, +//) { +// Row( +// modifier = Modifier +// .fillMaxWidth() +// .padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 12.dp), +// verticalAlignment = Alignment.CenterVertically, +// ) { +// Text( +// text = "대화", +// style = MaterialTheme.typography.titleLarge, +// modifier = Modifier.weight(1f), +// ) +// if (showEndButton) { +// if (endFlow.isBusy) { +// CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) +// } else { +// TextButton(onClick = onEndClick, enabled = canEnd) { +// Text( +// when { +// endFlow == EndFlow.CardFailedRetryable -> "카드 다시 만들기" +// endFlow is EndFlow.Ended -> "끝난 대화" +// else -> "대화 끝내기" +// }, +// ) +// } +// } +// } +// } +//} @Composable private fun ChatRoomInputSection( endFlow: EndFlow, input: String, - canSend: Boolean, + isInputEnabled: Boolean, replyTarget: ReplyTarget?, actions: ChatRoomActions, ) { @@ -338,7 +367,7 @@ private fun ChatRoomInputSection( MessageInputBar( input = input, - canSend = canSend, + enabled = isInputEnabled, onInputChange = actions.onInputChange, onSendClick = actions.onSendClick, ) @@ -449,28 +478,22 @@ private fun ReplyTargetBanner( @Composable private fun MessageInputBar( input: String, - canSend: Boolean, + enabled: Boolean, onInputChange: (String) -> Unit, onSendClick: () -> Unit, ) { - Row( + GamssInputBar( + value = input, + onValueChange = onInputChange, + onTrailingClick = onSendClick, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = input, - onValueChange = onInputChange, - modifier = Modifier.weight(1f), - placeholder = { Text("지금 기분을 적어보세요") }, - supportingText = { Text("${input.length}/$MAX_MESSAGE_LENGTH") }, - maxLines = 4, - ) - IconButton(onClick = onSendClick, enabled = canSend) { - Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "보내기") - } - } + .padding(horizontal = 18.dp, vertical = 12.dp), + placeholder = "지금 기분을 적어보세요", + trailingContentDescription = "보내기", + enabled = enabled, + maxLines = MESSAGE_INPUT_MAX_LINES, + ) } private fun Context.dialOrNotify(phoneNumber: String?) { @@ -494,3 +517,69 @@ private fun Context.dial(phoneNumber: String): Boolean = } private const val EMERGENCY_PHONE_NUMBER = "119" +private const val MESSAGE_INPUT_MAX_LINES = 5 + +@Preview(name = "Light", showBackground = true) +@Suppress("UnusedPrivateMember") +@Composable +private fun ChatRoomLightPreview() { + GamssTheme(darkTheme = false) { + ChatRoomPreviewContent() + } +} + +@Preview( + name = "Dark", + showBackground = true, + backgroundColor = 0xFF000000, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Suppress("UnusedPrivateMember") +@Composable +private fun ChatRoomDarkPreview() { + GamssTheme(darkTheme = true) { + ChatRoomPreviewContent() + } +} + +@Composable +private fun ChatRoomPreviewContent() { + val messages = listOf( + Message( + id = 1, + conversationId = 1, + sender = MessageSender.Character(EmotionCharacter.JOY), + content = "안녕! 오늘도 행복한 하루~!", + createdTime = "오후 1:38", + ), + Message( + id = 2, + conversationId = 1, + sender = MessageSender.User, + content = "안녕하세요 반가워요", + createdTime = "오후 1:39", + ), + Message( + id = 3, + conversationId = 1, + sender = MessageSender.Character(EmotionCharacter.SADNESS), + content = "오늘은 좀 힘든 하루였어요", + repliesToMessageId = 2, + createdTime = "오후 1:40", + ), + ) + val state = ChatRoomState( + conversationId = 1, + messages = messages, + input = "", + useChatEndFeature = true, + ) + val actions = ChatRoomActions( + onInputChange = {}, + onSendClick = {}, + onCharacterMessageClick = {}, + onReplyTargetClear = {}, + onEndClick = {}, + ) + ChatRoomContent(state = state, actions = actions) +} From 8f24b91ecf02a3a28dff5480fefc32ea9266ce8d Mon Sep 17 00:00:00 2001 From: seunghee Date: Sat, 15 Aug 2026 14:22:08 +0900 Subject: [PATCH 02/43] =?UTF-8?q?feat:=20GamssInputBar=20preview=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../designsystem/component/GamssInputBar.kt | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) 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 578423c9..9012ac84 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 @@ -21,6 +24,7 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.gamss.android.core.designsystem.theme.GamssTheme @@ -113,3 +117,60 @@ fun GamssInputBar( } } } + +@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(), + ) + } +} From 29c24fd6dd777568a7e59cbf416cc515d42cd7b6 Mon Sep 17 00:00:00 2001 From: seunghee Date: Sat, 15 Aug 2026 17:19:42 +0900 Subject: [PATCH 03/43] =?UTF-8?q?feat:=20TopNavigation=20=EC=98=A4?= =?UTF-8?q?=EB=A5=B8=EC=AA=BD=20=EB=8B=A4=EC=A4=91=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?Ui=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../topnavigation/GamssTopNavigation.kt | 87 ++++++++++++------- 1 file changed, 54 insertions(+), 33 deletions(-) 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..9cc7681d 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 @@ -5,8 +5,10 @@ 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 @@ -14,6 +16,7 @@ import androidx.compose.foundation.layout.size 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,22 @@ 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), } +/** + * 오른쪽 끝에 놓이는 아이콘 하나를 나타냅니다. [GamssTopNavigation]의 `rightActions`에 순서대로 + * 담으면 오른쪽 끝부터 [TopNavigationRightActionSpacing] 간격으로 나열되고, 각각 자기 [onClick]으로 + * 클릭 이벤트를 받습니다. + */ +@Immutable +data class GamssTopNavigationIconAction( + val icon: GamssTopNavigationIcon, + val onClick: () -> Unit, + val contentDescription: String? = null, +) + sealed interface GamssTopNavigationContent { /** * 로고도 제목도 두지 않는 형태입니다. 선택 모드처럼 좌우 아이콘만 남기는 화면에 사용합니다. @@ -63,7 +78,8 @@ sealed interface GamssTopNavigationContent { * [Color.Transparent]를 전달해 사용할 수 있습니다. * * [content]로 로고, 왼쪽 정렬 제목, 중앙 정렬 날짜/제목 형태를 선택할 수 있습니다. - * 오른쪽 아이콘의 글리프는 [rightIcon]으로 바꿀 수 있습니다. + * 오른쪽 끝에는 [rightActions]에 담은 아이콘들이 오른쪽부터 순서대로 놓입니다. 각 아이콘은 + * 서로 다른 글리프와 클릭 이벤트를 가질 수 있습니다. */ @Suppress("LongParameterList") @Composable @@ -76,12 +92,9 @@ fun GamssTopNavigation( 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 +143,23 @@ 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, + ) + } + } + } } } @@ -151,18 +172,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 +187,9 @@ fun GamssTopNavigation( modifier = modifier, backgroundColor = backgroundColor, showLeftIcon = showLeftIcon, - showRightIcon = showRightIcon, leftIconContentDescription = leftIconContentDescription, - rightIconContentDescription = rightIconContentDescription, onLeftIconClick = onLeftIconClick, - onRightIconClick = onRightIconClick, - rightIcon = rightIcon, + rightActions = rightActions, ) } @@ -265,23 +277,31 @@ 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 = {}), + ), ) } } @@ -290,6 +310,7 @@ private val TopNavigationHeight = 64.dp private val TopNavigationContentHorizontalPadding = 18.dp 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 LogoWidth = 79.dp From d98a398064672f16e4173e947d103386a6347c89 Mon Sep 17 00:00:00 2001 From: seunghee Date: Sat, 15 Aug 2026 17:20:42 +0900 Subject: [PATCH 04/43] =?UTF-8?q?feat:=20GamssInputBar=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=8B=B5=EC=9E=A5=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20ui=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # core/designsystem/src/main/res/drawable/ic_close.xml --- .../designsystem/component/GamssInputBar.kt | 178 +++++++++++++----- 1 file changed, 133 insertions(+), 45 deletions(-) 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 9012ac84..c114dace 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 @@ -16,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 @@ -24,8 +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 /** 입력바 아래에 무언가를 띄우는 화면이 있어 공개한다. */ @@ -33,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, @@ -59,60 +69,127 @@ fun GamssInputBar( trailingAction: @Composable (() -> Unit)? = null, /** 이 줄 수까지 늘어나고, 넘는 내용은 입력칸 안에서 스크롤된다. 화면별 글자 상한에 맞춰 조정한다. */ maxLines: Int = INPUT_MAX_LINES, + /** 답장 대상 미리보기. null 이 아니면 입력칸 위에 같은 테두리 안에서 보여준다. */ + replyQuote: ChatReplyQuote? = null, + onReplyClear: () -> Unit = {}, + replyClearContentDescription: String? = null, ) { val canSubmit = enabled && 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 = 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() + 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, ) } } @@ -172,5 +249,16 @@ private fun GamssInputBarPreviewContent() { enabled = false, modifier = Modifier.fillMaxWidth(), ) + // 답장 상태 — 입력칸 위에 답장 미리보기가 같은 테두리 안에서 보인다. + GamssInputBar( + value = "", + onValueChange = {}, + onTrailingClick = {}, + placeholder = "메세지 입력", + replyQuote = ChatReplyQuote(senderLabel = "불안이에게 답장", message = "안녕"), + onReplyClear = {}, + replyClearContentDescription = "답장 취소", + modifier = Modifier.fillMaxWidth(), + ) } } From 3f342419feb6d4a6383dbba155c4ed360908a43c Mon Sep 17 00:00:00 2001 From: seunghee Date: Sat, 15 Aug 2026 17:21:17 +0900 Subject: [PATCH 05/43] =?UTF-8?q?feat:=20=EC=9C=84=ED=97=98=EC=8B=A0?= =?UTF-8?q?=ED=98=B8=20=EB=8B=A4=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20ui?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/component/SupportAgencyDialog.kt | 68 ++++--------------- 1 file changed, 12 insertions(+), 56 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/SupportAgencyDialog.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/SupportAgencyDialog.kt index 7641dd92..97a77a14 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/SupportAgencyDialog.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/SupportAgencyDialog.kt @@ -37,11 +37,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import com.gamss.android.core.designsystem.button.GamssButton import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.domain.safety.SupportAgency import com.gamss.android.feature.chat.R -private val DialogShape = RoundedCornerShape(28.dp) +private val DialogShape = RoundedCornerShape(16.dp) private val SupportPanelShape = RoundedCornerShape(20.dp) private val ActionShape = RoundedCornerShape(12.dp) @@ -50,7 +51,6 @@ internal fun SupportAgencyDialog( agencies: List, onCallClick: (SupportAgency) -> Unit, onEmergencyCallClick: () -> Unit, - onConfirm: () -> Unit, onDismiss: () -> Unit, ) { Dialog( @@ -60,7 +60,7 @@ internal fun SupportAgencyDialog( Box( modifier = Modifier .fillMaxSize() - .padding(horizontal = GamssTheme.spacing.spacing300), + .padding(horizontal = GamssTheme.spacing.spacing550), contentAlignment = Alignment.Center, ) { Surface( @@ -68,12 +68,12 @@ internal fun SupportAgencyDialog( .fillMaxWidth() .widthIn(max = 520.dp), shape = DialogShape, - color = GamssTheme.colors.gray025, + color = GamssTheme.colors.white, ) { Column( modifier = Modifier .verticalScroll(rememberScrollState()) - .padding(GamssTheme.spacing.spacing500), + .padding(GamssTheme.spacing.spacing400), verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing500), ) { DialogHeader() @@ -82,7 +82,11 @@ internal fun SupportAgencyDialog( onCallClick = onCallClick, onEmergencyCallClick = onEmergencyCallClick, ) - DialogActions(onDismiss = onDismiss, onConfirm = onConfirm) + GamssButton( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.safety_agency_close_button_label), + onClick = onDismiss, + ) } } } @@ -216,63 +220,16 @@ private fun AdditionalInfo() { painter = painterResource(R.drawable.ic_info), contentDescription = null, tint = GamssTheme.colors.gray300, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(18.dp), ) Text( text = stringResource(R.string.chat_room_risk_dialog_additional_info), - style = GamssTheme.typography.body5Regular, + style = GamssTheme.typography.body6Medium, color = GamssTheme.colors.gray500, ) } } -@Composable -private fun DialogActions( - onDismiss: () -> Unit, - onConfirm: () -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), - ) { - DialogActionButton( - label = stringResource(R.string.chat_room_end_dialog_dismiss_button_label), - containerColor = GamssTheme.colors.gray100, - contentColor = GamssTheme.colors.gray600, - onClick = onDismiss, - modifier = Modifier.weight(1f), - ) - DialogActionButton( - label = stringResource(R.string.chat_room_risk_dialog_confirm_button_label), - containerColor = GamssTheme.colors.gray950, - contentColor = GamssTheme.colors.gray025, - onClick = onConfirm, - modifier = Modifier.weight(1f), - ) - } -} - -@Composable -private fun DialogActionButton( - label: String, - containerColor: Color, - contentColor: Color, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .height(56.dp) - .clip(ActionShape) - .background(containerColor) - .clickable(onClick = onClick) - .semantics { role = Role.Button }, - contentAlignment = Alignment.Center, - ) { - Text(text = label, style = GamssTheme.typography.subtitle3, color = contentColor) - } -} - @Composable private fun CallIcon(tint: Color) { Icon( @@ -296,7 +253,6 @@ private fun SupportAgencyDialogPreview() { ), onCallClick = {}, onEmergencyCallClick = {}, - onConfirm = {}, onDismiss = {}, ) } From f407ee8f92eb94200143527d255594a73aa317b6 Mon Sep 17 00:00:00 2001 From: seunghee Date: Sat, 15 Aug 2026 17:21:35 +0900 Subject: [PATCH 06/43] =?UTF-8?q?feat:=20GamssButton=20=EB=B0=B0=EA=B2=BD?= =?UTF-8?q?=EC=83=89=EC=83=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gamss/android/core/designsystem/button/GamssButton.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 577ad414..228a695e 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 @@ -90,7 +90,7 @@ private fun gamssButtonColors( ) GamssButtonVariant.Destructive -> GamssButtonColors( - containerColor = GamssTheme.colors.red, + containerColor = GamssTheme.colors.apricot, contentColor = GamssTheme.colors.white, ) } From 28356e9ec07eaee2c12534d902fe4973933f88f9 Mon Sep 17 00:00:00 2001 From: seunghee Date: Sat, 15 Aug 2026 17:25:05 +0900 Subject: [PATCH 07/43] =?UTF-8?q?feat:=20=EB=8C=80=ED=99=94=EB=B0=A9=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4=20UI=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gamss/android/app/main/MainScreen.kt | 1 + .../android/feature/chat/ChatRoomScreen.kt | 250 ++++++++---------- .../android/feature/chat/ChatRoomState.kt | 1 + .../android/feature/chat/ChatRoomViewModel.kt | 6 +- .../gamss/android/feature/chat/util/Utils.kt | 28 ++ feature/chat/src/main/res/values/strings.xml | 5 + 6 files changed, 146 insertions(+), 145 deletions(-) create mode 100644 feature/chat/src/main/java/com/gamss/android/feature/chat/util/Utils.kt diff --git a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt index de1355c7..458134be 100644 --- a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt +++ b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt @@ -151,6 +151,7 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { ChatRoomScreen( conversationId = key.conversationId, onCardClose = navigator::goBack, + onBackClick = navigator::goBack, ) } } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 2fdf4e60..1c01bd2a 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -1,13 +1,9 @@ package com.gamss.android.feature.chat -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent + import android.content.res.Configuration -import android.net.Uri import android.widget.Toast import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -26,19 +22,12 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect @@ -55,19 +44,26 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.gamss.android.core.designsystem.button.GamssButtonVariant import com.gamss.android.core.designsystem.component.GamssInputBar import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote import com.gamss.android.core.designsystem.component.chat.ChatSender import com.gamss.android.core.designsystem.component.chat.GamssReceivedChatBubble import com.gamss.android.core.designsystem.component.chat.GamssSentChatBubble +import com.gamss.android.core.designsystem.dialog.GamssDialog +import com.gamss.android.core.designsystem.dialog.GamssDialogAction +import com.gamss.android.core.designsystem.modifier.noRippleCombinedClickable import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationIcon +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationIconAction import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationTitleAlignment import com.gamss.android.domain.card.Card import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.feature.chat.component.SupportAgencyDialog +import com.gamss.android.feature.chat.util.dialOrNotify import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @@ -79,6 +75,7 @@ import org.orbitmvi.orbit.compose.collectSideEffect fun ChatRoomScreen( conversationId: Long, onCardClose: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, viewModel: ChatRoomViewModel = hiltViewModel(), ) { @@ -104,15 +101,18 @@ fun ChatRoomScreen( ) } - ChatRoomContent(state = state, actions = actions, modifier = modifier) + ChatRoomContent( + state = state, + actions = actions, + modifier = modifier, + onBackClick = onBackClick + ) state.riskDetection?.let { detection -> SupportAgencyDialog( agencies = detection.agencies, onCallClick = { agency -> context.dialOrNotify(agency.phoneNumber) }, onEmergencyCallClick = { context.dialOrNotify(EMERGENCY_PHONE_NUMBER) }, - // 감정 결과 화면으로 이동하는 별도 계약이 생기기 전까지는 안내만 닫는다. - onConfirm = viewModel::onRiskDialogDismiss, onDismiss = viewModel::onRiskDialogDismiss, ) } @@ -123,36 +123,35 @@ fun ChatRoomScreen( onConfirm = viewModel::onEndConfirm, onDismiss = viewModel::onEndCancel, ) + is EndFlow.CardReady -> CardBottomSheet(card = endFlow.card, onDismiss = onCardClose) EndFlow.NotStarted, EndFlow.Ending, EndFlow.CreatingCard, EndFlow.CardFailedRetryable, EndFlow.CardFailedFinal, - -> Unit + -> Unit } } -// 디자인 컴포넌트로 대체하기 @Composable private fun EndConversationDialog( onConfirm: () -> Unit, onDismiss: () -> Unit, ) { - AlertDialog( + GamssDialog( + title = stringResource(R.string.chat_room_end_dialog_title), + subtitle = stringResource(R.string.chat_room_end_dialog_subTitle), + secondaryAction = GamssDialogAction( + label = stringResource(R.string.chat_room_end_dialog_dismiss_button_label), + onClick = onDismiss, + variant = GamssButtonVariant.Secondary, + ), + primaryAction = GamssDialogAction( + label = stringResource(R.string.chat_room_end_dialog_confirm_button_label), + onClick = onConfirm, + ), onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.chat_room_end_dialog_title)) }, - text = { Text(stringResource(R.string.chat_room_end_dialog_subTitle)) }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text(stringResource(R.string.chat_room_end_dialog_confirm_button_label)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.chat_room_end_dialog_dismiss_button_label)) - } - }, ) } @@ -200,6 +199,7 @@ private fun ChatRoomContent( state: ChatRoomState, actions: ChatRoomActions, modifier: Modifier = Modifier, + onBackClick: () -> Unit, ) { val listState = rememberLazyListState() @@ -226,18 +226,12 @@ private fun ChatRoomContent( Scaffold( modifier = modifier, topBar = { - /// FIXME: 디자인 컴포넌트 적용 예정 -// ChatRoomTopBar( -// endFlow = state.endFlow, -// canEnd = state.canEnd, -// showEndButton = state.useChatEndFeature, -// onEndClick = actions.onEndClick, -// ) - GamssTopNavigation( - title = "YY.MM.DD", - titleAlignment = GamssTopNavigationTitleAlignment.Center, - showLeftIcon = true, - showRightIcon = true, + ChatRoomTopBar( + endFlow = state.endFlow, + canEnd = state.canEnd, + showEndButton = state.useChatEndFeature, + onEndClick = actions.onEndClick, + onBackClick = onBackClick ) }, // 상위 Scaffold 가 인셋을 이미 적용해, imePadding 을 그대로 쓰면 이중 적용된다. @@ -246,6 +240,7 @@ private fun ChatRoomContent( Column( modifier = Modifier .fillMaxSize() + .background(GamssTheme.colors.background) .padding(innerPadding) .windowInsetsPadding(WindowInsets.ime.exclude(WindowInsets.navigationBars)), ) { @@ -279,7 +274,6 @@ private fun ChatRoomContent( MessageBubble( message = message, messages = state.messages, - isReplyTarget = state.replyTarget?.messageId == message.id, onCharacterMessageClick = actions.onCharacterMessageClick, ) } @@ -288,15 +282,13 @@ private fun ChatRoomContent( } } - HorizontalDivider() - ChatRoomInputSection( endFlow = state.endFlow, input = state.input, // 전송 가능 여부(글자 유무)는 GamssInputBar 내부에서 계산한다. 여기서는 그 앞단 // 조건(전송 중·로딩 중·종료 흐름 진입)만 넘겨 입력칸 자체를 잠근다. isInputEnabled = !state.isSending && !state.isLoading && - state.endFlow == EndFlow.NotStarted, + state.endFlow == EndFlow.NotStarted, replyTarget = state.replyTarget, actions = actions, ) @@ -304,41 +296,37 @@ private fun ChatRoomContent( } } -//@Composable -//private fun ChatRoomTopBar( -// endFlow: EndFlow, -// canEnd: Boolean, -// showEndButton: Boolean, -// onEndClick: () -> Unit, -//) { -// Row( -// modifier = Modifier -// .fillMaxWidth() -// .padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 12.dp), -// verticalAlignment = Alignment.CenterVertically, -// ) { -// Text( -// text = "대화", -// style = MaterialTheme.typography.titleLarge, -// modifier = Modifier.weight(1f), -// ) -// if (showEndButton) { -// if (endFlow.isBusy) { -// CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) -// } else { -// TextButton(onClick = onEndClick, enabled = canEnd) { -// Text( -// when { -// endFlow == EndFlow.CardFailedRetryable -> "카드 다시 만들기" -// endFlow is EndFlow.Ended -> "끝난 대화" -// else -> "대화 끝내기" -// }, -// ) -// } -// } -// } -// } -//} +@Composable +private fun ChatRoomTopBar( + endFlow: EndFlow, + canEnd: Boolean, + showEndButton: Boolean, + onEndClick: () -> Unit, + onBackClick: () -> Unit, +) { + GamssTopNavigation( + title = "YY.MM.DD", + titleAlignment = GamssTopNavigationTitleAlignment.Center, + showLeftIcon = true, + onLeftIconClick = onBackClick, + rightActions = listOfNotNull( + /// TODO: showEndButton 이걸 release할때 조건 변경해야함 + if (!showEndButton) { + if (!endFlow.isBusy && canEnd) { + GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.CreateCard, + onClick = onEndClick, + ) + } else null + } else { + null + }, + GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Menu, onClick = { + // 토큰 확인 페이지? + }), + ), + ) +} @Composable private fun ChatRoomInputSection( @@ -361,15 +349,13 @@ private fun ChatRoomInputSection( return } - replyTarget?.let { - ReplyTargetBanner(replyTarget = it, onClear = actions.onReplyTargetClear) - } - MessageInputBar( input = input, enabled = isInputEnabled, + replyTarget = replyTarget, onInputChange = actions.onInputChange, onSendClick = actions.onSendClick, + onReplyClear = actions.onReplyTargetClear, ) } @@ -381,7 +367,6 @@ private fun ChatRoomInputSection( private fun MessageBubble( message: Message, messages: List, - isReplyTarget: Boolean, onCharacterMessageClick: (Message) -> Unit, modifier: Modifier = Modifier, ) { @@ -390,15 +375,7 @@ private fun MessageBubble( ?.toReplyQuote() Box( - modifier = modifier - .fillMaxWidth() - .then( - if (isReplyTarget) { - Modifier.background(GamssTheme.colors.blue.copy(alpha = 0.12f)) - } else { - Modifier - }, - ), + modifier = modifier.fillMaxWidth(), ) { when (val sender = message.sender) { MessageSender.User -> GamssSentChatBubble( @@ -407,6 +384,7 @@ private fun MessageBubble( replyQuote = replyQuote, modifier = Modifier.align(Alignment.CenterEnd), ) + is MessageSender.Character -> GamssReceivedChatBubble( sender = ChatSender(name = sender.character.displayName), message = message.content, @@ -414,8 +392,12 @@ private fun MessageBubble( replyQuote = replyQuote, modifier = Modifier .align(Alignment.CenterStart) - .clickable { onCharacterMessageClick(message) }, + .noRippleCombinedClickable( + onClick = { onCharacterMessageClick(message) }, + onLongClick = null + ), ) + MessageSender.Unknown -> GamssReceivedChatBubble( sender = ChatSender(name = ""), message = message.content, @@ -427,11 +409,16 @@ private fun MessageBubble( } } +@Composable private fun Message.toReplyQuote(): ChatReplyQuote { val label = when (val target = sender) { - is MessageSender.Character -> "${target.character.displayName}에게 답장" - MessageSender.User -> "나에게 답장" - MessageSender.Unknown -> "답장" + is MessageSender.Character -> stringResource( + R.string.chat_room_reply_to_character, + target.character.displayName, + ) + + MessageSender.User -> stringResource(R.string.chat_room_reply_to_me) + MessageSender.Unknown -> stringResource(R.string.chat_room_reply) } return ChatReplyQuote(senderLabel = label, message = content) } @@ -452,35 +439,14 @@ private fun GeneratingIndicator(modifier: Modifier = Modifier) { } } -@Composable -private fun ReplyTargetBanner( - replyTarget: ReplyTarget, - onClear: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(start = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "↩ ${replyTarget.characterName}에게 답장", - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = onClear) { - Icon(imageVector = Icons.Filled.Close, contentDescription = "답장 취소") - } - } -} - @Composable private fun MessageInputBar( input: String, enabled: Boolean, + replyTarget: ReplyTarget?, onInputChange: (String) -> Unit, onSendClick: () -> Unit, + onReplyClear: () -> Unit, ) { GamssInputBar( value = input, @@ -489,33 +455,24 @@ private fun MessageInputBar( modifier = Modifier .fillMaxWidth() .padding(horizontal = 18.dp, vertical = 12.dp), - placeholder = "지금 기분을 적어보세요", + placeholder = stringResource(R.string.chat_room_input_placeholder), trailingContentDescription = "보내기", enabled = enabled, maxLines = MESSAGE_INPUT_MAX_LINES, + replyQuote = replyTarget?.let { + ChatReplyQuote( + senderLabel = stringResource( + R.string.chat_room_reply_to_character, + it.characterName + ), + message = it.content, + ) + }, + onReplyClear = onReplyClear, + replyClearContentDescription = "답장 취소", ) } -private fun Context.dialOrNotify(phoneNumber: String?) { - if (phoneNumber == null) return - if (!dial(phoneNumber)) { - Toast.makeText( - this, - getString(R.string.safety_call_unavailable, phoneNumber), - Toast.LENGTH_LONG, - ).show() - } -} - -@Suppress("SwallowedException") -private fun Context.dial(phoneNumber: String): Boolean = - try { - startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:${Uri.encode(phoneNumber)}"))) - true - } catch (_: ActivityNotFoundException) { - false - } - private const val EMERGENCY_PHONE_NUMBER = "119" private const val MESSAGE_INPUT_MAX_LINES = 5 @@ -573,6 +530,11 @@ private fun ChatRoomPreviewContent() { messages = messages, input = "", useChatEndFeature = true, + replyTarget = ReplyTarget( + messageId = 1, + characterName = "기쁨", + content = "안녕! 오늘도 행복한 하루~!", + ), ) val actions = ChatRoomActions( onInputChange = {}, @@ -581,5 +543,5 @@ private fun ChatRoomPreviewContent() { onReplyTargetClear = {}, onEndClick = {}, ) - ChatRoomContent(state = state, actions = actions) + ChatRoomContent(state = state, actions = actions, onBackClick = {}) } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt index 40df4eb2..4aea2784 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt @@ -34,4 +34,5 @@ data class ChatRoomState( data class ReplyTarget( val messageId: Long, val characterName: String, + val content: String, ) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt index 34170837..9a84f592 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt @@ -94,7 +94,11 @@ class ChatRoomViewModel @Inject constructor( val character = (message.sender as? MessageSender.Character)?.character ?: return@intent reduce { state.copy( - replyTarget = ReplyTarget(messageId = message.id, characterName = character.displayName), + replyTarget = ReplyTarget( + messageId = message.id, + characterName = character.displayName, + content = message.content, + ), ) } } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/Utils.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/Utils.kt new file mode 100644 index 00000000..9d7603b5 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/Utils.kt @@ -0,0 +1,28 @@ +package com.gamss.android.feature.chat.util + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import com.gamss.android.feature.chat.R + +fun Context.dialOrNotify(phoneNumber: String?) { + if (phoneNumber == null) return + if (!dial(phoneNumber)) { + Toast.makeText( + this, + getString(R.string.safety_call_unavailable, phoneNumber), + Toast.LENGTH_LONG, + ).show() + } +} + +@Suppress("SwallowedException") +private fun Context.dial(phoneNumber: String): Boolean = + try { + startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:${Uri.encode(phoneNumber)}"))) + true + } catch (_: ActivityNotFoundException) { + false + } \ No newline at end of file diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml index b172d0e1..2db15c05 100644 --- a/feature/chat/src/main/res/values/strings.xml +++ b/feature/chat/src/main/res/values/strings.xml @@ -4,6 +4,10 @@ 감정 기록 생성 시 대화는 종료되며,\n더 이상 대화를 이어갈 수 없어요. 뒤로가기 감정 확인하기 + 메시지 입력 + %1$s에게 답장 + 나에게 답장 + 답장 혼자 감당하지 않아도 괜찮아요. 대화에서 도움이 필요하다는 신호가 확인됐어요.\n전문가와 이야기해 보는 걸 추천드려요. 24시간 상담이 가능해요. 누르면 바로 연결돼요. @@ -12,6 +16,7 @@ %1$s %2$s에 전화하기 119에 전화하기 전화 앱을 열 수 없어요. %1$s로 직접 전화해 주세요. + 닫기 삭제하기 대화를 정말 삭제할까요? From 0525149d128e53ac67760de8740b0da316b702c4 Mon Sep 17 00:00:00 2001 From: seunghee Date: Sat, 15 Aug 2026 18:15:27 +0900 Subject: [PATCH 08/43] =?UTF-8?q?feat:=20=EB=8C=80=ED=99=94=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=EC=83=9D=EC=84=B1=EC=9D=BC=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=20-=20=EB=8C=80=ED=99=94=20=EC=A0=95=EB=B3=B4=EC=99=80=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A5=BC=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EC=83=81=EC=84=B8=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80=20=20-=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=EA=B0=81=EC=9D=84=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=EB=B0=A9=20=EC=83=81=ED=83=9C=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EC=A0=84=EB=8B=AC=20=20-=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=EB=B0=A9=20=EC=83=81=EB=8B=A8=EC=97=90=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=ED=91=9C=EC=8B=9C=20=20-=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=ED=8F=AC=EB=A7=B7=20=EB=A1=9C=EC=A7=81=EC=9D=84=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gamss/android/core/common/util/Date.kt | 26 ++--- .../response/ConversationDetailResponse.kt | 18 +++ .../model/response/ConversationMessage.kt | 2 +- .../repository/ConversationRepositoryImpl.kt | 10 +- .../conversation/ConversationResponseTest.kt | 25 +++++ .../domain/conversation/ConversationDetail.kt | 6 + .../conversation/ConversationRepository.kt | 2 +- .../conversation/ConversationSession.kt | 8 +- ...esUseCase.kt => GetConversationUseCase.kt} | 8 +- .../conversation/ConversationSessionTest.kt | 11 +- .../DeleteConversationUseCaseTest.kt | 2 +- .../DeleteConversationsUseCaseTest.kt | 2 +- .../conversation/SendMessageUseCaseTest.kt | 9 +- .../UpdateConversationTitleUseCaseTest.kt | 2 +- .../android/feature/chat/ChatRoomScreen.kt | 106 +++++++++++------- .../android/feature/chat/ChatRoomState.kt | 2 + .../android/feature/chat/ChatRoomViewModel.kt | 9 +- .../gamss/android/feature/chat/util/Utils.kt | 2 +- .../android/feature/chat/ChatRoomTestFakes.kt | 15 ++- .../feature/chat/ChattingListTestFakes.kt | 4 +- .../android/feature/home/HomeTestFakes.kt | 14 ++- 21 files changed, 185 insertions(+), 98 deletions(-) create mode 100644 data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationDetailResponse.kt create mode 100644 domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationDetail.kt rename domain/src/main/kotlin/com/gamss/android/domain/conversation/{GetMessagesUseCase.kt => GetConversationUseCase.kt} (50%) diff --git a/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt b/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt index adaced20..772c3530 100644 --- a/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt +++ b/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt @@ -1,34 +1,19 @@ package com.gamss.android.core.common.util -import java.time.Instant import java.time.LocalDateTime -import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException import java.util.Locale private const val KOREAN_TIME_PATTERN = "a h:mm" - -private val KoreanTimeZone: ZoneId = ZoneId.of("Asia/Seoul") -private val KoreanTimeFormatter: DateTimeFormatter = DateTimeFormatter - .ofPattern(KOREAN_TIME_PATTERN, Locale.KOREAN) - .withZone(KoreanTimeZone) +private const val CONVERSATION_DATE_PATTERN = "yy.MM.dd" // 존을 덮지 않는다. 이미 벽시계로 변환된 값을 그대로 찍는 용도다. private val KoreanWallClockFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern(KOREAN_TIME_PATTERN, Locale.KOREAN) -/** - * UTC ISO-8601 날짜 문자열을 한국 시간의 `오전/오후 h:mm` 형식으로 변환한다. - * - * [isoDateTime]이 ISO-8601 형식이 아니면 `null`을 반환한다. - */ -fun formatKoreanTime(isoDateTime: String): String? = - try { - KoreanTimeFormatter.format(Instant.parse(isoDateTime)) - } catch (_: DateTimeParseException) { - null - } +// 불교력이나 일본력 로케일에서 연도가 밀리지 않게 ROOT 로 고정한다. +private val ConversationDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern(CONVERSATION_DATE_PATTERN, Locale.ROOT) /** * 이미 벽시계로 바뀐 시각을 `오전/오후 h:mm` 형식으로 변환한다. @@ -37,3 +22,6 @@ fun formatKoreanTime(isoDateTime: String): String? = * 기기 로케일이 영어여도 `AM/PM` 으로 바뀌지 않는다. */ fun formatKoreanTime(dateTime: LocalDateTime): String = KoreanWallClockFormatter.format(dateTime) + +/** 대화방 생성 시각을 `yy.MM.dd` 형식의 날짜로 표시한다. */ +fun formatConversationDate(dateTime: LocalDateTime): String = ConversationDateFormatter.format(dateTime) diff --git a/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationDetailResponse.kt b/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationDetailResponse.kt new file mode 100644 index 00000000..7e2e1c0f --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationDetailResponse.kt @@ -0,0 +1,18 @@ +package com.gamss.android.data.remote.conversation.model.response + +import com.gamss.android.domain.conversation.ConversationDetail +import kotlinx.serialization.Serializable + +@Serializable +internal data class ConversationDetailResponse( + val conversation: ConversationResponse, + val messages: List = emptyList(), +) + +internal fun ConversationDetailResponse.toDomain(): ConversationDetail? = + conversation.toDomain()?.let { conversation -> + ConversationDetail( + conversation = conversation, + messages = messages.map(ConversationMessage::toDomain), + ) + } diff --git a/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationMessage.kt b/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationMessage.kt index 18fa28d8..42de644b 100644 --- a/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationMessage.kt +++ b/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationMessage.kt @@ -47,7 +47,7 @@ private fun ConversationMessage.toMessage(sender: MessageSender): Message = Mess sender = sender, content = content, repliesToMessageId = repliesToMessageId, - createdTime = formatKoreanTime(createdAt) + createdTime = parseConversationCreatedAt(createdAt)?.let(::formatKoreanTime) ) private fun ConversationMessage.resolveSender(): MessageSender = when (senderType) { diff --git a/data/src/main/java/com/gamss/android/data/repository/ConversationRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/ConversationRepositoryImpl.kt index 13dbd06f..7d5caca2 100644 --- a/data/src/main/java/com/gamss/android/data/repository/ConversationRepositoryImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/ConversationRepositoryImpl.kt @@ -11,15 +11,14 @@ import com.gamss.android.data.di.ApplicationScope import com.gamss.android.data.remote.conversation.ConversationService import com.gamss.android.data.remote.conversation.model.request.SaveMessageRequest import com.gamss.android.data.remote.conversation.model.request.UpdateConversationTitleRequest -import com.gamss.android.data.remote.conversation.model.response.ConversationMessage import com.gamss.android.data.remote.conversation.model.response.ConversationResponse import com.gamss.android.data.remote.conversation.model.response.toDomain import com.gamss.android.data.remote.emotion.toServerEmotionType import com.gamss.android.data.remote.runCatchingApiCall import com.gamss.android.data.remote.throwIfFailed import com.gamss.android.domain.conversation.Conversation +import com.gamss.android.domain.conversation.ConversationDetail import com.gamss.android.domain.conversation.ConversationRepository -import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.SentMessage import com.gamss.android.domain.conversation.chattingsearch.ChattingRoomSummary import com.gamss.android.domain.emotion.EmotionCharacter @@ -62,10 +61,9 @@ internal class ConversationRepositoryImpl @Inject constructor( checkNotNull(response.data) { "No available saved message data" }.toDomain() } - override suspend fun getMessages(conversationId: Long): AppResult> = runCatchingApiCall { - val response = conversationService.getMessages(conversationId) - checkNotNull(response.data) { "No available message data" } - .mapNotNull(ConversationMessage::toDomain) + override suspend fun getConversation(conversationId: Long): AppResult = runCatchingApiCall { + val response = conversationService.getConversation(conversationId) + checkNotNull(response.data?.toDomain()) { "No available conversation detail data" } } override suspend fun updateTitle(conversationId: Long, title: String): AppResult = diff --git a/data/src/test/kotlin/com/gamss/android/data/remote/conversation/ConversationResponseTest.kt b/data/src/test/kotlin/com/gamss/android/data/remote/conversation/ConversationResponseTest.kt index f4882b4a..3114ed0d 100644 --- a/data/src/test/kotlin/com/gamss/android/data/remote/conversation/ConversationResponseTest.kt +++ b/data/src/test/kotlin/com/gamss/android/data/remote/conversation/ConversationResponseTest.kt @@ -1,5 +1,7 @@ package com.gamss.android.data.remote.conversation +import com.gamss.android.data.remote.conversation.model.response.ConversationDetailResponse +import com.gamss.android.data.remote.conversation.model.response.ConversationMessage import com.gamss.android.data.remote.conversation.model.response.ConversationResponse import com.gamss.android.data.remote.conversation.model.response.parseConversationCreatedAt import com.gamss.android.data.remote.conversation.model.response.toDomain @@ -75,4 +77,27 @@ class ConversationResponseTest { fun 빈_문자열_시각은_null_이다() { assertNull(parseConversationCreatedAt(" ")) } + + @Test + fun 대화_상세는_생성_시각과_메시지를_도메인으로_옮긴다() { + val detail = ConversationDetailResponse( + conversation = ConversationResponse( + id = 37, + title = "비 오는 날의 짜증", + createdAt = "2026-08-15T17:16:52.320", + ), + messages = listOf( + ConversationMessage( + id = 1, + conversationId = 37, + senderType = ConversationMessage.SENDER_USER, + content = "오늘 억울한 일이 있었어", + createdAt = "2026-08-15T17:16:52.320", + ), + ), + ).toDomain() + + assertEquals(LocalDateTime.of(2026, 8, 15, 17, 16, 52, 320_000_000), detail?.conversation?.createdAt) + assertEquals(listOf(1L), detail?.messages?.map { it.id }) + } } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationDetail.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationDetail.kt new file mode 100644 index 00000000..38497cef --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationDetail.kt @@ -0,0 +1,6 @@ +package com.gamss.android.domain.conversation + +data class ConversationDetail( + val conversation: Conversation, + val messages: List, +) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationRepository.kt index 70456456..10b1bcbf 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationRepository.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationRepository.kt @@ -22,7 +22,7 @@ interface ConversationRepository { excludeCharacters: Set, ): AppResult - suspend fun getMessages(conversationId: Long): AppResult> + suspend fun getConversation(conversationId: Long): AppResult suspend fun updateTitle(conversationId: Long, title: String): AppResult diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt index cda89e1c..59ca6aa8 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt @@ -17,7 +17,7 @@ import javax.inject.Inject @Suppress("LongParameterList") class ConversationSession @Inject constructor( private val sendMessage: SendMessageUseCase, - private val getMessages: GetMessagesUseCase, + private val getConversation: GetConversationUseCase, private val updateConversationTitle: UpdateConversationTitleUseCase, private val endConversation: EndConversationUseCase, private val createConversationCard: CreateConversationCardUseCase, @@ -30,12 +30,12 @@ class ConversationSession @Inject constructor( private var pendingTitle: PendingTitle? = null - suspend fun restore(conversationId: Long): AppResult> { + suspend fun restore(conversationId: Long): AppResult { clearPendingTitle() - val result = getMessages(conversationId) + val result = getConversation(conversationId) when (result) { is AppResult.Success -> { - val utterances = result.data.userUtterances() + val utterances = result.data.messages.userUtterances() summaryStore.restore(utterances) emotionAccumulator.restore(utterances) } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/GetMessagesUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/GetConversationUseCase.kt similarity index 50% rename from domain/src/main/kotlin/com/gamss/android/domain/conversation/GetMessagesUseCase.kt rename to domain/src/main/kotlin/com/gamss/android/domain/conversation/GetConversationUseCase.kt index 7e89f2d0..30a2d3ec 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/GetMessagesUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/GetConversationUseCase.kt @@ -4,10 +4,10 @@ import com.gamss.android.core.common.AppResult import com.gamss.android.domain.usecase.UseCase import javax.inject.Inject -class GetMessagesUseCase @Inject constructor( +class GetConversationUseCase @Inject constructor( private val conversationRepository: ConversationRepository, -) : UseCase>> { +) : UseCase> { - override suspend fun invoke(params: Long): AppResult> = - conversationRepository.getMessages(params) + override suspend fun invoke(params: Long): AppResult = + conversationRepository.getConversation(params) } diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt index 35e3ce79..0c032939 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt @@ -258,7 +258,7 @@ class ConversationSessionTest { pendingReveal: PendingConversationReveal = PendingConversationReveal(), ) = ConversationSession( sendMessage = SendMessageUseCase(repository), - getMessages = GetMessagesUseCase(repository), + getConversation = GetConversationUseCase(repository), updateConversationTitle = UpdateConversationTitleUseCase(repository), endConversation = EndConversationUseCase(repository), createConversationCard = CreateConversationCardUseCase( @@ -350,8 +350,13 @@ class ConversationSessionTest { override suspend fun getOngoingConversations(): AppResult> = AppResult.Success(emptyList()) - override suspend fun getMessages(conversationId: Long): AppResult> = - AppResult.Success(emptyList()) + override suspend fun getConversation(conversationId: Long): AppResult = + AppResult.Success( + ConversationDetail( + conversation = Conversation(id = conversationId, title = null), + messages = emptyList(), + ), + ) override suspend fun updateTitle(conversationId: Long, title: String): AppResult { updatedTitles += conversationId to title diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationUseCaseTest.kt index a23a0632..da987ba7 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationUseCaseTest.kt @@ -51,7 +51,7 @@ class DeleteConversationUseCaseTest { override suspend fun getOngoingConversations(): AppResult> = throw UnsupportedOperationException() - override suspend fun getMessages(conversationId: Long): AppResult> = + override suspend fun getConversation(conversationId: Long): AppResult = throw UnsupportedOperationException() override suspend fun updateTitle(conversationId: Long, title: String): AppResult = diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationsUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationsUseCaseTest.kt index a51160dd..f9a0a3ee 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationsUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationsUseCaseTest.kt @@ -205,7 +205,7 @@ class DeleteConversationsUseCaseTest { override suspend fun getOngoingConversations(): AppResult> = throw UnsupportedOperationException() - override suspend fun getMessages(conversationId: Long): AppResult> = + override suspend fun getConversation(conversationId: Long): AppResult = throw UnsupportedOperationException() override suspend fun updateTitle(conversationId: Long, title: String): AppResult = diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/SendMessageUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/SendMessageUseCaseTest.kt index e4d9812c..fc942745 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/SendMessageUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/SendMessageUseCaseTest.kt @@ -60,8 +60,13 @@ class SendMessageUseCaseTest { override suspend fun getOngoingConversations(): AppResult> = AppResult.Success(emptyList()) - override suspend fun getMessages(conversationId: Long): AppResult> = - AppResult.Success(emptyList()) + override suspend fun getConversation(conversationId: Long): AppResult = + AppResult.Success( + ConversationDetail( + conversation = Conversation(id = conversationId, title = null), + messages = emptyList(), + ), + ) override suspend fun updateTitle(conversationId: Long, title: String): AppResult = AppResult.Success(Unit) diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/UpdateConversationTitleUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/UpdateConversationTitleUseCaseTest.kt index 28cddc76..b696f888 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/UpdateConversationTitleUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/UpdateConversationTitleUseCaseTest.kt @@ -89,7 +89,7 @@ class UpdateConversationTitleUseCaseTest { override suspend fun getOngoingConversations(): AppResult> = throw UnsupportedOperationException() - override suspend fun getMessages(conversationId: Long): AppResult> = + override suspend fun getConversation(conversationId: Long): AppResult = throw UnsupportedOperationException() override suspend fun updateTitle(conversationId: Long, title: String): AppResult { diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 1c01bd2a..07f2abfc 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -1,6 +1,5 @@ package com.gamss.android.feature.chat - import android.content.res.Configuration import android.widget.Toast import androidx.compose.foundation.background @@ -20,6 +19,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.CircularProgressIndicator @@ -44,6 +44,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.gamss.android.core.common.util.formatConversationDate import com.gamss.android.core.designsystem.button.GamssButtonVariant import com.gamss.android.core.designsystem.component.GamssInputBar import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote @@ -66,6 +67,7 @@ import com.gamss.android.feature.chat.component.SupportAgencyDialog import com.gamss.android.feature.chat.util.dialOrNotify import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect +import java.time.LocalDateTime /** * @param onCardClose 카드 시트를 닫을 때 호출한다. 이 화면을 실제로 벗어나야 한다. @@ -105,7 +107,7 @@ fun ChatRoomScreen( state = state, actions = actions, modifier = modifier, - onBackClick = onBackClick + onBackClick = onBackClick, ) state.riskDetection?.let { detection -> @@ -130,7 +132,7 @@ fun ChatRoomScreen( EndFlow.CreatingCard, EndFlow.CardFailedRetryable, EndFlow.CardFailedFinal, - -> Unit + -> Unit } } @@ -227,11 +229,12 @@ private fun ChatRoomContent( modifier = modifier, topBar = { ChatRoomTopBar( + conversationCreatedAt = state.conversationCreatedAt, endFlow = state.endFlow, canEnd = state.canEnd, showEndButton = state.useChatEndFeature, onEndClick = actions.onEndClick, - onBackClick = onBackClick + onBackClick = onBackClick, ) }, // 상위 Scaffold 가 인셋을 이미 적용해, imePadding 을 그대로 쓰면 이중 적용된다. @@ -244,43 +247,14 @@ private fun ChatRoomContent( .padding(innerPadding) .windowInsetsPadding(WindowInsets.ime.exclude(WindowInsets.navigationBars)), ) { - LazyColumn( - state = listState, + ChatMessageList( + state = state, + actions = actions, + listState = listState, modifier = Modifier .weight(1f) .fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (state.messages.isEmpty()) { - item { - Box( - modifier = Modifier.fillParentMaxSize(), - contentAlignment = Alignment.Center, - ) { - if (state.isLoading) { - CircularProgressIndicator() - } else { - Text( - text = "오늘 어떤 일이 있었나요?", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - items(state.messages, key = { it.id }) { message -> - MessageBubble( - message = message, - messages = state.messages, - onCharacterMessageClick = actions.onCharacterMessageClick, - ) - } - if (state.isAwaitingComments) { - item { GeneratingIndicator() } - } - } + ) ChatRoomInputSection( endFlow = state.endFlow, @@ -288,7 +262,7 @@ private fun ChatRoomContent( // 전송 가능 여부(글자 유무)는 GamssInputBar 내부에서 계산한다. 여기서는 그 앞단 // 조건(전송 중·로딩 중·종료 흐름 진입)만 넘겨 입력칸 자체를 잠근다. isInputEnabled = !state.isSending && !state.isLoading && - state.endFlow == EndFlow.NotStarted, + state.endFlow == EndFlow.NotStarted, replyTarget = state.replyTarget, actions = actions, ) @@ -296,8 +270,53 @@ private fun ChatRoomContent( } } +@Composable +private fun ChatMessageList( + state: ChatRoomState, + actions: ChatRoomActions, + listState: LazyListState, + modifier: Modifier = Modifier, +) { + LazyColumn( + state = listState, + modifier = modifier, + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (state.messages.isEmpty()) { + item { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (state.isLoading) { + CircularProgressIndicator() + } else { + Text( + text = "오늘 어떤 일이 있었나요?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + items(state.messages, key = { it.id }) { message -> + MessageBubble( + message = message, + messages = state.messages, + onCharacterMessageClick = actions.onCharacterMessageClick, + ) + } + if (state.isAwaitingComments) { + item { GeneratingIndicator() } + } + } +} + @Composable private fun ChatRoomTopBar( + conversationCreatedAt: LocalDateTime?, endFlow: EndFlow, canEnd: Boolean, showEndButton: Boolean, @@ -305,19 +324,20 @@ private fun ChatRoomTopBar( onBackClick: () -> Unit, ) { GamssTopNavigation( - title = "YY.MM.DD", + title = conversationCreatedAt?.let(::formatConversationDate).orEmpty(), titleAlignment = GamssTopNavigationTitleAlignment.Center, showLeftIcon = true, onLeftIconClick = onBackClick, rightActions = listOfNotNull( - /// TODO: showEndButton 이걸 release할때 조건 변경해야함 - if (!showEndButton) { + if (showEndButton) { if (!endFlow.isBusy && canEnd) { GamssTopNavigationIconAction( icon = GamssTopNavigationIcon.CreateCard, onClick = onEndClick, ) - } else null + } else { + null + } } else { null }, diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt index 4aea2784..8c8ebe57 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt @@ -3,9 +3,11 @@ package com.gamss.android.feature.chat import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.safety.RiskDetection +import java.time.LocalDateTime data class ChatRoomState( val conversationId: Long? = null, + val conversationCreatedAt: LocalDateTime? = null, val messages: List = emptyList(), val pendingComments: List = emptyList(), val input: String = "", diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt index 9a84f592..718fa017 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt @@ -78,7 +78,14 @@ class ChatRoomViewModel @Inject constructor( when (val result = session.restore(conversationId)) { is AppResult.Success -> - reduce { state.copy(isLoading = false, messages = result.data, pendingComments = emptyList()) } + reduce { + state.copy( + isLoading = false, + conversationCreatedAt = result.data.conversation.createdAt, + messages = result.data.messages, + pendingComments = emptyList(), + ) + } is AppResult.Failure -> { reduce { state.copy(isLoading = false) } postSideEffect(ChatRoomSideEffect.ShowToast(LOAD_FAILED)) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/Utils.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/Utils.kt index 9d7603b5..2b40cba3 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/Utils.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/Utils.kt @@ -25,4 +25,4 @@ private fun Context.dial(phoneNumber: String): Boolean = true } catch (_: ActivityNotFoundException) { false - } \ No newline at end of file + } diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt index 8a10a84c..9239f560 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt @@ -11,11 +11,12 @@ import com.gamss.android.domain.config.RemoteConfigKey import com.gamss.android.domain.config.RemoteConfigRepository import com.gamss.android.domain.conversation.CommentGenerationStatus import com.gamss.android.domain.conversation.Conversation +import com.gamss.android.domain.conversation.ConversationDetail import com.gamss.android.domain.conversation.ConversationRepository import com.gamss.android.domain.conversation.ConversationSession import com.gamss.android.domain.conversation.ConversationSummaryStore import com.gamss.android.domain.conversation.EndConversationUseCase -import com.gamss.android.domain.conversation.GetMessagesUseCase +import com.gamss.android.domain.conversation.GetConversationUseCase import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.conversation.PendingConversationReveal @@ -58,7 +59,7 @@ internal fun chatRoomViewModel( pendingReveal: PendingConversationReveal = PendingConversationReveal(), session: ConversationSession = ConversationSession( sendMessage = SendMessageUseCase(conversationRepository), - getMessages = GetMessagesUseCase(conversationRepository), + getConversation = GetConversationUseCase(conversationRepository), updateConversationTitle = UpdateConversationTitleUseCase(conversationRepository), endConversation = EndConversationUseCase(conversationRepository), createConversationCard = CreateConversationCardUseCase( @@ -141,6 +142,7 @@ internal class FakeConversationRepository( private val commentCount: Int = 0, private val failing: Boolean = false, private val endFailing: Boolean = false, + private val restoredConversation: Conversation = Conversation(id = ROOM_ID, title = null), ) : ConversationRepository { private var sentCount = 0 @@ -181,8 +183,13 @@ internal class FakeConversationRepository( ) } - override suspend fun getMessages(conversationId: Long): AppResult> = - AppResult.Success(emptyList()) + override suspend fun getConversation(conversationId: Long): AppResult = + AppResult.Success( + ConversationDetail( + conversation = restoredConversation, + messages = emptyList(), + ), + ) override suspend fun getOngoingConversations(): AppResult> = AppResult.Success(emptyList()) diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChattingListTestFakes.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChattingListTestFakes.kt index a60e9534..b236e618 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChattingListTestFakes.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChattingListTestFakes.kt @@ -3,10 +3,10 @@ package com.gamss.android.feature.chat import androidx.paging.PagingData import com.gamss.android.core.common.AppResult import com.gamss.android.domain.conversation.Conversation +import com.gamss.android.domain.conversation.ConversationDetail import com.gamss.android.domain.conversation.ConversationRepository import com.gamss.android.domain.conversation.DeleteConversationsUseCase import com.gamss.android.domain.conversation.GetOngoingConversationsUseCase -import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.SentMessage import com.gamss.android.domain.conversation.chattingsearch.ChattingRoomSummary import com.gamss.android.domain.conversation.chattingsearch.SearchChattingRoomsUseCase @@ -75,7 +75,7 @@ internal class FakeChattingListRepository( excludeCharacters: Set, ): AppResult = error("목록 테스트에서 쓰지 않는다") - override suspend fun getMessages(conversationId: Long): AppResult> = + override suspend fun getConversation(conversationId: Long): AppResult = error("목록 테스트에서 쓰지 않는다") override suspend fun updateTitle(conversationId: Long, title: String): AppResult = diff --git a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt index 32795419..c31c15b3 100644 --- a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt +++ b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt @@ -8,11 +8,12 @@ import com.gamss.android.domain.card.CreateCardUseCase import com.gamss.android.domain.card.CreateConversationCardUseCase import com.gamss.android.domain.conversation.CommentGenerationStatus import com.gamss.android.domain.conversation.Conversation +import com.gamss.android.domain.conversation.ConversationDetail import com.gamss.android.domain.conversation.ConversationRepository import com.gamss.android.domain.conversation.ConversationSession import com.gamss.android.domain.conversation.ConversationSummaryStore import com.gamss.android.domain.conversation.EndConversationUseCase -import com.gamss.android.domain.conversation.GetMessagesUseCase +import com.gamss.android.domain.conversation.GetConversationUseCase import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.conversation.PendingConversationReveal @@ -36,7 +37,7 @@ internal const val NEW_ROOM_ID = 42L internal fun conversationSession(repository: ConversationRepository) = ConversationSession( sendMessage = SendMessageUseCase(repository), - getMessages = GetMessagesUseCase(repository), + getConversation = GetConversationUseCase(repository), updateConversationTitle = UpdateConversationTitleUseCase(repository), endConversation = EndConversationUseCase(repository), createConversationCard = CreateConversationCardUseCase( @@ -103,8 +104,13 @@ internal class RecordingConversationRepository( override suspend fun getOngoingConversations(): AppResult> = AppResult.Success(emptyList()) - override suspend fun getMessages(conversationId: Long): AppResult> = - AppResult.Success(emptyList()) + override suspend fun getConversation(conversationId: Long): AppResult = + AppResult.Success( + ConversationDetail( + conversation = Conversation(id = conversationId, title = null), + messages = emptyList(), + ), + ) override suspend fun updateTitle(conversationId: Long, title: String): AppResult = AppResult.Success(Unit) From 22b9bb335fbb15bd2611717a537e8fac5e84cdc6 Mon Sep 17 00:00:00 2001 From: seunghee Date: Sat, 15 Aug 2026 18:37:52 +0900 Subject: [PATCH 09/43] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EC=86=A1?= =?UTF-8?q?=EC=88=98=EC=8B=A0=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/chat/ChatMessageAnimation.kt | 86 +++++++++++++++++++ .../android/feature/chat/ChatRoomScreen.kt | 23 +++-- 2 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 feature/chat/src/main/java/com/gamss/android/feature/chat/ChatMessageAnimation.kt diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatMessageAnimation.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatMessageAnimation.kt new file mode 100644 index 00000000..4c3598fb --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatMessageAnimation.kt @@ -0,0 +1,86 @@ +package com.gamss.android.feature.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember + +/** + * 진입시 표시되는 메시지는 그대로 표시하고, 그 이후 목록에 추가된 메시지만 진입 애니메이션 대상으로 관리한다. + */ +internal class ChatMessageAnimation { + private val displayedMessageIds = mutableSetOf() + private var hasCapturedInitialMessages = false + + fun shouldAnimate(messageId: Long): Boolean = + hasCapturedInitialMessages && messageId !in displayedMessageIds + + fun markDisplayed(messageIds: List) { + displayedMessageIds += messageIds + hasCapturedInitialMessages = true + } +} + +@Composable +internal fun rememberChatMessageAnimationState( + conversationId: Long?, + isLoading: Boolean, + messageIds: List, +): ChatMessageAnimation { + val animationState = remember(conversationId) { ChatMessageAnimation() } + + SideEffect { + if (conversationId != null && !isLoading) { + animationState.markDisplayed(messageIds) + } + } + + return animationState +} + +/** 메시지 목록 하단(입력창과 맞닿는 위치)에서 최종 말풍선 위치까지 메시지를 올려 보낸다. */ +@Composable +internal fun AnimatedChatMessage( + messageId: Long, + shouldAnimate: Boolean, + listState: LazyListState, + content: @Composable () -> Unit, +) { + if (!shouldAnimate) { + content() + return + } + + val visibilityState = remember(messageId) { + MutableTransitionState(false).apply { targetState = true } + } + AnimatedVisibility( + visibleState = visibilityState, + enter = slideInVertically( + animationSpec = tween( + durationMillis = MESSAGE_ENTER_DURATION_MILLIS, + easing = FastOutSlowInEasing, + ), + initialOffsetY = { messageHeight -> + listState.offsetFromInput(messageId, messageHeight) + }, + ) + fadeIn(animationSpec = tween(durationMillis = MESSAGE_FADE_IN_DURATION_MILLIS)), + ) { + content() + } +} + +private fun LazyListState.offsetFromInput(messageId: Long, messageHeight: Int): Int { + val messageItem = layoutInfo.visibleItemsInfo.firstOrNull { it.key == messageId } + ?: return messageHeight + return (layoutInfo.viewportEndOffset - messageItem.offset).coerceAtLeast(messageHeight) +} + +private const val MESSAGE_ENTER_DURATION_MILLIS = 350 +private const val MESSAGE_FADE_IN_DURATION_MILLIS = 180 diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 07f2abfc..88d6dc68 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -204,6 +204,11 @@ private fun ChatRoomContent( onBackClick: () -> Unit, ) { val listState = rememberLazyListState() + val messageAnimationState = rememberChatMessageAnimationState( + conversationId = state.conversationId, + isLoading = state.isLoading, + messageIds = state.messages.map(Message::id), + ) LaunchedEffect(state.messages.size, state.isAwaitingComments) { val itemCount = state.messages.size + if (state.isAwaitingComments) 1 else 0 @@ -251,6 +256,7 @@ private fun ChatRoomContent( state = state, actions = actions, listState = listState, + animationState = messageAnimationState, modifier = Modifier .weight(1f) .fillMaxWidth(), @@ -275,6 +281,7 @@ private fun ChatMessageList( state: ChatRoomState, actions: ChatRoomActions, listState: LazyListState, + animationState: ChatMessageAnimation, modifier: Modifier = Modifier, ) { LazyColumn( @@ -302,11 +309,17 @@ private fun ChatMessageList( } } items(state.messages, key = { it.id }) { message -> - MessageBubble( - message = message, - messages = state.messages, - onCharacterMessageClick = actions.onCharacterMessageClick, - ) + AnimatedChatMessage( + messageId = message.id, + shouldAnimate = animationState.shouldAnimate(message.id), + listState = listState, + ) { + MessageBubble( + message = message, + messages = state.messages, + onCharacterMessageClick = actions.onCharacterMessageClick, + ) + } } if (state.isAwaitingComments) { item { GeneratingIndicator() } From cd92856779229a925e672ba151c1bc133c1549ec Mon Sep 17 00:00:00 2001 From: seunghee Date: Sun, 16 Aug 2026 18:12:28 +0900 Subject: [PATCH 10/43] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=A7=84=EC=9E=85=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=9E=90=EC=97=B0=EC=8A=A4?= =?UTF-8?q?=EB=9F=BD=EA=B2=8C=20=EA=B0=9C=EC=84=A0=20-=20slide=20=EC=99=80?= =?UTF-8?q?=20fade=20=EA=B0=80=20=EC=84=9C=EB=A1=9C=20=EB=8B=A4=EB=A5=B8?= =?UTF-8?q?=20duration=20=EC=9D=98=20tween=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=94=B0=EB=A1=9C=20=EB=81=9D=EB=82=98=EB=A9=B0=20=EB=81=8A?= =?UTF-8?q?=EA=B2=A8=20=EB=B3=B4=EC=9D=B4=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= =?UTF-8?q?=EB=A5=BC=20spring=20=EC=8A=A4=ED=8E=99=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=B4=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EC=9D=98=20=EC=9C=A0=EB=A0=A4=ED=95=9C=20=EC=9B=80=EC=A7=81?= =?UTF-8?q?=EC=9E=84=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EC=96=B4=EC=A7=80?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat/{ => util}/ChatMessageAnimation.kt | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) rename feature/chat/src/main/java/com/gamss/android/feature/chat/{ => util}/ChatMessageAnimation.kt (74%) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatMessageAnimation.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatMessageAnimation.kt similarity index 74% rename from feature/chat/src/main/java/com/gamss/android/feature/chat/ChatMessageAnimation.kt rename to feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatMessageAnimation.kt index 4c3598fb..777534f7 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatMessageAnimation.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatMessageAnimation.kt @@ -1,9 +1,9 @@ -package com.gamss.android.feature.chat +package com.gamss.android.feature.chat.util import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInVertically import androidx.compose.foundation.lazy.LazyListState @@ -62,15 +62,22 @@ internal fun AnimatedChatMessage( } AnimatedVisibility( visibleState = visibilityState, + // slide 와 fade 를 동일한 스프링 스펙으로 묶어, 두 효과가 서로 다른 타이밍으로 끝나며 + // 끊겨 보이던 문제를 없애고 하나의 유려한 움직임으로 이어지게 한다. enter = slideInVertically( - animationSpec = tween( - durationMillis = MESSAGE_ENTER_DURATION_MILLIS, - easing = FastOutSlowInEasing, + animationSpec = spring( + dampingRatio = MessageEnterDampingRatio, + stiffness = MessageEnterStiffness, ), initialOffsetY = { messageHeight -> listState.offsetFromInput(messageId, messageHeight) }, - ) + fadeIn(animationSpec = tween(durationMillis = MESSAGE_FADE_IN_DURATION_MILLIS)), + ) + fadeIn( + animationSpec = spring( + dampingRatio = MessageEnterDampingRatio, + stiffness = MessageEnterStiffness, + ), + ), ) { content() } @@ -82,5 +89,5 @@ private fun LazyListState.offsetFromInput(messageId: Long, messageHeight: Int): return (layoutInfo.viewportEndOffset - messageItem.offset).coerceAtLeast(messageHeight) } -private const val MESSAGE_ENTER_DURATION_MILLIS = 350 -private const val MESSAGE_FADE_IN_DURATION_MILLIS = 180 +private const val MessageEnterDampingRatio = Spring.DampingRatioLowBouncy +private const val MessageEnterStiffness = Spring.StiffnessMediumLow From 5ac53f6b17cb5050e4123135966ebd7ad8b12929 Mon Sep 17 00:00:00 2001 From: seunghee Date: Sun, 16 Aug 2026 18:13:10 +0900 Subject: [PATCH 11/43] =?UTF-8?q?fix:=20=EC=A0=84=EC=86=A1=20=EC=A4=91?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=EC=9E=85=EB=A0=A5=EC=B0=BD=20=ED=8F=AC?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=EC=99=80=20=ED=82=A4=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=20-=20=EC=A0=84=EC=86=A1=20=EC=A4=91?= =?UTF-8?q?=EC=97=90=20=EC=9E=85=EB=A0=A5=EC=B0=BD=EC=9D=84=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=ED=95=98=EB=8D=98=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EC=9D=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gamss/android/feature/chat/ChatRoomScreen.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 88d6dc68..015d0c35 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -64,7 +64,10 @@ import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.feature.chat.component.SupportAgencyDialog +import com.gamss.android.feature.chat.util.AnimatedChatMessage +import com.gamss.android.feature.chat.util.ChatMessageAnimation import com.gamss.android.feature.chat.util.dialOrNotify +import com.gamss.android.feature.chat.util.rememberChatMessageAnimationState import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect import java.time.LocalDateTime @@ -266,9 +269,10 @@ private fun ChatRoomContent( endFlow = state.endFlow, input = state.input, // 전송 가능 여부(글자 유무)는 GamssInputBar 내부에서 계산한다. 여기서는 그 앞단 - // 조건(전송 중·로딩 중·종료 흐름 진입)만 넘겨 입력칸 자체를 잠근다. - isInputEnabled = !state.isSending && !state.isLoading && - state.endFlow == EndFlow.NotStarted, + // 조건(로딩 중·종료 흐름 진입)만 넘겨 입력칸 자체를 잠근다. 전송 중(isSending)에 + // 여기서 잠그면 텍스트필드가 disabled 되며 포커스와 키보드가 함께 내려가므로 넣지 + // 않는다 — 연타 방지는 이미 ChatRoomState.canSend/onSend 쪽에서 보장된다. + isInputEnabled = !state.isLoading && state.endFlow == EndFlow.NotStarted, replyTarget = state.replyTarget, actions = actions, ) From 664441c4edba0d2cfa86bad40212e3a12c044fea Mon Sep 17 00:00:00 2001 From: seunghee Date: Sun, 16 Aug 2026 18:41:31 +0900 Subject: [PATCH 12/43] =?UTF-8?q?feat:=20ChatRoomScreen=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EB=90=98=EB=8A=94=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/feature/chat/ChatRoomScreen.kt | 194 +++--------------- .../chat/component/EndConversationDialog.kt | 29 +++ .../feature/chat/component/MessageBubble.kt | 80 ++++++++ .../feature/chat/component/MessageInputBar.kt | 48 +++++ 4 files changed, 183 insertions(+), 168 deletions(-) create mode 100644 feature/chat/src/main/java/com/gamss/android/feature/chat/component/EndConversationDialog.kt create mode 100644 feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt create mode 100644 feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 015d0c35..6fbd9287 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -63,6 +63,9 @@ import com.gamss.android.domain.card.Card import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.emotion.EmotionCharacter +import com.gamss.android.feature.chat.component.EndConversationDialog +import com.gamss.android.feature.chat.component.MessageBubble +import com.gamss.android.feature.chat.component.MessageInputBar import com.gamss.android.feature.chat.component.SupportAgencyDialog import com.gamss.android.feature.chat.util.AnimatedChatMessage import com.gamss.android.feature.chat.util.ChatMessageAnimation @@ -139,27 +142,6 @@ fun ChatRoomScreen( } } -@Composable -private fun EndConversationDialog( - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - GamssDialog( - title = stringResource(R.string.chat_room_end_dialog_title), - subtitle = stringResource(R.string.chat_room_end_dialog_subTitle), - secondaryAction = GamssDialogAction( - label = stringResource(R.string.chat_room_end_dialog_dismiss_button_label), - onClick = onDismiss, - variant = GamssButtonVariant.Secondary, - ), - primaryAction = GamssDialogAction( - label = stringResource(R.string.chat_room_end_dialog_confirm_button_label), - onClick = onConfirm, - ), - onDismissRequest = onDismiss, - ) -} - // 카드생성 bottomsheet @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -220,9 +202,6 @@ private fun ChatRoomContent( } } - // 답장할 메시지까지 스크롤해 둔 채로 입력창을 탭하면, 키보드가 올라오며 줄어드는 높이만큼 - // 뷰포트 아래쪽이 잘려 그 메시지가 화면 밖으로 밀려난다. 키보드가 커진 만큼 같이 스크롤해 - // 같은 메시지가 입력창 위에 계속 보이게 한다. 키보드가 내려갈 때도 같은 식으로 되돌아온다. val imeBottomPx = WindowInsets.ime.getBottom(LocalDensity.current) var previousImeBottomPx by remember { mutableIntStateOf(imeBottomPx) } LaunchedEffect(imeBottomPx) { @@ -236,13 +215,28 @@ private fun ChatRoomContent( Scaffold( modifier = modifier, topBar = { - ChatRoomTopBar( - conversationCreatedAt = state.conversationCreatedAt, - endFlow = state.endFlow, - canEnd = state.canEnd, - showEndButton = state.useChatEndFeature, - onEndClick = actions.onEndClick, - onBackClick = onBackClick, + GamssTopNavigation( + title = state.conversationCreatedAt?.let(::formatConversationDate).orEmpty(), + titleAlignment = GamssTopNavigationTitleAlignment.Center, + showLeftIcon = true, + onLeftIconClick = onBackClick, + rightActions = listOfNotNull( + if (state.useChatEndFeature) { + if (!state.endFlow.isBusy && state.canEnd) { + GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.CreateCard, + onClick = actions.onEndClick, + ) + } else { + null + } + } else { + null + }, + GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Menu, onClick = { + // 토큰 확인 페이지? + }), + ), ) }, // 상위 Scaffold 가 인셋을 이미 적용해, imePadding 을 그대로 쓰면 이중 적용된다. @@ -268,10 +262,6 @@ private fun ChatRoomContent( ChatRoomInputSection( endFlow = state.endFlow, input = state.input, - // 전송 가능 여부(글자 유무)는 GamssInputBar 내부에서 계산한다. 여기서는 그 앞단 - // 조건(로딩 중·종료 흐름 진입)만 넘겨 입력칸 자체를 잠근다. 전송 중(isSending)에 - // 여기서 잠그면 텍스트필드가 disabled 되며 포커스와 키보드가 함께 내려가므로 넣지 - // 않는다 — 연타 방지는 이미 ChatRoomState.canSend/onSend 쪽에서 보장된다. isInputEnabled = !state.isLoading && state.endFlow == EndFlow.NotStarted, replyTarget = state.replyTarget, actions = actions, @@ -331,40 +321,6 @@ private fun ChatMessageList( } } -@Composable -private fun ChatRoomTopBar( - conversationCreatedAt: LocalDateTime?, - endFlow: EndFlow, - canEnd: Boolean, - showEndButton: Boolean, - onEndClick: () -> Unit, - onBackClick: () -> Unit, -) { - GamssTopNavigation( - title = conversationCreatedAt?.let(::formatConversationDate).orEmpty(), - titleAlignment = GamssTopNavigationTitleAlignment.Center, - showLeftIcon = true, - onLeftIconClick = onBackClick, - rightActions = listOfNotNull( - if (showEndButton) { - if (!endFlow.isBusy && canEnd) { - GamssTopNavigationIconAction( - icon = GamssTopNavigationIcon.CreateCard, - onClick = onEndClick, - ) - } else { - null - } - } else { - null - }, - GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Menu, onClick = { - // 토큰 확인 페이지? - }), - ), - ) -} - @Composable private fun ChatRoomInputSection( endFlow: EndFlow, @@ -396,70 +352,6 @@ private fun ChatRoomInputSection( ) } -/** - * @param messages 답장 대상 메시지의 내용을 찾기 위한 전체 목록. [Message.repliesToMessageId] 가 - * 가리키는 메시지가 이 목록에 없으면(예: 아직 로드되지 않은 과거 메시지) 답장 인용 없이 표시한다. - */ -@Composable -private fun MessageBubble( - message: Message, - messages: List, - onCharacterMessageClick: (Message) -> Unit, - modifier: Modifier = Modifier, -) { - val replyQuote = message.repliesToMessageId - ?.let { targetId -> messages.find { it.id == targetId } } - ?.toReplyQuote() - - Box( - modifier = modifier.fillMaxWidth(), - ) { - when (val sender = message.sender) { - MessageSender.User -> GamssSentChatBubble( - message = message.content, - time = message.createdTime, - replyQuote = replyQuote, - modifier = Modifier.align(Alignment.CenterEnd), - ) - - is MessageSender.Character -> GamssReceivedChatBubble( - sender = ChatSender(name = sender.character.displayName), - message = message.content, - time = message.createdTime, - replyQuote = replyQuote, - modifier = Modifier - .align(Alignment.CenterStart) - .noRippleCombinedClickable( - onClick = { onCharacterMessageClick(message) }, - onLongClick = null - ), - ) - - MessageSender.Unknown -> GamssReceivedChatBubble( - sender = ChatSender(name = ""), - message = message.content, - time = message.createdTime, - replyQuote = replyQuote, - modifier = Modifier.align(Alignment.CenterStart), - ) - } - } -} - -@Composable -private fun Message.toReplyQuote(): ChatReplyQuote { - val label = when (val target = sender) { - is MessageSender.Character -> stringResource( - R.string.chat_room_reply_to_character, - target.character.displayName, - ) - - MessageSender.User -> stringResource(R.string.chat_room_reply_to_me) - MessageSender.Unknown -> stringResource(R.string.chat_room_reply) - } - return ChatReplyQuote(senderLabel = label, message = content) -} - @Composable private fun GeneratingIndicator(modifier: Modifier = Modifier) { Row( @@ -476,42 +368,8 @@ private fun GeneratingIndicator(modifier: Modifier = Modifier) { } } -@Composable -private fun MessageInputBar( - input: String, - enabled: Boolean, - replyTarget: ReplyTarget?, - onInputChange: (String) -> Unit, - onSendClick: () -> Unit, - onReplyClear: () -> Unit, -) { - GamssInputBar( - value = input, - onValueChange = onInputChange, - onTrailingClick = onSendClick, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 12.dp), - placeholder = stringResource(R.string.chat_room_input_placeholder), - trailingContentDescription = "보내기", - enabled = enabled, - maxLines = MESSAGE_INPUT_MAX_LINES, - replyQuote = replyTarget?.let { - ChatReplyQuote( - senderLabel = stringResource( - R.string.chat_room_reply_to_character, - it.characterName - ), - message = it.content, - ) - }, - onReplyClear = onReplyClear, - replyClearContentDescription = "답장 취소", - ) -} - private const val EMERGENCY_PHONE_NUMBER = "119" -private const val MESSAGE_INPUT_MAX_LINES = 5 + @Preview(name = "Light", showBackground = true) @Suppress("UnusedPrivateMember") diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EndConversationDialog.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EndConversationDialog.kt new file mode 100644 index 00000000..2f2583e0 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EndConversationDialog.kt @@ -0,0 +1,29 @@ +package com.gamss.android.feature.chat.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.gamss.android.core.designsystem.button.GamssButtonVariant +import com.gamss.android.core.designsystem.dialog.GamssDialog +import com.gamss.android.core.designsystem.dialog.GamssDialogAction +import com.gamss.android.feature.chat.R + +@Composable +fun EndConversationDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + GamssDialog( + title = stringResource(R.string.chat_room_end_dialog_title), + subtitle = stringResource(R.string.chat_room_end_dialog_subTitle), + secondaryAction = GamssDialogAction( + label = stringResource(R.string.chat_room_end_dialog_dismiss_button_label), + onClick = onDismiss, + variant = GamssButtonVariant.Secondary, + ), + primaryAction = GamssDialogAction( + label = stringResource(R.string.chat_room_end_dialog_confirm_button_label), + onClick = onConfirm, + ), + onDismissRequest = onDismiss, + ) +} \ No newline at end of file diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt new file mode 100644 index 00000000..c57131ae --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt @@ -0,0 +1,80 @@ +package com.gamss.android.feature.chat.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote +import com.gamss.android.core.designsystem.component.chat.ChatSender +import com.gamss.android.core.designsystem.component.chat.GamssReceivedChatBubble +import com.gamss.android.core.designsystem.component.chat.GamssSentChatBubble +import com.gamss.android.core.designsystem.modifier.noRippleCombinedClickable +import com.gamss.android.domain.conversation.Message +import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.feature.chat.R + +/** + * @param messages 답장 대상 메시지의 내용을 찾기 위한 전체 목록. [Message.repliesToMessageId] 가 + * 가리키는 메시지가 이 목록에 없으면(예: 아직 로드되지 않은 과거 메시지) 답장 인용 없이 표시한다. + */ +@Composable +fun MessageBubble( + message: Message, + messages: List, + onCharacterMessageClick: (Message) -> Unit, + modifier: Modifier = Modifier, +) { + val replyQuote = message.repliesToMessageId + ?.let { targetId -> messages.find { it.id == targetId } } + ?.toReplyQuote() + + Box( + modifier = modifier.fillMaxWidth(), + ) { + when (val sender = message.sender) { + MessageSender.User -> GamssSentChatBubble( + message = message.content, + time = message.createdTime, + replyQuote = replyQuote, + modifier = Modifier.align(Alignment.CenterEnd), + ) + + is MessageSender.Character -> GamssReceivedChatBubble( + sender = ChatSender(name = sender.character.displayName), + message = message.content, + time = message.createdTime, + replyQuote = replyQuote, + modifier = Modifier + .align(Alignment.CenterStart) + .noRippleCombinedClickable( + onClick = { onCharacterMessageClick(message) }, + onLongClick = null + ), + ) + + MessageSender.Unknown -> GamssReceivedChatBubble( + sender = ChatSender(name = ""), + message = message.content, + time = message.createdTime, + replyQuote = replyQuote, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + } +} + +@Composable +private fun Message.toReplyQuote(): ChatReplyQuote { + val label = when (val target = sender) { + is MessageSender.Character -> stringResource( + R.string.chat_room_reply_to_character, + target.character.displayName, + ) + + MessageSender.User -> stringResource(R.string.chat_room_reply_to_me) + MessageSender.Unknown -> stringResource(R.string.chat_room_reply) + } + return ChatReplyQuote(senderLabel = label, message = content) +} \ No newline at end of file diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt new file mode 100644 index 00000000..85475b19 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt @@ -0,0 +1,48 @@ +package com.gamss.android.feature.chat.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.component.GamssInputBar +import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote +import com.gamss.android.feature.chat.R +import com.gamss.android.feature.chat.ReplyTarget + +@Composable +fun MessageInputBar( + input: String, + enabled: Boolean, + replyTarget: ReplyTarget?, + onInputChange: (String) -> Unit, + onSendClick: () -> Unit, + onReplyClear: () -> Unit, +) { + GamssInputBar( + value = input, + onValueChange = onInputChange, + onTrailingClick = onSendClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 12.dp), + placeholder = stringResource(R.string.chat_room_input_placeholder), + trailingContentDescription = "보내기", + enabled = enabled, + maxLines = MESSAGE_INPUT_MAX_LINES, + replyQuote = replyTarget?.let { + ChatReplyQuote( + senderLabel = stringResource( + R.string.chat_room_reply_to_character, + it.characterName + ), + message = it.content, + ) + }, + onReplyClear = onReplyClear, + replyClearContentDescription = "답장 취소", + ) +} + +private const val MESSAGE_INPUT_MAX_LINES = 5 \ No newline at end of file From 2bb84a0373def6842246d1ce1d9c50924579d99a Mon Sep 17 00:00:00 2001 From: seunghee Date: Sun, 16 Aug 2026 20:13:32 +0900 Subject: [PATCH 13/43] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=A7=84=EC=9E=85=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=9E=90=EC=97=B0=EC=8A=A4?= =?UTF-8?q?=EB=9F=BD=EA=B2=8C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/chat/util/ChatMessageAnimation.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatMessageAnimation.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatMessageAnimation.kt index 777534f7..99b6915f 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatMessageAnimation.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatMessageAnimation.kt @@ -66,16 +66,16 @@ internal fun AnimatedChatMessage( // 끊겨 보이던 문제를 없애고 하나의 유려한 움직임으로 이어지게 한다. enter = slideInVertically( animationSpec = spring( - dampingRatio = MessageEnterDampingRatio, - stiffness = MessageEnterStiffness, + dampingRatio = MESSAGE_ENTER_DAMPING_RATIO, + stiffness = MESSAGE_ENTER_STIFFNESS, ), initialOffsetY = { messageHeight -> listState.offsetFromInput(messageId, messageHeight) }, ) + fadeIn( animationSpec = spring( - dampingRatio = MessageEnterDampingRatio, - stiffness = MessageEnterStiffness, + dampingRatio = MESSAGE_ENTER_DAMPING_RATIO, + stiffness = MESSAGE_ENTER_STIFFNESS, ), ), ) { @@ -89,5 +89,5 @@ private fun LazyListState.offsetFromInput(messageId: Long, messageHeight: Int): return (layoutInfo.viewportEndOffset - messageItem.offset).coerceAtLeast(messageHeight) } -private const val MessageEnterDampingRatio = Spring.DampingRatioLowBouncy -private const val MessageEnterStiffness = Spring.StiffnessMediumLow +private const val MESSAGE_ENTER_DAMPING_RATIO = Spring.DampingRatioLowBouncy +private const val MESSAGE_ENTER_STIFFNESS = Spring.StiffnessMediumLow From 08d4c54dcfd11dc0d9c49fd591e5fc9db45603de Mon Sep 17 00:00:00 2001 From: seunghee Date: Sun, 16 Aug 2026 20:15:34 +0900 Subject: [PATCH 14/43] =?UTF-8?q?feat:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=9D=B8=ED=84=B0=EB=9E=99=EC=85=98=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=85=EB=A0=A5=EC=A4=91,=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=A4=91=EC=97=90=EB=8F=84=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=B0=BD=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=EC=99=80=20?= =?UTF-8?q?=ED=82=A4=EB=B3=B4=EB=93=9C=20=EC=9C=A0=EC=A7=80=20-=20?= =?UTF-8?q?=EC=83=88=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=99=80=20=EB=A7=A8=20=EC=95=84=EB=9E=98=EB=A1=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=B2=84=ED=8A=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EC=B1=84=ED=8C=85=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EB=A7=90=ED=92=8D=EC=84=A0=EC=97=90=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=ED=95=91=20=EB=A1=9C=ED=8B=B0=20=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/designsystem/build.gradle.kts | 4 + .../core/designsystem/component/GamssIcons.kt | 3 + .../component/chat/GamssChatBubble.kt | 74 +++++- .../core/designsystem/modifier/Shadow.kt | 24 ++ .../topnavigation/GamssTopNavigation.kt | 6 +- .../src/main/res/drawable/ic_create_card.xml | 16 -- .../src/main/res/drawable/ic_scroll_down.xml | 9 + .../src/main/res/drawable/ic_token_check.xml | 20 ++ .../src/main/res/raw/typing_loading.json | 1 + .../android/feature/chat/ChatRoomScreen.kt | 232 +++++++++++------- .../chat/component/EndConversationDialog.kt | 2 +- .../feature/chat/component/MessageBubble.kt | 8 +- .../feature/chat/component/MessageInputBar.kt | 2 +- .../feature/chat/component/NewMessageToast.kt | 136 ++++++++++ .../feature/chat/util/ChatScrollState.kt | 131 ++++++++++ feature/chat/src/main/res/values/strings.xml | 2 + gradle/libs.versions.toml | 3 + 17 files changed, 553 insertions(+), 120 deletions(-) create mode 100644 core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/Shadow.kt delete mode 100644 core/designsystem/src/main/res/drawable/ic_create_card.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_scroll_down.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_token_check.xml create mode 100644 core/designsystem/src/main/res/raw/typing_loading.json create mode 100644 feature/chat/src/main/java/com/gamss/android/feature/chat/component/NewMessageToast.kt create mode 100644 feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt 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/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/chat/GamssChatBubble.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/chat/GamssChatBubble.kt index 80142913..bcc6212b 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 @@ -17,12 +17,19 @@ 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +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 /** @@ -58,6 +65,15 @@ object GamssChatBubbleDefaults { private val AvatarSize = 24.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 +115,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,10 +147,46 @@ 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( @@ -146,10 +196,7 @@ private fun ChatBubbleSurface( .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 - ), + .padding(horizontal = horizontalPadding, vertical = verticalPadding), verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing075), horizontalAlignment = if (isMine) Alignment.End else Alignment.Start, ) { @@ -157,6 +204,21 @@ private fun ChatBubbleSurface( } } +/** 말풍선 안에서 무한 반복 재생되는 타이핑 로티. 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, 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/topnavigation/GamssTopNavigation.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/topnavigation/GamssTopNavigation.kt index 9cc7681d..0de9441d 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 @@ -38,8 +38,12 @@ enum class GamssTopNavigationTitleAlignment { */ enum class GamssTopNavigationIcon(@DrawableRes internal val drawableRes: Int) { LeftChevron(R.drawable.ic_left_chevron), - CreateCard(R.drawable.ic_create_card), + + /// FIXME: 카드 이모티콘 추가필요 + CreateCard(R.drawable.ic_menu), Menu(R.drawable.ic_menu), + + CheckToken(R.drawable.ic_token_check) } /** diff --git a/core/designsystem/src/main/res/drawable/ic_create_card.xml b/core/designsystem/src/main/res/drawable/ic_create_card.xml deleted file mode 100644 index 2de35c0a..00000000 --- a/core/designsystem/src/main/res/drawable/ic_create_card.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_scroll_down.xml b/core/designsystem/src/main/res/drawable/ic_scroll_down.xml new file mode 100644 index 00000000..942855bf --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_scroll_down.xml @@ -0,0 +1,9 @@ + + + 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/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 6fbd9287..7ab4171c 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -3,12 +3,12 @@ package com.gamss.android.feature.chat import android.content.res.Configuration import android.widget.Toast import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize @@ -22,8 +22,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold @@ -33,27 +35,24 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.gamss.android.core.common.util.formatConversationDate -import com.gamss.android.core.designsystem.button.GamssButtonVariant -import com.gamss.android.core.designsystem.component.GamssInputBar -import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote -import com.gamss.android.core.designsystem.component.chat.ChatSender -import com.gamss.android.core.designsystem.component.chat.GamssReceivedChatBubble -import com.gamss.android.core.designsystem.component.chat.GamssSentChatBubble -import com.gamss.android.core.designsystem.dialog.GamssDialog -import com.gamss.android.core.designsystem.dialog.GamssDialogAction -import com.gamss.android.core.designsystem.modifier.noRippleCombinedClickable +import com.gamss.android.core.designsystem.component.GamssIcons +import com.gamss.android.core.designsystem.modifier.gamssShadow import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationIcon @@ -64,16 +63,20 @@ import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.feature.chat.component.EndConversationDialog +import com.gamss.android.feature.chat.component.LoadingMessageBubble import com.gamss.android.feature.chat.component.MessageBubble import com.gamss.android.feature.chat.component.MessageInputBar +import com.gamss.android.feature.chat.component.NewMessageToast import com.gamss.android.feature.chat.component.SupportAgencyDialog import com.gamss.android.feature.chat.util.AnimatedChatMessage import com.gamss.android.feature.chat.util.ChatMessageAnimation +import com.gamss.android.feature.chat.util.ChatScrollState import com.gamss.android.feature.chat.util.dialOrNotify import com.gamss.android.feature.chat.util.rememberChatMessageAnimationState +import com.gamss.android.feature.chat.util.rememberChatScrollState +import kotlinx.coroutines.delay import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect -import java.time.LocalDateTime /** * @param onCardClose 카드 시트를 닫을 때 호출한다. 이 화면을 실제로 벗어나야 한다. @@ -194,13 +197,7 @@ private fun ChatRoomContent( isLoading = state.isLoading, messageIds = state.messages.map(Message::id), ) - - LaunchedEffect(state.messages.size, state.isAwaitingComments) { - val itemCount = state.messages.size + if (state.isAwaitingComments) 1 else 0 - if (itemCount > 0) { - listState.animateScrollToItem(itemCount - 1) - } - } + val chatScrollState = rememberChatScrollState(state = state, listState = listState) val imeBottomPx = WindowInsets.ime.getBottom(LocalDensity.current) var previousImeBottomPx by remember { mutableIntStateOf(imeBottomPx) } @@ -212,33 +209,21 @@ private fun ChatRoomContent( } } + // 키보드가 오르내리는 동안엔 위 보정 스크롤이 인셋 변화를 프레임 단위로 정확히 따라잡지 + // 못해 listState.canScrollForward 가 순간적으로 흔들리고, 그 값을 그대로 따르는 스크롤 + // 버튼이 깜빡인다. imeBottomPx 가 바뀔 때마다 이 이펙트가 재시작되므로(진행 중이던 delay는 + // 취소됨), 인셋이 한동안(마지막 변화 후 IME_SETTLE_GRACE_PERIOD_MILLIS) 안 바뀌어 안정됐을 + // 때만 버튼을 노출해 깜빡임을 없앤다. + var isImeInTransition by remember { mutableStateOf(false) } + LaunchedEffect(imeBottomPx) { + isImeInTransition = true + delay(IME_SETTLE_GRACE_PERIOD_MILLIS) + isImeInTransition = false + } + Scaffold( modifier = modifier, - topBar = { - GamssTopNavigation( - title = state.conversationCreatedAt?.let(::formatConversationDate).orEmpty(), - titleAlignment = GamssTopNavigationTitleAlignment.Center, - showLeftIcon = true, - onLeftIconClick = onBackClick, - rightActions = listOfNotNull( - if (state.useChatEndFeature) { - if (!state.endFlow.isBusy && state.canEnd) { - GamssTopNavigationIconAction( - icon = GamssTopNavigationIcon.CreateCard, - onClick = actions.onEndClick, - ) - } else { - null - } - } else { - null - }, - GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Menu, onClick = { - // 토큰 확인 페이지? - }), - ), - ) - }, + topBar = { ChatRoomTopBar(state = state, actions = actions, onBackClick = onBackClick) }, // 상위 Scaffold 가 인셋을 이미 적용해, imePadding 을 그대로 쓰면 이중 적용된다. contentWindowInsets = WindowInsets(0), ) { innerPadding -> @@ -254,6 +239,8 @@ private fun ChatRoomContent( actions = actions, listState = listState, animationState = messageAnimationState, + scrollState = chatScrollState, + showScrollToBottomButton = chatScrollState.showScrollToBottomButton && !isImeInTransition, modifier = Modifier .weight(1f) .fillMaxWidth(), @@ -270,57 +257,130 @@ private fun ChatRoomContent( } } +@Composable +private fun ChatRoomTopBar( + state: ChatRoomState, + actions: ChatRoomActions, + onBackClick: () -> Unit, +) { + GamssTopNavigation( + title = state.conversationCreatedAt?.let(::formatConversationDate).orEmpty(), + titleAlignment = GamssTopNavigationTitleAlignment.Center, + showLeftIcon = true, + onLeftIconClick = onBackClick, + rightActions = listOfNotNull( + if (state.useChatEndFeature && !state.endFlow.isBusy && state.canEnd) { + GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.CreateCard, + onClick = actions.onEndClick, + ) + } else { + null + }, + GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Menu, onClick = { + // 토큰 확인 페이지? + }), + ), + ) +} + @Composable private fun ChatMessageList( state: ChatRoomState, actions: ChatRoomActions, listState: LazyListState, animationState: ChatMessageAnimation, + scrollState: ChatScrollState, + showScrollToBottomButton: Boolean, modifier: Modifier = Modifier, ) { - LazyColumn( - state = listState, - modifier = modifier, - contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (state.messages.isEmpty()) { - item { - Box( - modifier = Modifier.fillParentMaxSize(), - contentAlignment = Alignment.Center, - ) { - if (state.isLoading) { - CircularProgressIndicator() - } else { - Text( - text = "오늘 어떤 일이 있었나요?", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Box(modifier = modifier) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (state.messages.isEmpty()) { + item { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (state.isLoading) { + CircularProgressIndicator() + } else { + Text( + text = "오늘 어떤 일이 있었나요?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } - } - items(state.messages, key = { it.id }) { message -> - AnimatedChatMessage( - messageId = message.id, - shouldAnimate = animationState.shouldAnimate(message.id), - listState = listState, - ) { - MessageBubble( - message = message, - messages = state.messages, - onCharacterMessageClick = actions.onCharacterMessageClick, - ) + items(state.messages, key = { it.id }) { message -> + AnimatedChatMessage( + messageId = message.id, + shouldAnimate = animationState.shouldAnimate(message.id), + listState = listState, + ) { + MessageBubble( + message = message, + messages = state.messages, + onCharacterMessageClick = actions.onCharacterMessageClick, + ) + } + } + if (state.isAwaitingComments) { + item { LoadingMessageBubble() } } } - if (state.isAwaitingComments) { - item { GeneratingIndicator() } + + scrollState.newMessageToast?.let { toastMessage -> + NewMessageToast( + message = toastMessage, + onClick = { scrollState.dismissToastAndScrollToBottom(state) }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 12.dp), + ) + } + + if (showScrollToBottomButton) { + ScrollToBottomButton( + onClick = { scrollState.scrollToBottom(state) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 18.dp, bottom = 12.dp), + ) } } } +@Composable +private fun ScrollToBottomButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(36.dp) + .gamssShadow(shape = CircleShape) + .clip(CircleShape) + .background(GamssTheme.colors.gray700, CircleShape) + .clickable(role = Role.Button, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(GamssIcons.ScrollDown), + contentDescription = stringResource(R.string.chat_room_scroll_to_bottom), + modifier = Modifier.size(24.dp), + tint = GamssTheme.colors.gray025, + ) + } +} + @Composable private fun ChatRoomInputSection( endFlow: EndFlow, @@ -352,24 +412,8 @@ private fun ChatRoomInputSection( ) } -@Composable -private fun GeneratingIndicator(modifier: Modifier = Modifier) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp) - Text( - text = "답장을 쓰고 있어요", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - private const val EMERGENCY_PHONE_NUMBER = "119" - +private const val IME_SETTLE_GRACE_PERIOD_MILLIS = 120L @Preview(name = "Light", showBackground = true) @Suppress("UnusedPrivateMember") diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EndConversationDialog.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EndConversationDialog.kt index 2f2583e0..2b77d604 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EndConversationDialog.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EndConversationDialog.kt @@ -26,4 +26,4 @@ fun EndConversationDialog( ), onDismissRequest = onDismiss, ) -} \ No newline at end of file +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt index c57131ae..95ef5f83 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote import com.gamss.android.core.designsystem.component.chat.ChatSender +import com.gamss.android.core.designsystem.component.chat.GamssLoadingMessageBubble import com.gamss.android.core.designsystem.component.chat.GamssReceivedChatBubble import com.gamss.android.core.designsystem.component.chat.GamssSentChatBubble import com.gamss.android.core.designsystem.modifier.noRippleCombinedClickable @@ -65,6 +66,11 @@ fun MessageBubble( } } +@Composable +fun LoadingMessageBubble() { + GamssLoadingMessageBubble(senderStatus = stringResource(R.string.chat_room_message_writing_label)) +} + @Composable private fun Message.toReplyQuote(): ChatReplyQuote { val label = when (val target = sender) { @@ -77,4 +83,4 @@ private fun Message.toReplyQuote(): ChatReplyQuote { MessageSender.Unknown -> stringResource(R.string.chat_room_reply) } return ChatReplyQuote(senderLabel = label, message = content) -} \ No newline at end of file +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt index 85475b19..4f13f2dd 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt @@ -45,4 +45,4 @@ fun MessageInputBar( ) } -private const val MESSAGE_INPUT_MAX_LINES = 5 \ No newline at end of file +private const val MESSAGE_INPUT_MAX_LINES = 5 diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/NewMessageToast.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/NewMessageToast.kt new file mode 100644 index 00000000..dd56879f --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/NewMessageToast.kt @@ -0,0 +1,136 @@ +package com.gamss.android.feature.chat.component + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +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.component.GamssText +import com.gamss.android.core.designsystem.modifier.gamssShadow +import com.gamss.android.core.designsystem.modifier.noRippleCombinedClickable +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.domain.conversation.Message +import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.domain.emotion.EmotionCharacter + +// 디자인 상 각지게 그려진다(둥근 pill 아님). +private val ToastShape = RectangleShape +private val AvatarSize = 16.dp +private val ToastStrokeWidth = 1.dp +private val ToastHorizontalMargin = 40.dp +private val ToastHorizontalPadding = 16.dp +private val ToastVerticalPadding = 10.dp +private val ToastContentGap = 8.dp + +/** + * 스크롤을 올려 최신 메시지가 화면 밖으로 벗어났을 때, 새로 도착한 메시지를 알리는 토스트. + * + * 좌우 [ToastHorizontalMargin]까지만 늘어나고 그 안에서 내용에 맞춰 너비가 줄어든다. 넘치는 + * 내용은 말줄임된다. 탭하기 전까지 자동으로 사라지지 않으므로 dismiss 콜백은 따로 두지 않고, + * 탭하면 [onClick] 하나로 "확인 + 최신 메시지로 이동"을 함께 처리한다. + */ +@Composable +fun NewMessageToast( + message: Message, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // 발신자는 항상 캐릭터다 — 본인이 보낸 메시지는 호출부에서 애초에 이 토스트 대상으로 넘기지 + // 않는다. Unknown 은 MessageBubble 과 동일하게 빈 이름으로 둔다. + val senderName = when (val sender = message.sender) { + is MessageSender.Character -> sender.character.displayName + MessageSender.User, MessageSender.Unknown -> "" + } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = ToastHorizontalMargin) + .gamssShadow(shape = ToastShape) + .clip(ToastShape) + .background(GamssTheme.colors.gray800, ToastShape) + .border(ToastStrokeWidth, GamssTheme.colors.gray950, ToastShape) + .noRippleCombinedClickable(onClick = onClick, onLongClick = null) + .padding(horizontal = ToastHorizontalPadding, vertical = ToastVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + ToastAvatar() + GamssText( + text = senderName, + modifier = Modifier.padding(start = ToastContentGap), + style = GamssTheme.typography.body5Medium, + color = GamssTheme.colors.gray300, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + GamssText( + text = message.content, + modifier = Modifier + .padding(start = ToastContentGap) + .weight(weight = 1f, fill = false), + style = GamssTheme.typography.body5Medium, + color = GamssTheme.colors.gray025, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ToastAvatar(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(AvatarSize) + .clip(CircleShape) + .background(GamssTheme.colors.gray025), + ) +} + +@Preview(name = "Light", showBackground = true) +@Suppress("UnusedPrivateMember") +@Composable +private fun NewMessageToastLightPreview() { + GamssTheme(darkTheme = false) { + NewMessageToastPreviewContent() + } +} + +@Preview( + name = "Dark", + showBackground = true, + backgroundColor = 0xFF000000, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Suppress("UnusedPrivateMember") +@Composable +private fun NewMessageToastDarkPreview() { + GamssTheme(darkTheme = true) { + NewMessageToastPreviewContent() + } +} + +@Composable +private fun NewMessageToastPreviewContent() { + NewMessageToast( + message = Message( + id = 1, + conversationId = 1, + sender = MessageSender.Character(EmotionCharacter.PRICKLY), + content = "안녕하세요", + createdTime = "오후 1:38", + ), + onClick = {}, + ) +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt new file mode 100644 index 00000000..0df8714a --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt @@ -0,0 +1,131 @@ +package com.gamss.android.feature.chat.util + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import com.gamss.android.domain.conversation.Message +import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.feature.chat.ChatRoomState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * 채팅 목록의 "바닥 유지" 동작을 관리한다. + * + * - 최초 진입(과거 메시지 로딩 완료) 시 한 번 바닥으로 점프한다. + * - 내가 메시지를 보내면(전송 완료 시점) 항상 바닥까지 스크롤한다. 사용자의 명시적 행동이라 + * 상대 메시지 도착 시와 달리 자동 스크롤을 유지한다. + * - 상대 메시지는 자동 스크롤하지 않는다. 바닥이 아닌 상태에서 도착하면 [newMessageToast]를 + * 채워 화면에서 안내하고, 이미 바닥이면 무시한다. + */ +internal class ChatScrollState( + private val listState: LazyListState, + private val coroutineScope: CoroutineScope, +) { + var newMessageToast by mutableStateOf(null) + private set + + var hasScrolledToInitialBottom by mutableStateOf(false) + private set + + private var lastSeenMessageId: Long? = null + + // 내 전송 완료로 인한 자동 스크롤이 방금 반영한 메시지 id. handleNewMessage 가 같은 메시지를 + // 또 새 메시지로 처리해 토스트를 잠깐 띄웠다 지우는 걸 막는다. + private var lastAutoScrolledMessageId: Long? = null + + val showScrollToBottomButton: Boolean + get() = listState.canScrollForward && newMessageToast == null + + fun dismissToastAndScrollToBottom(state: ChatRoomState) { + newMessageToast = null + scrollToBottom(state) + } + + fun scrollToBottom(state: ChatRoomState) { + val index = state.lastItemIndex + if (index < 0) return + coroutineScope.launch { listState.animateScrollToItem(index) } + } + + suspend fun handleInitialLoad(state: ChatRoomState) { + if (hasScrolledToInitialBottom || state.conversationId == null || state.isLoading) return + if (state.messages.isNotEmpty()) { + listState.scrollToItem(state.lastItemIndex) + } + lastSeenMessageId = state.messages.lastOrNull()?.id + hasScrolledToInitialBottom = true + } + + // 스크롤(애니메이션이라 시간이 걸림)보다 커서 갱신이 먼저 반영돼야, 그 사이 handleNewMessage + // 가 같은 메시지를 놓치고 토스트를 잠깐 띄우는 경합을 막을 수 있다. + suspend fun handleSendConcluded(state: ChatRoomState) { + if (!hasScrolledToInitialBottom) return + lastAutoScrolledMessageId = state.messages.lastOrNull()?.id + newMessageToast = null + val index = state.lastItemIndex + if (index >= 0) { + listState.animateScrollToItem(index) + } + } + + /** 상대 메시지가 하나씩 도착할 때마다(코멘트 순차 공개 포함) 호출된다. */ + fun handleNewMessage(state: ChatRoomState) { + if (!hasScrolledToInitialBottom) return + val latest = state.messages.lastOrNull() + if (latest == null || latest.id == lastSeenMessageId) return + lastSeenMessageId = latest.id + + // 내 전송으로 이미 자동 스크롤된 메시지거나 내가 보낸 메시지면 토스트 대상이 아니다 — + // 기존 토스트가 있다면 건드리지 않고 그대로 둔다. + val isToastCandidate = latest.id != lastAutoScrolledMessageId && latest.sender != MessageSender.User + if (isToastCandidate) { + newMessageToast = if (listState.canScrollForward) latest else null + } + } + + fun clearToastIfAtBottom() { + if (!listState.canScrollForward) newMessageToast = null + } +} + +/** 가장 최근에 보이던 마지막 아이템의 인덱스. 코멘트 생성 표시(로딩)까지 바닥에 포함시킨다. */ +private val ChatRoomState.lastItemIndex: Int + get() = messages.size - 1 + if (isAwaitingComments) 1 else 0 + +@Composable +internal fun rememberChatScrollState( + state: ChatRoomState, + listState: LazyListState, +): ChatScrollState { + val coroutineScope = rememberCoroutineScope() + val scrollState = remember(state.conversationId) { ChatScrollState(listState, coroutineScope) } + + LaunchedEffect(state.conversationId, state.isLoading) { + scrollState.handleInitialLoad(state) + } + + var wasSending by remember(state.conversationId) { mutableStateOf(false) } + LaunchedEffect(state.isSending) { + if (wasSending && !state.isSending) { + scrollState.handleSendConcluded(state) + } + wasSending = state.isSending + } + + LaunchedEffect(state.messages.lastOrNull()?.id, scrollState.hasScrolledToInitialBottom) { + scrollState.handleNewMessage(state) + } + + LaunchedEffect(listState) { + snapshotFlow { listState.canScrollForward }.collect { scrollState.clearToastIfAtBottom() } + } + + return scrollState +} diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml index 2db15c05..fc95804a 100644 --- a/feature/chat/src/main/res/values/strings.xml +++ b/feature/chat/src/main/res/values/strings.xml @@ -8,6 +8,8 @@ %1$s에게 답장 나에게 답장 답장 + 최신 메시지로 이동 + 입력 중 혼자 감당하지 않아도 괜찮아요. 대화에서 도움이 필요하다는 신호가 확인됐어요.\n전문가와 이야기해 보는 걸 추천드려요. 24시간 상담이 가능해요. 누르면 바로 연결돼요. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e3b3c180..c13958a7 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" @@ -98,6 +99,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" } From f7ea001cde4152ccd78a7dcd6d3c6322e545a6cb Mon Sep 17 00:00:00 2001 From: seunghee Date: Mon, 17 Aug 2026 14:01:10 +0900 Subject: [PATCH 15/43] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=20=EC=A1=B0=ED=9A=8C=20toggle=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20-=20=ED=86=A0=ED=81=B0=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EB=9F=89=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20toggle=20ui=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20-=20=ED=86=A0=ED=81=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=8B=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20UI=EA=B0=80=20?= =?UTF-8?q?=EB=9C=A8=EB=8F=84=EB=A1=9D=20toggle=C2=B7retry=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt --- .../android/core/common/util/TokenUsage.kt | 20 +++ .../component/GamssTokenUsageTooltip.kt | 153 ++++++++++++++++++ .../topnavigation/GamssTopNavigation.kt | 17 +- .../src/main/res/drawable/ic_create_card.xml | 16 ++ .../android/domain/model/DailyTokenUsage.kt | 1 + .../usecase/GetDailyTokenUsageUseCase.kt | 11 +- .../usecase/GetDailyTokenUsageUseCaseTest.kt | 62 +++++++ .../android/feature/chat/ChatRoomScreen.kt | 93 ++++++++--- .../android/feature/chat/ChatRoomState.kt | 2 + .../android/feature/chat/ChatRoomViewModel.kt | 41 ++++- feature/chat/src/main/res/values/strings.xml | 5 + .../android/feature/chat/ChatRoomTestFakes.kt | 36 +++-- 12 files changed, 423 insertions(+), 34 deletions(-) create mode 100644 core/common/src/main/java/com/gamss/android/core/common/util/TokenUsage.kt create mode 100644 core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssTokenUsageTooltip.kt create mode 100644 core/designsystem/src/main/res/drawable/ic_create_card.xml create mode 100644 domain/src/test/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCaseTest.kt diff --git a/core/common/src/main/java/com/gamss/android/core/common/util/TokenUsage.kt b/core/common/src/main/java/com/gamss/android/core/common/util/TokenUsage.kt new file mode 100644 index 00000000..84101dbd --- /dev/null +++ b/core/common/src/main/java/com/gamss/android/core/common/util/TokenUsage.kt @@ -0,0 +1,20 @@ +package com.gamss.android.core.common.util + +import kotlin.math.roundToInt + +/** + * 오늘 사용한 토큰량을 오늘 하루 총 한도 대비 백분율로 환산한다. + * + * @param usedTokens 오늘 사용한 토큰 수. + * @param dailyLimit 오늘 하루 총 한도. 서버가 "한도 없음"을 null 로 내려줄 수 있어 nullable 이다. + * @return 0~100 사이로 clamp 된 정수 퍼센트. [dailyLimit] 이 null 이거나 0 이하라 나눌 수 없으면 + * "0%"로 거짓 표시하지 않도록 null 을 반환한다 — 호출부가 무제한 상태를 구분해서 다뤄야 한다. + */ +fun calculateTokenUsagePercent(usedTokens: Long, dailyLimit: Long?): Int? { + if (dailyLimit == null || dailyLimit <= 0) return null + + val percent = (usedTokens.toDouble() / dailyLimit.toDouble() * PERCENT_SCALE).roundToInt() + return percent.coerceIn(0, 100) +} + +private const val PERCENT_SCALE = 100 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/topnavigation/GamssTopNavigation.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/topnavigation/GamssTopNavigation.kt index 0de9441d..3c261cb0 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 @@ -39,8 +39,7 @@ enum class GamssTopNavigationTitleAlignment { enum class GamssTopNavigationIcon(@DrawableRes internal val drawableRes: Int) { LeftChevron(R.drawable.ic_left_chevron), - /// FIXME: 카드 이모티콘 추가필요 - CreateCard(R.drawable.ic_menu), + CreateCard(R.drawable.ic_create_card), Menu(R.drawable.ic_menu), CheckToken(R.drawable.ic_token_check) @@ -310,8 +309,18 @@ private fun TopNavigationPreviewContent() { } } -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 diff --git a/core/designsystem/src/main/res/drawable/ic_create_card.xml b/core/designsystem/src/main/res/drawable/ic_create_card.xml new file mode 100644 index 00000000..95e22dd2 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_create_card.xml @@ -0,0 +1,16 @@ + + + + diff --git a/domain/src/main/kotlin/com/gamss/android/domain/model/DailyTokenUsage.kt b/domain/src/main/kotlin/com/gamss/android/domain/model/DailyTokenUsage.kt index 0264bdfd..a9632390 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/model/DailyTokenUsage.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/model/DailyTokenUsage.kt @@ -4,4 +4,5 @@ data class DailyTokenUsage( val usedTokens: Long, val dailyLimit: Long?, val exceeded: Boolean, + val usagePercent: Int? = null, ) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCase.kt index 6ccf398d..ff304fd6 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCase.kt @@ -1,6 +1,8 @@ package com.gamss.android.domain.usecase import com.gamss.android.core.common.AppResult +import com.gamss.android.core.common.map +import com.gamss.android.core.common.util.calculateTokenUsagePercent import com.gamss.android.domain.model.DailyTokenUsage import com.gamss.android.domain.user.UserRepository import javax.inject.Inject @@ -10,5 +12,12 @@ class GetDailyTokenUsageUseCase @Inject constructor( ) : NoParamUseCase> { override suspend fun invoke(): AppResult = - userRepository.getDailyTokenUsage() + userRepository.getDailyTokenUsage().map { usage -> + usage.copy( + usagePercent = calculateTokenUsagePercent( + usedTokens = usage.usedTokens, + dailyLimit = usage.dailyLimit, + ), + ) + } } diff --git a/domain/src/test/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCaseTest.kt new file mode 100644 index 00000000..96c5bc76 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCaseTest.kt @@ -0,0 +1,62 @@ +package com.gamss.android.domain.usecase + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.model.DailyTokenUsage +import com.gamss.android.domain.user.UserProfile +import com.gamss.android.domain.user.UserRepository +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class GetDailyTokenUsageUseCaseTest { + + @Test + fun `저장소 값으로 사용률을 계산해 채운다`() = runBlocking { + val repository = FakeUserRepository( + usage = DailyTokenUsage(usedTokens = 60, dailyLimit = 100, exceeded = false), + ) + + val result = GetDailyTokenUsageUseCase(repository)() + + assertEquals(60, (result as AppResult.Success).data.usagePercent) + } + + @Test + fun `한도가 없으면 사용률은 null이다`() = runBlocking { + val repository = FakeUserRepository( + usage = DailyTokenUsage(usedTokens = 60, dailyLimit = null, exceeded = false), + ) + + val result = GetDailyTokenUsageUseCase(repository)() + + assertNull((result as AppResult.Success).data.usagePercent) + } + + @Test + fun `저장소 실패를 그대로 반환한다`() = runBlocking { + val failure = IllegalStateException("network error") + val repository = FakeUserRepository(result = AppResult.Failure(failure)) + + val result = GetDailyTokenUsageUseCase(repository)() + + assertSame(failure, (result as AppResult.Failure).throwable) + } + + private class FakeUserRepository( + usage: DailyTokenUsage = DailyTokenUsage(usedTokens = 0, dailyLimit = 100, exceeded = false), + private val result: AppResult = AppResult.Success(usage), + ) : UserRepository { + override suspend fun updateNickname(nickname: String): AppResult = + error("Not needed for this test") + + override suspend fun deleteUserAccount(): AppResult = + error("Not needed for this test") + + override suspend fun getUserInfo(): AppResult = + error("Not needed for this test") + + override suspend fun getDailyTokenUsage(): AppResult = result + } +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 7ab4171c..3874f11b 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -48,13 +48,19 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import androidx.hilt.navigation.compose.hiltViewModel import com.gamss.android.core.common.util.formatConversationDate import com.gamss.android.core.designsystem.component.GamssIcons +import com.gamss.android.core.designsystem.component.GamssTokenUsageTooltip import com.gamss.android.core.designsystem.modifier.gamssShadow import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationHeight +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationHorizontalPadding import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationIcon import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationIconAction import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationTitleAlignment @@ -109,6 +115,8 @@ fun ChatRoomScreen( onCharacterMessageClick = viewModel::onReplyTargetSelect, onReplyTargetClear = viewModel::onReplyTargetClear, onEndClick = viewModel::onEndRequest, + onTokenUsageToggle = viewModel::onTokenUsageToggle, + onTokenUsageRetry = viewModel::onTokenUsageRetry ) } @@ -182,6 +190,8 @@ private data class ChatRoomActions( val onCharacterMessageClick: (Message) -> Unit, val onReplyTargetClear: () -> Unit, val onEndClick: () -> Unit, + val onTokenUsageToggle: () -> Unit, + val onTokenUsageRetry: () -> Unit, ) @Composable @@ -263,25 +273,70 @@ private fun ChatRoomTopBar( actions: ChatRoomActions, onBackClick: () -> Unit, ) { - GamssTopNavigation( - title = state.conversationCreatedAt?.let(::formatConversationDate).orEmpty(), - titleAlignment = GamssTopNavigationTitleAlignment.Center, - showLeftIcon = true, - onLeftIconClick = onBackClick, - rightActions = listOfNotNull( - if (state.useChatEndFeature && !state.endFlow.isBusy && state.canEnd) { + Box { + GamssTopNavigation( + title = state.conversationCreatedAt?.let(::formatConversationDate).orEmpty(), + titleAlignment = GamssTopNavigationTitleAlignment.Center, + showLeftIcon = true, + onLeftIconClick = onBackClick, + rightActions = listOfNotNull( + if (state.useChatEndFeature && !state.endFlow.isBusy && state.canEnd) { + GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.CreateCard, + onClick = actions.onEndClick, + ) + } else { + null + }, GamssTopNavigationIconAction( - icon = GamssTopNavigationIcon.CreateCard, - onClick = actions.onEndClick, - ) - } else { - null - }, - GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Menu, onClick = { - // 토큰 확인 페이지? - }), - ), - ) + icon = GamssTopNavigationIcon.CheckToken, + onClick = actions.onTokenUsageToggle, + ), + ), + ) + + if (state.isTokenUsagePopupExpanded) { + TokenUsagePopup( + usagePercent = state.tokenUsagePercent, + onDismissRequest = actions.onTokenUsageToggle, + onRetryClick = actions.onTokenUsageRetry + ) + } + } +} + +/** + * 상단 CheckToken 아이콘 아래에 뜬다. 오프셋은 [GamssTopNavigation]이 공개한 크기 상수로 + * 계산한다 — [GamssCharacterPicker] 를 입력바 아래에 띄울 때 쓰는 것과 같은 방식이다. + */ +@Composable +private fun TokenUsagePopup( + usagePercent: Int?, + onDismissRequest: () -> Unit, + onRetryClick: () -> Unit, +) { + val popupOffset = with(LocalDensity.current) { + IntOffset( + x = -GamssTopNavigationHorizontalPadding.roundToPx(), + y = GamssTopNavigationHeight.roundToPx(), + ) + } + Popup( + alignment = Alignment.TopEnd, + offset = popupOffset, + onDismissRequest = onDismissRequest, + properties = PopupProperties(focusable = true), + ) { + GamssTokenUsageTooltip( + title = stringResource(R.string.chat_room_token_usage_title), + usagePercent = usagePercent, + usagePercentLabel = if(usagePercent != null) stringResource(R.string.chat_room_token_usage_percent, usagePercent) else "", + resetTimeLabel = stringResource(R.string.chat_room_token_usage_reset_time), + failMessage = stringResource(R.string.chat_room_check_token_usage_fail), + retryLabel = stringResource(R.string.chat_room_check_token_usage_button_label), + onRetryClick = onRetryClick, + ) + } } @Composable @@ -481,6 +536,8 @@ private fun ChatRoomPreviewContent() { onCharacterMessageClick = {}, onReplyTargetClear = {}, onEndClick = {}, + onTokenUsageToggle = {}, + onTokenUsageRetry = {} ) ChatRoomContent(state = state, actions = actions, onBackClick = {}) } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt index 8c8ebe57..09c45e04 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt @@ -18,6 +18,8 @@ data class ChatRoomState( val riskDetection: RiskDetection? = null, /** 원격 설정 `use_chat_end_feature`. 꺼져 있으면 대화 끝내기 버튼을 숨긴다. */ val useChatEndFeature: Boolean = false, + val tokenUsagePercent: Int? = null, + val isTokenUsagePopupExpanded: Boolean = false, ) { /** 종료 흐름이 시작된 뒤로는 막는다. 종료 API 가 도는 중에 보내면 저장 여부가 갈린다. */ val canSend: Boolean diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt index 718fa017..a0fe046a 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt @@ -15,6 +15,7 @@ import com.gamss.android.domain.conversation.takeWithinMessageLimit import com.gamss.android.domain.repository.TokenUsageRefreshNotifier import com.gamss.android.domain.safety.DetectRiskInTextUseCase import com.gamss.android.domain.safety.RiskLevel +import com.gamss.android.domain.usecase.GetDailyTokenUsageUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -34,6 +35,7 @@ class ChatRoomViewModel @Inject constructor( private val tokenUsageRefreshNotifier: TokenUsageRefreshNotifier, private val detectRiskInText: DetectRiskInTextUseCase, private val getRemoteConfigFlag: GetRemoteConfigFlagUseCase, + private val getDailyTokenUsageUseCase: GetDailyTokenUsageUseCase, ) : ViewModel(), ContainerHost { @@ -114,6 +116,39 @@ class ChatRoomViewModel @Inject constructor( reduce { state.copy(replyTarget = null) } } + fun onTokenUsageToggle() = intent { + if (!state.isTokenUsagePopupExpanded) { + refreshTokenUsage() + } + reduce { state.copy(isTokenUsagePopupExpanded = !state.isTokenUsagePopupExpanded) } + } + + fun onTokenUsageRetry() = intent { + refreshTokenUsage() + } + + /** + * 사용자가 직접 요청한 조회(팝업 열기·재시도)는 실패를 그대로 반영해야 재시도 UI가 뜬다. + * [onSend]의 백그라운드 갱신처럼 조용히 이전 값을 유지하는 fallback을 여기선 쓰지 않는다. + */ + private suspend fun ChatRoomSyntax.refreshTokenUsage() { + val percent = fetchTokenUsagePercent() + reduce { state.copy(tokenUsagePercent = percent) } + } + + /** + * 조회 실패는 채팅 자체를 막을 이유가 없어 화면에는 조용히 null 로만 반영한다. + */ + private suspend fun fetchTokenUsagePercent(): Int? = + when (val result = getDailyTokenUsageUseCase()) { + is AppResult.Success -> { + result.data.usagePercent + } + is AppResult.Failure -> { + null + } + } + fun onSend() = intent { flushPendingComments() @@ -157,6 +192,10 @@ class ChatRoomViewModel @Inject constructor( when (result) { is AppResult.Success -> { val sent = result.data + tokenUsageRefreshNotifier.requestRefresh() + // 메시지 반영과 같은 reduce 에 묶어야 한다 — 따로 reduce 하면 상태 스트림에 + // 댓글 노출 사이로 사용량 갱신용 상태가 하나 더 끼어든다. + val usagePercent = fetchTokenUsagePercent() reduce { state.copy( isSending = false, @@ -165,11 +204,11 @@ class ChatRoomViewModel @Inject constructor( pendingComments = sent.comments, input = if (state.input == sending.content) "" else state.input, replyTarget = state.replyTarget.takeIf { it?.messageId != sending.replyToMessageId }, + tokenUsagePercent = usagePercent ?: state.tokenUsagePercent, ) } launchCommentReveal() sent.commentStatus.toUserMessage()?.let { postSideEffect(ChatRoomSideEffect.ShowToast(it)) } - tokenUsageRefreshNotifier.requestRefresh() session.finishSend() } is AppResult.Failure -> { diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml index fc95804a..c8bde7a3 100644 --- a/feature/chat/src/main/res/values/strings.xml +++ b/feature/chat/src/main/res/values/strings.xml @@ -9,6 +9,11 @@ 나에게 답장 답장 최신 메시지로 이동 + 토큰 사용량 + %1$d%% 사용됨 + 오전 5:00에 초기화됩니다 + 사용량을 불러오지 못했어요. + 재시도 입력 중 혼자 감당하지 않아도 괜찮아요. 대화에서 도움이 필요하다는 신호가 확인됐어요.\n전문가와 이야기해 보는 걸 추천드려요. diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt index 9239f560..4819c5f8 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt @@ -41,7 +41,6 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow -import java.time.LocalDate /** * 채팅 ViewModel 조립을 한 곳에 둔다. 테스트마다 따로 조립하면 세션 구성이 갈라진다. @@ -57,7 +56,16 @@ internal fun chatRoomViewModel( tokenUsageRefreshNotifier: TokenUsageRefreshNotifier = RecordingTokenUsageRefreshNotifier(), remoteConfigRepository: RemoteConfigRepository = FakeRemoteConfigRepository(), pendingReveal: PendingConversationReveal = PendingConversationReveal(), - session: ConversationSession = ConversationSession( + userRepository: UserRepository = FakeUserRepository(), +): ChatRoomViewModel = ChatRoomViewModel( + tokenUsageRefreshNotifier = tokenUsageRefreshNotifier, + detectRiskInText = DetectRiskInTextUseCase( + repository = NoRiskLexiconRepository, + matcher = RiskTermMatcher(), + ), + getRemoteConfigFlag = GetRemoteConfigFlagUseCase(remoteConfigRepository), + getDailyTokenUsageUseCase = GetDailyTokenUsageUseCase(userRepository), + session = ConversationSession( sendMessage = SendMessageUseCase(conversationRepository), getConversation = GetConversationUseCase(conversationRepository), updateConversationTitle = UpdateConversationTitleUseCase(conversationRepository), @@ -73,14 +81,6 @@ internal fun chatRoomViewModel( emotionAccumulator = ConversationEmotionAccumulator(classifier), pendingReveal = pendingReveal, ), -): ChatRoomViewModel = ChatRoomViewModel( - tokenUsageRefreshNotifier = tokenUsageRefreshNotifier, - detectRiskInText = DetectRiskInTextUseCase( - repository = NoRiskLexiconRepository, - matcher = RiskTermMatcher(), - ), - getRemoteConfigFlag = GetRemoteConfigFlagUseCase(remoteConfigRepository), - session = session, ) /** 원격 설정 조회 없이 항상 켜진 값을 돌려준다. 값 자체를 검증하는 테스트는 별도로 stub 한다. */ @@ -107,6 +107,22 @@ private object NoRiskLexiconRepository : RiskLexiconRepository { override suspend fun refresh() = Unit } +/** 토큰 사용량 조회만 있으면 되는 테스트용 스텁. 채팅 흐름은 닉네임/계정 API 를 쓰지 않는다. */ +internal class FakeUserRepository( + private val usage: DailyTokenUsage = DailyTokenUsage(usedTokens = 0, dailyLimit = 100, exceeded = false), +) : UserRepository { + override suspend fun updateNickname(nickname: String): AppResult = + error("Not needed for this test") + + override suspend fun deleteUserAccount(): AppResult = + error("Not needed for this test") + + override suspend fun getUserInfo(): AppResult = + error("Not needed for this test") + + override suspend fun getDailyTokenUsage(): AppResult = AppResult.Success(usage) +} + /** 갱신 요청 횟수만 센다. 홈 쪽 수신은 feature:home 테스트가 본다. */ internal class RecordingTokenUsageRefreshNotifier : TokenUsageRefreshNotifier { var refreshCount = 0 From c8e4af6aff43bb7106ee3346a012c6473b4b6516 Mon Sep 17 00:00:00 2001 From: seunghee Date: Mon, 17 Aug 2026 14:14:46 +0900 Subject: [PATCH 16/43] =?UTF-8?q?chore:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20detekt=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gamss/android/feature/chat/ChatRoomScreen.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 3874f11b..a3db5902 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -299,7 +299,7 @@ private fun ChatRoomTopBar( TokenUsagePopup( usagePercent = state.tokenUsagePercent, onDismissRequest = actions.onTokenUsageToggle, - onRetryClick = actions.onTokenUsageRetry + onRetryClick = actions.onTokenUsageRetry, ) } } @@ -330,7 +330,14 @@ private fun TokenUsagePopup( GamssTokenUsageTooltip( title = stringResource(R.string.chat_room_token_usage_title), usagePercent = usagePercent, - usagePercentLabel = if(usagePercent != null) stringResource(R.string.chat_room_token_usage_percent, usagePercent) else "", + usagePercentLabel = if (usagePercent != null) { + stringResource( + R.string.chat_room_token_usage_percent, + usagePercent, + ) + } else { + "" + }, resetTimeLabel = stringResource(R.string.chat_room_token_usage_reset_time), failMessage = stringResource(R.string.chat_room_check_token_usage_fail), retryLabel = stringResource(R.string.chat_room_check_token_usage_button_label), From a431ba228cf3d703e2e6b93e5bf07acdea7349aa Mon Sep 17 00:00:00 2001 From: seunghee Date: Mon, 17 Aug 2026 14:57:50 +0900 Subject: [PATCH 17/43] =?UTF-8?q?chore:=20detekt=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EC=9D=B4=EC=8A=88=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20conflict=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../topnavigation/GamssTopNavigation.kt | 4 +- .../src/main/res/drawable/ic_search.xml | 20 +++++++++ .../conversation/ConversationService.kt | 6 +-- .../chat/component/ChattingListTopBar.kt | 44 +++++-------------- 4 files changed, 38 insertions(+), 36 deletions(-) create mode 100644 core/designsystem/src/main/res/drawable/ic_search.xml 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 3c261cb0..4c623892 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 @@ -42,7 +42,9 @@ enum class GamssTopNavigationIcon(@DrawableRes internal val drawableRes: Int) { CreateCard(R.drawable.ic_create_card), Menu(R.drawable.ic_menu), - CheckToken(R.drawable.ic_token_check) + CheckToken(R.drawable.ic_token_check), + + Search(R.drawable.ic_search) } /** 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/data/src/main/java/com/gamss/android/data/remote/conversation/ConversationService.kt b/data/src/main/java/com/gamss/android/data/remote/conversation/ConversationService.kt index 226d4039..ea7d8626 100644 --- a/data/src/main/java/com/gamss/android/data/remote/conversation/ConversationService.kt +++ b/data/src/main/java/com/gamss/android/data/remote/conversation/ConversationService.kt @@ -3,7 +3,7 @@ package com.gamss.android.data.remote.conversation import com.gamss.android.data.remote.conversation.model.request.SaveMessageRequest import com.gamss.android.data.remote.conversation.model.request.UpdateConversationTitleRequest import com.gamss.android.data.remote.conversation.model.response.ChattingRoomSearchResponse -import com.gamss.android.data.remote.conversation.model.response.ConversationMessage +import com.gamss.android.data.remote.conversation.model.response.ConversationDetailResponse import com.gamss.android.data.remote.conversation.model.response.ConversationResponse import com.gamss.android.data.remote.conversation.model.response.SaveMessageResponse import com.gamss.android.data.remote.model.response.ApiResponse @@ -23,8 +23,8 @@ internal interface ConversationService { @POST("/api/conversations/messages") suspend fun saveMessage(@Body request: SaveMessageRequest): ApiResponse - @GET("/api/conversations/{conversationId}/messages") - suspend fun getMessages(@Path("conversationId") conversationId: Long): ApiResponse> + @GET("/api/conversations/{conversationId}") + suspend fun getConversation(@Path("conversationId") conversationId: Long): ApiResponse @PATCH("/api/conversations/{conversationId}/title") suspend fun updateTitle( 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..b75a81c2 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 @@ -1,21 +1,14 @@ package com.gamss.android.feature.chat.component import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp 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,31 +35,18 @@ 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, + leftIconContentDescription = stringResource(R.string.chatting_list_selection_cancel), + rightActions = listOf( + GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.Search, + onClick = onSearchClick + ), + GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.Menu, + onClick = onMenuClick, + ), + ), ) - - if (!isSelectionMode) { - // GamssTopNavigation의 오른쪽에 복수의 아이콘 받는 컴포넌트로 변경되었을때 이 컴포넌트 삭제 예정 (검색 모드 진입을 위한 임시조치) - IconButton( - onClick = onSearchClick, - modifier = Modifier - .align(Alignment.CenterEnd) - .offset(x = (-42).dp, y = 3.dp) - .size(48.dp), - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource( - R.string.chatting_list_search_content_description, - ), - tint = GamssTheme.colors.gray900, - ) - } - } } } From 85eedfa86921af01c1c3e4d78c4634de9ab1da50 Mon Sep 17 00:00:00 2001 From: seunghee Date: Mon, 17 Aug 2026 15:07:07 +0900 Subject: [PATCH 18/43] =?UTF-8?q?chore:=20test=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/domain/src/test/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt index 50656fea..4f81158b 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt @@ -3,8 +3,8 @@ package com.gamss.android.domain.chattingsearch import androidx.paging.PagingData import com.gamss.android.core.common.AppResult import com.gamss.android.domain.conversation.Conversation +import com.gamss.android.domain.conversation.ConversationDetail import com.gamss.android.domain.conversation.ConversationRepository -import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.SentMessage import com.gamss.android.domain.conversation.chattingsearch.ChattingRoomSearchException import com.gamss.android.domain.conversation.chattingsearch.ChattingRoomSummary @@ -76,7 +76,7 @@ class SearchChattingRoomsUseCaseTest { excludeCharacters: Set, ): AppResult = unused() - override suspend fun getMessages(conversationId: Long): AppResult> = unused() + override suspend fun getConversation(conversationId: Long): AppResult = unused() override suspend fun updateTitle(conversationId: Long, title: String): AppResult = unused() From 030ab63ad5e18a46b4c83a9a5a7de15aac55729d Mon Sep 17 00:00:00 2001 From: seunghee Date: Mon, 17 Aug 2026 15:19:22 +0900 Subject: [PATCH 19/43] =?UTF-8?q?fix:=20GamssTopNavigation=EC=9D=B4=20Gams?= =?UTF-8?q?sTheme=EC=9D=98=20=EA=B0=95=EC=A0=9C=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EB=94=B0=EB=A5=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/core/designsystem/theme/Theme.kt | 18 +++++++++++++++++- .../topnavigation/GamssTopNavigation.kt | 5 ++--- 2 files changed, 19 insertions(+), 4 deletions(-) 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 4c623892..c1be5588 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,7 +4,6 @@ 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 @@ -91,7 +90,7 @@ sealed interface GamssTopNavigationContent { fun GamssTopNavigation( content: GamssTopNavigationContent, modifier: Modifier = Modifier, - backgroundColor: Color = if (isSystemInDarkTheme()) { + backgroundColor: Color = if (GamssTheme.isDarkTheme) { GamssTheme.colors.black } else { GamssTheme.colors.white @@ -209,7 +208,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 From 3ef872b7dfefb1aee26d2b5372b0e8a8866d38a2 Mon Sep 17 00:00:00 2001 From: seunghee Date: Mon, 17 Aug 2026 23:11:21 +0900 Subject: [PATCH 20/43] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EB=B2=84?= =?UTF-8?q?=EB=B8=94=20vector=20drawable=20=EA=B3=BC=EB=8B=A4=20pathData?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=81=AC=EB=9E=98=EC=8B=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20chatmessage=5Fstroke=EC=9D=98=205,000?= =?UTF-8?q?=EA=B0=9C=20=EC=9D=B4=EC=83=81=20draw=20command=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=80=EC=A7=84=20vector=20pathData=EB=A5=BC=209-patch=20PNG?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20-=20painterResource()=EA=B0=80?= =?UTF-8?q?=209-patch(NinePatchDrawable)=EB=A5=BC=20=EC=A7=80=EC=9B=90?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EC=95=84=20=EB=B0=9C=EC=83=9D?= =?UTF-8?q?=ED=95=98=EB=8D=98=20=ED=81=AC=EB=9E=98=EC=8B=9C=EB=A5=BC,=20Dr?= =?UTF-8?q?awable=EC=9D=84=20=EC=A7=81=EC=A0=91=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20rememberNinePatchPainter=EB=A1=9C=20=EC=9A=B0?= =?UTF-8?q?=ED=9A=8C=20-=20=ED=85=8C=EB=91=90=EB=A6=AC(chatmessage=5Fstrok?= =?UTF-8?q?e)=EC=99=80=20=EB=8F=99=EC=9D=BC=ED=95=9C=20=EC=86=90=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=EC=9C=A4=EA=B3=BD=EC=97=90=EC=84=9C=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD=20=EC=B1=84=EC=9A=B0=EA=B8=B0(chatmessage=5Ffill)?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EC=B6=9C=ED=95=B4,=20=EA=B0=81=EC=A7=84?= =?UTF-8?q?=20=EB=B0=B0=EA=B2=BD=EC=9D=B4=20wobble=20=ED=85=8C=EB=91=90?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=96=EC=9C=BC=EB=A1=9C=20=EC=82=90=EC=A0=B8?= =?UTF-8?q?=EB=82=98=EC=98=A4=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20ChatBubbleSurface=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/chat/GamssChatBubble.kt | 89 +++++++++++++++--- .../res/drawable-nodpi/chatmessage_fill.9.png | Bin 0 -> 1030 bytes .../drawable-nodpi/chatmessage_stroke.9.png | Bin 0 -> 9915 bytes 3 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 core/designsystem/src/main/res/drawable-nodpi/chatmessage_fill.9.png create mode 100644 core/designsystem/src/main/res/drawable-nodpi/chatmessage_stroke.9.png 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 bcc6212b..39766f1c 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 @@ -18,12 +21,23 @@ 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 @@ -31,6 +45,7 @@ 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] 말풍선 안에 표시할 답장 인용 정보. @@ -189,18 +204,68 @@ private fun ChatBubbleSurface( 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 = horizontalPadding, vertical = verticalPadding), - 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) + } } } 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 0000000000000000000000000000000000000000..a30a7785cc38bc78df8596e8adcbbfbb82639934 GIT binary patch literal 1030 zcmeAS@N?(olHy`uVBq!ia0y~yVA=*`&*ESMk};pSI~W+4&w9EzhE&XXd+TD}BLfi! z$Jzh?r@s&063V?>!K>LneV$I}fvLiag1apf{9nIeWO(%KCi8*C&m@x9Y;|E`V7NV_ zxbElk*G0z^7#KeEWV3HRB*xIdlE}^A(6oV-A;EioeV;-OBZG$k&|VdeXa)wM7F`Ag zP83xNjyISXCSXx^J1To^vM%}BXBD3#N&Ai-!)rI485j<1o`b_hL^_;4%5GeX%9hQB zg*DDlggfTIwr6ubM{tuE32^P;7$s~ACCXxsNql~z$iVO*(hpCpAJ}#+>NPHZBt#dV zt6bAcd?KU3e+81sJ(kaHss$JrDvmsF+k591GXuk()!A>WOw+DMz5e?6K<@Fgui~Ce z>*qK5&iO(5PBF(%>3Qw{Z?((YF5SEB=J$Cb`#(8ZuKoA=UhTfE>GR|3uidMg^Z4$% z_sZWt`Jef>>ixC8-~YO1=GVTnetZ7^w`bOAwaf{Gkk1Xmv*K~9^B}s)~C|+qxy6? znm^T$>BjTvB%7noY)Tp#MGYt~O?3=~LhtBg(>U8`MsStD;!nj$2El<4Db(qI-^g!kcDtmM~@L+)JGKa-NeoORm`8}I&e}tLxQo^%P zLZ6iKiyykBaPclf6toGnCv9R+a+*$MmjRNRL8bjLG z5U-HDv9FO)y%Z4!>vp1teBE1Jcie@7{f->5CRP%~W&?>H2zsjBy38j*Rt+mj>DA+H(*_#Yox z-My%k2vj6nQ#J5A!;thbkhphfZGokWBU+d4yZs}S)!(4>7Hts61CQ*?4*7kvg8Z|)UaX>~t3(ga8YhRBWBs5rX%*_T!J5^&6TJ|zzvKyh$Q9g@9!TOnS2}=7{LGf_3M(U9aU`#T35&r zG$C9^?Pu~KRdsHzhC@52u3ZG~I)Ci#m>O}XMZWpm9C zXHm^WKt-sM%GLHm(7Fl)FabS>jll>Kh4p9NJZ{Klno6>1GVhc6ad7aEGnaBe-0Rtc zxZwnxIO>AHXLQ!bp?i9dSxlhbw2i>!GoB1+8}K0SGx#%5&>27G7$Z;)Uvo0y9JEBV z&JJew$5~FvF47m-b+NmW#+eqrt1}gIRoL$ZlB)6_jd+4&{v>|H9U#k7{f5Xc z*M9ZFOd(DbdL=~&!pU}()`cCffLh<^j&+0jKp#UHG*+~4#pr7UB~k*SvO02bZfym~ zpl5v~N30JZffP=Wguj3{04SCP8DvPr_Ix3)bO8>`yqaXqGzdY9@n-NE0S9|`W0+}5 zc(F9il$FV-yJ^^TmmI~^LVu=n9~L^ugecpLRg}~ok{rzv!()=CmaC@P zel^>}W>{S5mdx+g5@ru;&fT8!-$4ITYhQ_3+V9CGB_OF$eICVdC!8^YyTt6dl$rq! z_LAX!0_q}A!n>PPtLG}E*~Gh>Rm+jQJo`>9$%aEgELYvDZp@;@yxe`%UA4~*Y4Z%_ zDw*~(CW|?knqa@sBJ~{PX+BFNJnK{CoxaH}3Z`RP1sdb~V@~r~eq}1zubVVj*M~7IZD^HDTU!zPdNH+?v@G=@XKeS3`KGY4?EvM~_fOO+iMU z^c$BjH|2i0)sy&y(*iU2;7|G3;$pmLN{>pPP|Xwb*gq`LK-1N+e%0wD^CSoe^n z9+60(A@j9^&`L^~{+rlm;tATa819??Q7>AQqQL-xvtKfX@fB9k+pG#wSiv$DXEDwv zWg=JyI`d5B^bUDK%T{?L8xQ3=DxG%;CY6P#D+_5@@2I*94 z&scvD0*|MKFuCb<%B#Pi4zmKEKJG!8Dde&&zAZRs4v<@4*BSsWuQT7dmr%|Np9ii0cc~fo}3 zGSv^M<)R%*u>ZJiOfIzp~^La_g z2cVv0EE}d8zC`m{-w%Nk*as^UQ;B^xznt14{sjCkc47j!)uU7fKUL93aMsCOA&sVg zj^yUpj`xq~~5D1h3F?0%-VJxHT|S{-fsV|rpMKCzUBktZ)-y(4aq5kB#2&g2^5 z2LH7z1;LQbn#Qm?mQ7#ovw()hHCI}6Q_>2PwZ(e+4}mU8z?(T#IDk%{1CWb9u=g!a zQ%m|n1-N_OS2TDp|4HCwPJK>rTb$~6B292$arMy9kf**I?4kiODi5y>DtCTkW_zv{ zz~6Fm>{85^d+!Fy1md2%`tcYNf!*ZF>7MH<8Gdk?OuzHV^P;j0G5qS1;45(l46ruH zmNL36W{m}Zcr@QyPVZuc=Z^7Zh?k^D*qyuS*!O_=>nx5MyvkGwTH`8Zj>~=cRY;NE zlk-DuXFISG9GG?Q&t+9=T}LreEP~e5G=*7AfSF6fGA76%`e!B#qesotqzZDnb;~k#hR3kMOmfqL+-~o`d@fFH1O| z8DJUd{$XB|b4TGpj`?L{P&3s!6+!4F7py~i0&3Jc&EuR%SpRWer(^z|u zPsh|T88K_QdttG47)dv&AEBEddkxh<_e-*qh~kEXQp|wV)b3vAqLI>!5MC2Znlc|T z^Gg|UfEvK79`>Jks0;W&J4D7>!Fxz_rlF@iKi6*CFgta-g{#gXs)lu-*C~DgO-EF8@Zp>7Sxi}J4A~4Rxz`v1-zI9hA7dhps^LaK*?n2DM^Q?rl;-+_XrZXtfBEtLp^uGxjRcEeTd!i9Je}zvk*=mZZLErQ7S7lU=hBw^5e_^K5 zQn?$HZ;mmM+lbKWvJ{xm`^AkCJYxV}(fABg&_U3~Km>%6g)JH(0o?s6I9(uqFN<~9 zpj!#*2X!{UgOY4pRNO!nE4QbZHV&0WyEgaDk~fd`X!dd#`VWO_2We;ARtUmVki4KT zaJ{GT@x6(4@#yR{c9{PcxM+?@fLo0Xuftp*m4fhw@%q6nQ2V=gHjNj*pZ_}7p~&9| zG|+irI_D%eR6#4-j?ugyp|+qPD?%>nr^W2|M<}lQJT#GTJO_|tIP&g@=Ih;Dv}0>n zs*d*8LOiXEEGkPEL7z1AwTTY6m%o$LK<$T-&jfHWcnELf=B>Ce@mTA#MfdpGKoXE< z0~a%#Ox*1sC2#8<+|pD4`|HfS&ie6EwfdLas}R~1z9LmriRge?;Tz`nrD+?=XCxnn zmNamGXDDO<&qI4DIg=)I3EZU)ygN!ZV?9cB3_#H09}>dJPOA>1Tm@`BKBS;20Wk_z zT5$pAG@is`&VCp9BSkHL-wEm=Dj;rGtky*))h4XYncy^Jf|IwcejV7j%>E|R~fyh3qmzP zfGB1%nz+++SABP=`BPU{S7?PL$3nWg?E2hl6B%l%^v#Dee?uZ}%F~xbBcw#Ss=e8P zgv5@!cSBzAL?2n^SsD`Jzw{XyH%WZ^_ASHQ>&dQ={0$qW1oWXt&AaGvqMA4zos&tz zQ?4NpZ+G=IonMc6qDGDe4i~NLXK!z6sM$sEK7AnG^m+d0Pq`y|C;2N)Eg2aZLf5Wc zW9JbLDVEmA%YDFKmF}LKnw*@RdZ+CWtMtAn>g8ibo~d)yN3^py>F#=zln*BzeR7+Q z*)zgCzZtr-(jLDW^-t@wY|@DQw)k%c22g1(gB}M&9zLGlt^cFIkv)g`_(Nvndr04B z(pV~itq}u^_8sbswqzyeFo^Yc?h<6e=O|g0ZwBtJrDH<-Q6d%ZGMIerO()5OB`Y?O z3J0+W{>sRmrO~uF-J^TG@^Y=?<#KZ5vO{DNNfW*nPd6k70!m79F{X}Di_{SlXZlZ` zHMqUa7UXk#*VnlmH;Am*>TucVA``qXi%T$fb6^u00p|hRQocX`UT$(H*D5(08O%zw z-zHiDRdxw?TG;e-EUtNvHh(K7Vi4(wDZ&7G zZQrMCQjVOUU(*`iREIfF9nN)z%wh8;8Ar*)C94deZog}^DbrO_b2Y#2^Tq=N8m6w=bWisE1}Mq3bKVJC3P015Td`OsDVO! z&xX(QIA`*v^-r$ayhRUmIf8)PFRzRF#To0&$@3l%CWR>{5BE=F0IFwo)oOPtmp!13 zy!dA-t^84^-axMH%qB%_^dP9eZQS}zMMYRD6`7+ZBZR>~LP9`O+e90^jc4j3;}UcB zMOiY5h_tk_x@k7WwYx-IZBt*mGsrfQ5y(<#MvW!!7$R0^A+{_-;!I5#yn9HDEQO$(nWhN7dgZAWhCusB= zO=(w-FwabB&CGunlgYb}=7ZMv6)$Hn2eT!Nv7W@3N4<@- zYmS~{**U_;Zzqe@TL6H5-cX%Olvw0*t8tc# zSi@Zdc>z_|(+H-PMqcU=zUH00R@o*4`~B}y=7}5{NWn_V-ZG;&T!wh!h)^?^Bj$96 ze-Q~4`6f?Bv}z91MO|)Zah-Sv$3xSp3-Vi~78HKXZ8F_Tjw{{d+kF|UYt7#lz8Nd5 z=N_?+cF%BM<_kHYcwZiHFDf?k3HZ_LCMvk9aw_N})WC+Lz_oIp@R())=(#y}p8)jX zsSe=)^`X1yPv2V*Kw$~7C_DjL94a1~1DT0ABMpa^#l)BlSvI90~34SZk6+2GK$>|7p0joZmG z58$-QhwjOPLvp&8?!R5Wy5sjhmMSt5_Pg=INLaO@a{7KO-_EIMyvyi8#86MnGJUh= z1F|+J+5fzU3sw&Gl+uLtc5OJ0mCIcvtBr_cW%D;HWiQ2w26#VU+!v#4W&h8Tn+Jb3 zyAjtb#)_@a@p;y@rF`J|Phd@2jS|e~JFS0;r>66pCfL4p)o-Ft^PAFPj06^oMHFCl zFM+#aDyp}Z8MYK0cO6oYkb}uKL^d0j~ zU3AA?`nQz3jq-M8yaffJunjq|xAtG03~-;Vvt15%Hb{fmr4)UoAj?u?m!EdsFX(E< zZf292l-*%^tQSMQ$%zKlCsTz@Sj`x`6Ukw?Tfa~d=V#hjbXj?8>V$(so%yh%*KOi6 zq?lbn`*>xR%PKYdo-+5Sllyv^qs2IjBGxsy^x@`zI=+lB={naOhPM=_#sa;MzKNdj z0HU;J@Gd2JC$ea{Q^4MW%uEVmBMN{ScBBU6>~no_m7&V+)N#zh6m3HkkR%^RSD^p-zH=IhymPpW2F1#!ooZ=fis}$B(fFx%=Ovg1-<%+M@$c=f0NPLgar0rZw_%tt1fbGfiz1qC z|Ce72k z9Xc{h4C+Lq&U>Apl+RR}E4ysWYOoPRD6*C7NMYhMw;l$iVY_9$EXn535$%xUZ~0&5 zF`qPjlE+>}`L^fMZTix`hKJat$E|zCb3n#PdB1>fyOXSfrTN!y-IOhT`sut4>{5KZ zBZAF*Zjt%iDT1d5d%riK!WPXjzfg<3=W}cLxZscbq3RQFPu(J))J7gQzZw6@EoM5+EA?pA^luE( z_}8>$y}r#67c76v*)vQ^rj^I5wGay=d^1%@9}p&89iY94xDE> z9;D|jef@25;3$*MK3yQw}`_5ob)m-_2g!b?^%9H1a_bHs~F`SG%t@gj}n2PTTdT(1H zMPyH#Bt9Dv0{h*Q!QR`XT;0$*bCS14*}g4F85&mBa@YVGG}L)br^PgLYinT;2Dn9D zUl_mBrn+#48Aw8BA0aGv2&Y&o1&J_7FM|N3ZLhLaGOW8w&vljltV~ONH4sl2qiXcilOe* zpbwG|r8ENaWk!v5i)GK<#C#d*PU`}M_f=XP?|0kgF?w&C@~bL5|Amgmt!j(nkaC|m zoFzIyyVy(nWLGI&4U$StoUN z(JXI=*0GIX4SLc(KK>?lvIt0jY(eVrKrYF56MPgadt6;#tDdNw1Be~yZD6`X!dwB) zIr`jHWc}2PU#x0Aqvebp699M}40Qhk?fPEn@m`j8BsU12a4Mbo3cYt47`!W)aWMJB z5rnnW-8IZd=hM|&$o zo)(+5OQqCf7%UACdbm$KhNSI``*@6YwsCn)lU{UUii(P2$t{;?8u=^4OW?N=%N z@&iV_opz>2~4;oa4vN@|Tg zX5!DBJBv9v!h<#$9;k>6{MmdneP%$z)i&;lVQ_3J;Gtt|=82fS8Y2qSK#MgELFY>k zVy|eF0K1yC$$oJ&n(Nl#HN;o3Y1cH2HL_TxwVYY-d=E*TCXgKq7SuvrdEsSl8v8hI zG^%7!ae8Fr7N(*?wnqH6y`x8}Xs{6f2(NT%)^N7MJ>RlB z(Zs|gao;J=1WnS<1?M0+^7+6=7rd@Azp+zusDJ;kq#bE=ZZ0uYA8?yvNqr)eJL+z7 z?O9hajv}85ClnW%PZX9q({4piO~JriOYK(>D!Ipv^El(JN9qd?ig%lAsHodpTPqwL zu0}hJd95BD65U|ZX8@SAcEC@hm(2Xx3q6E7BXfb0O0?e2NIN;|sw<^3-vY-lpAK=F*x}02P@bez5 z>Tb|gY<U#ah}7wW!bi5g}JSyj)T4b5FM1k&9AyYsAOzZ7&Yl zY~LwLQku~1=rn3*gf5hF3{iLw0mw@TkIX<(j)Z%s7qj{iY#wxMxXlG|RcrK6Ph8r?8Q+TU8(niG5h|HF(jfrg*Oix&df5Krb< zDR>0qtkmN6e*9|N@lkWQdbdfQB)GKZomvnqe;W^k90cEJIvr-WOk~C#70v;h9 zV1*(aUm(1UjEr(sqK^*7rZ&|*+~>-b>ul%iY1BYl=RdfC*b0@9_JC(z9AlvxeC(5q zx0B;Hb)DyR#A6TtvmyLNU)E`!z+-QqRn*giu&pO;H{*4AtB*C_2fqHxUzzgLDa1qi zKt>PY(ro@TGiPZ_&Tu{oDkU@yT)B^qU0K?IQM~QtN=b-wds3n?mg&b0vw4+~T?x!c zqKUvt!CqV_6^;T$g`$`NaDpfw=qjHtleNj$ap+Vbe%L0Xtz}{4m+HqaU%n`_4Ft^c zU|cj-$F3{)Rv#$zZT`Exz5QTix_M*vQ);_PAJ2T}x2(!b1W;@dq4Ak9-;1O%$_C+u zU*;I83mpK10QXm~h1Y`%oD~>MeLq{OxOjxBqFji@sv{eRs5^7l07al&xDWrU)RY9^ z1<2oa;;t9i3*q_ClP0ZjAb=hxQy7M7nGa-X-<~Z|0{Ovb;5cQ|=)yHUl$4-vapAJ9 zgu7@vR|=g*V-5k( zJo@1kBnOkD-Ysg}&vxA(4VM4brqL8CxJt;^Yk^*HT0*gP^3t zfVOqZ_6_%oz%$-!q%*bWI4`0unaochPAvs0{{%vKAk|E5M6Mb02a5cg!2B^jaxXAYE8n46IA&!#jB1!VE@~@k zZ9P&B>@@wjboYJHQ-bYrdEO@F8xxa+!{U9HT9%+UZ`JlQd5_uacVFEg9Ss}{8=eWmHCvFVeEL;XP!5}ajaJ}S{VCKTth?tLD{ zlTD_!cr#ibzbeo}tsv~l{a%qKaQJ4iKO;^EXJpz0*uqsbZ*kKyYN}AIDYJxm9VYNC z#YgD5a^MsTZSHSV$bvpspa1#d}mF^uXHi%@>BL_gTosXCZa1r#_;T;#_hQUO-)vP@(sMWjT8>INf-jmFxe6^>DE8jFA?Ptdl5>Q+(>->9bYpcg7 zY^LvpYL1d>k5$YT`m3$ho2t-AZj`lFGTZ*UI|ZauI_(i)oeIwgKZwESs0=hIRi@AX zY1b_wed4)cH`Xb;rS$m=9BOo!qSpafE0 zn<3b^Zm7!AZ{d(i0I3>Qw6~ z9~c2g0fIzo2*4@g`l4UvA8MvNRQP><*;o79wWiI{(~!`ot`0l9g;RnbCC$BnFH35f z-C#C~Nzm16ev6BWV#tf6BkGE%IM)2O2WwooOq3&_VD86+gQMwO8@2sk=9IUf2%(&s z{!>duQJe+v%y&Q#vM?rg67%!7NXeNO;1P=2*j7bqk%62YP`mg8-0Ps95)m#&*dbcAq8p`30V2V z4quILWBWnVAuUZRZ*fz{mbe{wF zpC{uJF8Ce&mfv&EV*G9cOkgo+V<2b4*b9K5kvT65S$5Hvbx?1FZ z(CooM#EaR5{rzyM;hmDsl}Vz{mF@vUe@zw)SO*aB2>Ms6T}H`b9WOl@G{Yobza!68qM{kNRLU~`#c%))O>n=ct6FllE6 z?HQA+nPTMDQ_QChlGXMmk$kG?xSqc*KFCyjJjjL zYgPA{(MfU^Mwj%F-<^we^ws`;ynfmtC-@+6TWF*(t#_(5^4@;nYRMin&^8`DxQK6d zT3i?`xyZ)G_CwwTjARD%iwW9(&u7El9E;`^sB7ChIQV$jeEXFfEy=#{YwELMxe{ov zgVe*9HAhgOs;a7b>!+iLpJ-iny5GEc^F*HgX?q%b^1T1r$|O%n^nv9`f8hUJj;H@b ZOO;(J$=1+fA$^nv=<6Emyw!5P|9>qp%@F_q literal 0 HcmV?d00001 From 2270e6c46f4edce7d54cb4f0cf1911588c890a51 Mon Sep 17 00:00:00 2001 From: seunghee Date: Mon, 17 Aug 2026 23:45:36 +0900 Subject: [PATCH 21/43] =?UTF-8?q?feat:=20=EA=B0=90=EC=A0=95=EB=B3=84=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/chat/GamssChatBubble.kt | 18 +++-------- .../src/main/res/drawable/ic_avatar_anger.xml | 16 ++++++++++ .../main/res/drawable/ic_avatar_anxiety.xml | 16 ++++++++++ .../src/main/res/drawable/ic_avatar_joy.xml | 16 ++++++++++ .../main/res/drawable/ic_avatar_prickly.xml | 16 ++++++++++ .../main/res/drawable/ic_avatar_quirky.xml | 16 ++++++++++ .../main/res/drawable/ic_avatar_sadness.xml | 19 ++++++++++++ .../chat/component/EmotionAvatarIcon.kt | 16 ++++++++++ .../feature/chat/component/MessageBubble.kt | 16 +++++++++- .../feature/chat/component/NewMessageToast.kt | 30 +++++++++++++------ 10 files changed, 155 insertions(+), 24 deletions(-) create mode 100644 core/designsystem/src/main/res/drawable/ic_avatar_anger.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_avatar_anxiety.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_avatar_joy.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_avatar_prickly.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_avatar_quirky.xml create mode 100644 core/designsystem/src/main/res/drawable/ic_avatar_sadness.xml create mode 100644 feature/chat/src/main/java/com/gamss/android/feature/chat/component/EmotionAvatarIcon.kt 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 39766f1c..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 @@ -78,7 +78,8 @@ 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 @@ -329,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/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/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EmotionAvatarIcon.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EmotionAvatarIcon.kt new file mode 100644 index 00000000..6f363fc1 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EmotionAvatarIcon.kt @@ -0,0 +1,16 @@ +package com.gamss.android.feature.chat.component + +import androidx.annotation.DrawableRes +import com.gamss.android.domain.emotion.EmotionCharacter +import com.gamss.android.core.designsystem.R as DesignSystemR + +/** [EmotionCharacter] 6종 각각에 대응하는 core:designsystem 아바타 아이콘. */ +internal val EmotionCharacter.avatarIconRes: Int + @DrawableRes get() = when (this) { + EmotionCharacter.JOY -> DesignSystemR.drawable.ic_avatar_joy + EmotionCharacter.ANGER -> DesignSystemR.drawable.ic_avatar_anger + EmotionCharacter.ANXIETY -> DesignSystemR.drawable.ic_avatar_anxiety + EmotionCharacter.SADNESS -> DesignSystemR.drawable.ic_avatar_sadness + EmotionCharacter.QUIRKY -> DesignSystemR.drawable.ic_avatar_quirky + EmotionCharacter.PRICKLY -> DesignSystemR.drawable.ic_avatar_prickly + } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt index 95ef5f83..c5fd3812 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt @@ -1,10 +1,14 @@ package com.gamss.android.feature.chat.component +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote import com.gamss.android.core.designsystem.component.chat.ChatSender @@ -43,7 +47,17 @@ fun MessageBubble( ) is MessageSender.Character -> GamssReceivedChatBubble( - sender = ChatSender(name = sender.character.displayName), + sender = ChatSender( + name = sender.character.displayName, + avatar = { + Image( + painter = painterResource(sender.character.avatarIconRes), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + }, + ), message = message.content, time = message.createdTime, replyQuote = replyQuote, diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/NewMessageToast.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/NewMessageToast.kt index dd56879f..8712f21a 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/NewMessageToast.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/NewMessageToast.kt @@ -1,10 +1,13 @@ package com.gamss.android.feature.chat.component import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -14,6 +17,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -28,7 +33,7 @@ import com.gamss.android.domain.emotion.EmotionCharacter // 디자인 상 각지게 그려진다(둥근 pill 아님). private val ToastShape = RectangleShape private val AvatarSize = 16.dp -private val ToastStrokeWidth = 1.dp +private val ToastStrokeWidth = 0.5.dp private val ToastHorizontalMargin = 40.dp private val ToastHorizontalPadding = 16.dp private val ToastVerticalPadding = 10.dp @@ -48,11 +53,9 @@ fun NewMessageToast( modifier: Modifier = Modifier, ) { // 발신자는 항상 캐릭터다 — 본인이 보낸 메시지는 호출부에서 애초에 이 토스트 대상으로 넘기지 - // 않는다. Unknown 은 MessageBubble 과 동일하게 빈 이름으로 둔다. - val senderName = when (val sender = message.sender) { - is MessageSender.Character -> sender.character.displayName - MessageSender.User, MessageSender.Unknown -> "" - } + // 않는다. Unknown 은 MessageBubble 과 동일하게 빈 이름/빈 아바타로 둔다. + val character = (message.sender as? MessageSender.Character)?.character + val senderName = character?.displayName.orEmpty() Row( modifier = modifier @@ -66,7 +69,7 @@ fun NewMessageToast( .padding(horizontal = ToastHorizontalPadding, vertical = ToastVerticalPadding), verticalAlignment = Alignment.CenterVertically, ) { - ToastAvatar() + ToastAvatar(avatarIconRes = character?.avatarIconRes) GamssText( text = senderName, modifier = Modifier.padding(start = ToastContentGap), @@ -89,13 +92,22 @@ fun NewMessageToast( } @Composable -private fun ToastAvatar(modifier: Modifier = Modifier) { +private fun ToastAvatar(@DrawableRes avatarIconRes: Int?, modifier: Modifier = Modifier) { Box( modifier = modifier .size(AvatarSize) .clip(CircleShape) .background(GamssTheme.colors.gray025), - ) + ) { + if (avatarIconRes != null) { + Image( + painter = painterResource(avatarIconRes), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } } @Preview(name = "Light", showBackground = true) From 0a3ec04f77733b4fd28aec0ccbb36e58ad7de2a3 Mon Sep 17 00:00:00 2001 From: seunghee Date: Tue, 18 Aug 2026 00:30:20 +0900 Subject: [PATCH 22/43] =?UTF-8?q?fix:=20=EC=B9=B4=EB=93=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/res/drawable/ic_create_card.xml | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) 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 95e22dd2..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"/> + + Date: Tue, 18 Aug 2026 00:35:41 +0900 Subject: [PATCH 23/43] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B2=80=EC=83=89=EC=B0=BD=20=EC=BB=A4?= =?UTF-8?q?=EC=84=9C=20=EC=83=89=EC=83=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/feature/chat/component/ChattingSearchContent.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingSearchContent.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingSearchContent.kt index 1101b922..db751be3 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingSearchContent.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingSearchContent.kt @@ -155,6 +155,7 @@ private fun SearchInput( unfocusedContainerColor = GamssTheme.colors.gray075, focusedIndicatorColor = GamssTheme.colors.gray075, unfocusedIndicatorColor = GamssTheme.colors.gray075, + cursorColor = GamssTheme.colors.gray900 ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { onSearch() }), From 8866d25abaed279b35d25711b5102e36d9866165 Mon Sep 17 00:00:00 2001 From: seunghee Date: Tue, 18 Aug 2026 06:19:34 +0900 Subject: [PATCH 24/43] =?UTF-8?q?fix:=20=EB=A1=9C=EC=BB=AC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EC=98=A4=EB=A5=98=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt | 4 ++-- .../java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt index e671cd9f..2c034ba7 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt @@ -5,7 +5,7 @@ import com.gamss.android.domain.card.CreateConversationCardUseCase import com.gamss.android.domain.conversation.ConversationSession import com.gamss.android.domain.conversation.ConversationSummaryStore import com.gamss.android.domain.conversation.EndConversationUseCase -import com.gamss.android.domain.conversation.GetMessagesUseCase +import com.gamss.android.domain.conversation.GetConversationUseCase import com.gamss.android.domain.conversation.PendingConversationReveal import com.gamss.android.domain.conversation.SendMessageUseCase import com.gamss.android.domain.conversation.UpdateConversationTitleUseCase @@ -93,7 +93,7 @@ class ChatRoomLoadTest { private fun homeSession(repository: FakeConversationRepository, pendingReveal: PendingConversationReveal) = ConversationSession( sendMessage = SendMessageUseCase(repository), - getMessages = GetMessagesUseCase(repository), + getConversation = GetConversationUseCase(repository), updateConversationTitle = UpdateConversationTitleUseCase(repository), endConversation = EndConversationUseCase(repository), createConversationCard = CreateConversationCardUseCase( diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt index 4819c5f8..2e7d0c8e 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomTestFakes.kt @@ -29,6 +29,7 @@ import com.gamss.android.domain.emotion.ConversationEmotionAccumulator import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.domain.emotion.EmotionClassifier import com.gamss.android.domain.emotion.EmotionLabel +import com.gamss.android.domain.model.DailyTokenUsage import com.gamss.android.domain.repository.TokenUsageRefreshNotifier import com.gamss.android.domain.safety.DetectRiskInTextUseCase import com.gamss.android.domain.safety.RiskLexicon @@ -37,10 +38,14 @@ import com.gamss.android.domain.safety.RiskTermMatcher import com.gamss.android.domain.summary.DiarySummarizer import com.gamss.android.domain.summary.SummarizeDiaryUseCase import com.gamss.android.domain.summary.UtteranceTokenCounter +import com.gamss.android.domain.usecase.GetDailyTokenUsageUseCase +import com.gamss.android.domain.user.UserProfile +import com.gamss.android.domain.user.UserRepository import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow +import java.time.LocalDate /** * 채팅 ViewModel 조립을 한 곳에 둔다. 테스트마다 따로 조립하면 세션 구성이 갈라진다. From 638d37843418eeba5bdc7a0874ff00d0375d464e Mon Sep 17 00:00:00 2001 From: seunghee Date: Tue, 18 Aug 2026 06:30:31 +0900 Subject: [PATCH 25/43] =?UTF-8?q?fix:=20reveal=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EC=97=90=EC=84=9C=EB=8F=84=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=EB=B0=A9=20title(=EC=83=9D=EC=84=B1=20=EC=9D=BC?= =?UTF-8?q?=EC=8B=9C)=EC=9D=84=20=EC=A6=89=EC=8B=9C=20=EC=B1=84=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20-=20PendingConversationReveal.consume()=EC=9D=B4=20?= =?UTF-8?q?SentMessage=20=EB=8C=80=EC=8B=A0=20PendingReveal=20=20=20(sent?= =?UTF-8?q?=20+=20createdAt)=EC=9D=84=20=EB=B0=98=ED=99=98=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=ED=99=95=EC=9E=A5=20-=20createdAt?= =?UTF-8?q?=EC=9D=80=20=EC=84=9C=EB=B2=84=20=EA=B0=92=EC=9D=B4=20=EC=95=84?= =?UTF-8?q?=EB=8B=88=EB=9D=BC=20save()=20=ED=98=B8=EC=B6=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81(=EB=8C=80=ED=99=94=20=EC=83=9D=EC=84=B1=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90)=EC=9D=84=20=EC=82=AC=EC=9A=A9=20-=20ChatRoomViewMode?= =?UTF-8?q?l=EC=9D=98=20pending-reveal=20=EB=B6=84=EA=B8=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20conversationCreatedAt=EC=9D=84=20=20=20=EC=A6=89?= =?UTF-8?q?=EC=8B=9C=20=EC=84=B8=ED=8C=85=ED=95=B4,=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=9E=AC=EC=A1=B0=ED=9A=8C=20=EC=97=86=EC=9D=B4=EB=8F=84=20?= =?UTF-8?q?top=20bar=20title=EC=9D=B4=20=EB=B0=94=EB=A1=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../conversation/ConversationSession.kt | 8 +++---- .../conversation/PendingConversationReveal.kt | 23 ++++++++++++++++--- .../conversation/ConversationSessionTest.kt | 2 +- .../PendingConversationRevealTest.kt | 21 ++++++++++++++--- .../android/feature/chat/ChatRoomViewModel.kt | 5 ++-- .../android/feature/chat/ChatRoomLoadTest.kt | 2 ++ 6 files changed, 48 insertions(+), 13 deletions(-) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt index 59ca6aa8..d84c3221 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt @@ -83,12 +83,12 @@ class ConversationSession @Inject constructor( } /** 홈 쪽 인스턴스에서만 채워진 요약·감정 상태를, [restore] 와 같은 방식으로 이 인스턴스에도 시드해 둔다. */ - suspend fun consumePendingReveal(conversationId: Long): SentMessage? { - val sent = pendingReveal.consume(conversationId) ?: return null - val utterances = listOf(sent.message).userUtterances() + suspend fun consumePendingReveal(conversationId: Long): PendingReveal? { + val pending = pendingReveal.consume(conversationId) ?: return null + val utterances = listOf(pending.sent.message).userUtterances() summaryStore.restore(utterances) emotionAccumulator.restore(utterances) - return sent + return pending } suspend fun finishSend() = coroutineScope { diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt index 9e555a5d..cb24d6db 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt @@ -2,6 +2,9 @@ package com.gamss.android.domain.conversation import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton @@ -18,12 +21,26 @@ class PendingConversationReveal @Inject constructor() { mutex.withLock { pending = Entry(sent, now) } } - suspend fun consume(conversationId: Long, now: Long = System.currentTimeMillis()): SentMessage? = + suspend fun consume(conversationId: Long, now: Long = System.currentTimeMillis()): PendingReveal? = mutex.withLock { val current = pending?.takeIf { it.sent.message.conversationId == conversationId } ?: return@withLock null pending = null - current.sent.takeIf { now - current.savedAtMillis <= PENDING_REVEAL_EXPIRY_MILLIS } + current.takeIf { now - it.savedAtMillis <= PENDING_REVEAL_EXPIRY_MILLIS }?.toPendingReveal() } - private data class Entry(val sent: SentMessage, val savedAtMillis: Long) + private data class Entry(val sent: SentMessage, val savedAtMillis: Long) { + fun toPendingReveal() = PendingReveal( + sent = sent, + createdAt = Instant.ofEpochMilli(savedAtMillis).atZone(ZoneId.systemDefault()).toLocalDateTime(), + ) + } } + +/** + * [PendingConversationReveal.consume] 결과. 서버 대화 상세를 다시 받아오기 전이라 [createdAt] 은 + * 서버 값이 아니라 [PendingConversationReveal.save] 가 호출된(=대화가 막 생긴) 시각이다. + */ +data class PendingReveal( + val sent: SentMessage, + val createdAt: LocalDateTime, +) diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt index 0c032939..3024383a 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt @@ -163,7 +163,7 @@ class ConversationSessionTest { val result = homeSession.send(conversationId = null, content = SEED, replyToMessageId = null) val sent = (result as AppResult.Success).data - assertEquals(sent, chatSession.consumePendingReveal(ROOM_ID)) + assertEquals(sent, chatSession.consumePendingReveal(ROOM_ID)?.sent) } @Test diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt index a471121e..409aed23 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import java.time.Instant +import java.time.ZoneId class PendingConversationRevealTest { @@ -14,7 +16,20 @@ class PendingConversationRevealTest { reveal.save(sent, now = BASE_TIME) - assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)) + assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)?.sent) + } + + @Test + fun 저장_시각이_생성_일시로_즉시_채워진다() = runBlocking { + val reveal = PendingConversationReveal() + reveal.save(sentMessage(), now = BASE_TIME) + + val result = reveal.consume(ROOM_ID, now = BASE_TIME) + + assertEquals( + Instant.ofEpochMilli(BASE_TIME).atZone(ZoneId.systemDefault()).toLocalDateTime(), + result?.createdAt, + ) } @Test @@ -34,7 +49,7 @@ class PendingConversationRevealTest { reveal.save(sent, now = BASE_TIME) assertNull(reveal.consume(OTHER_ROOM_ID, now = BASE_TIME)) - assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)) + assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)?.sent) } @Test @@ -44,7 +59,7 @@ class PendingConversationRevealTest { val result = reveal.consume(ROOM_ID, now = BASE_TIME + PENDING_REVEAL_EXPIRY_MILLIS) - assertEquals(ROOM_ID, result?.message?.conversationId) + assertEquals(ROOM_ID, result?.sent?.message?.conversationId) } @Test diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt index a0fe046a..99943bec 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt @@ -70,8 +70,9 @@ class ChatRoomViewModel @Inject constructor( reduce { state.copy( isLoading = false, - messages = listOf(pending.message), - pendingComments = pending.comments, + conversationCreatedAt = pending.createdAt, + messages = listOf(pending.sent.message), + pendingComments = pending.sent.comments, ) } launchCommentReveal() diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt index 2c034ba7..b509128b 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt @@ -56,6 +56,8 @@ class ChatRoomLoadTest { listOf(COMMENT_ID_BASE + 0, COMMENT_ID_BASE + 1, COMMENT_ID_BASE + 2), afterLoad.pendingComments.map { it.id }, ) + // reveal 캐시 경로도 서버 재조회 없이 top bar 제목(생성 일시)을 즉시 채운다. + assertTrue(afterLoad.conversationCreatedAt != null) val firstReveal = awaitState() assertEquals(COMMENT_ID_BASE + 0, firstReveal.messages.last().id) From 1b2995e1160dab0765602ac356ae7c55e1d4e4cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Tue, 18 Aug 2026 11:36:58 +0900 Subject: [PATCH 26/43] =?UTF-8?q?fix:=20=EC=B9=B4=EB=93=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=A4=91=20=EB=A1=9C=EB=94=A9=20=EC=9D=B8=EB=94=94?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=ED=84=B0=20=EB=B3=B5=EC=9B=90=20-=20GamssTop?= =?UTF-8?q?NavigationIconAction=EC=97=90=20isLoading=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=B4=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B8=B0"=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=ED=95=98=EB=8F=84=EB=A1=9D=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20-=20ChatRoomScreen=EC=97=90=EC=84=9C=20endFlow.isBu?= =?UTF-8?q?sy=EC=9D=BC=20=EB=95=8C=20CreateCard=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EC=9E=90=EB=A6=AC=EB=A5=BC=20=EB=B9=84=EC=9A=B0?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20isLoading=20=3D=20true?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20=E2=80=94=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=A4=91=EC=9E=84=EC=9D=84=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=8B=9C=EA=B0=81=EC=A0=81=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=95=8C=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../topnavigation/GamssTopNavigation.kt | 31 ++++++++++++++----- .../android/feature/chat/ChatRoomScreen.kt | 16 +++++++--- 2 files changed, 36 insertions(+), 11 deletions(-) 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 c1be5588..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 @@ -12,6 +12,7 @@ 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 @@ -50,12 +51,17 @@ enum class GamssTopNavigationIcon(@DrawableRes internal val drawableRes: Int) { * 오른쪽 끝에 놓이는 아이콘 하나를 나타냅니다. [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 { @@ -160,6 +166,7 @@ fun GamssTopNavigation( icon = action.icon, contentDescription = action.contentDescription, onClick = action.onClick, + isLoading = action.isLoading, ) } } @@ -235,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, + ) + } } } } @@ -327,5 +343,6 @@ 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/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index a3db5902..d1fc7ed4 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -260,6 +260,7 @@ private fun ChatRoomContent( endFlow = state.endFlow, input = state.input, isInputEnabled = !state.isLoading && state.endFlow == EndFlow.NotStarted, + isSending = state.isSending, replyTarget = state.replyTarget, actions = actions, ) @@ -280,13 +281,18 @@ private fun ChatRoomTopBar( showLeftIcon = true, onLeftIconClick = onBackClick, rightActions = listOfNotNull( - if (state.useChatEndFeature && !state.endFlow.isBusy && state.canEnd) { - GamssTopNavigationIconAction( + when { + !state.useChatEndFeature -> null + state.endFlow.isBusy -> GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.CreateCard, + onClick = {}, + isLoading = true, + ) + state.canEnd -> GamssTopNavigationIconAction( icon = GamssTopNavigationIcon.CreateCard, onClick = actions.onEndClick, ) - } else { - null + else -> null }, GamssTopNavigationIconAction( icon = GamssTopNavigationIcon.CheckToken, @@ -448,6 +454,7 @@ private fun ChatRoomInputSection( endFlow: EndFlow, input: String, isInputEnabled: Boolean, + isSending: Boolean, replyTarget: ReplyTarget?, actions: ChatRoomActions, ) { @@ -467,6 +474,7 @@ private fun ChatRoomInputSection( MessageInputBar( input = input, enabled = isInputEnabled, + isSending = isSending, replyTarget = replyTarget, onInputChange = actions.onInputChange, onSendClick = actions.onSendClick, From 3334d413782ffc3ae5a4d564d0c8739685b17da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Tue, 18 Aug 2026 11:38:59 +0900 Subject: [PATCH 27/43] =?UTF-8?q?fix:=20selection=20mode=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80=20=EB=B3=B5=EC=9B=90=20-=20ChattingListTopBar?= =?UTF-8?q?=EC=97=90=EC=84=9C=20!isSelectionMode=EC=9D=BC=20=EB=95=8C?= =?UTF-8?q?=EB=A7=8C=20Search=20=EC=95=84=EC=9D=B4=EC=BD=98=EC=9D=84=20rig?= =?UTF-8?q?htActions=EC=97=90=20=ED=8F=AC=ED=95=A8=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=9B=90=EB=9E=98=20=EA=B0=80=EB=93=9C=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/chat/component/ChattingListTopBar.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 b75a81c2..04645152 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 @@ -37,11 +37,16 @@ internal fun ChattingListTopBar( showLeftIcon = isSelectionMode, onLeftIconClick = onSelectionCancel, leftIconContentDescription = stringResource(R.string.chatting_list_selection_cancel), - rightActions = listOf( - GamssTopNavigationIconAction( - icon = GamssTopNavigationIcon.Search, - onClick = onSearchClick - ), + rightActions = listOfNotNull( + // 선택 모드에서는 검색으로 들어갈 수 없다 + if (!isSelectionMode) { + GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.Search, + onClick = onSearchClick, + ) + } else { + null + }, GamssTopNavigationIconAction( icon = GamssTopNavigationIcon.Menu, onClick = onMenuClick, From bccfe228bd000d51f038a253e5226f64dcf364e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Tue, 18 Aug 2026 11:42:53 +0900 Subject: [PATCH 28/43] =?UTF-8?q?fix:=20ChatRoomViewModel.onSend()?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A9=94=EC=8B=9C=EC=A7=80=20reduce?= =?UTF-8?q?=EB=A5=BC=20=ED=86=A0=ED=81=B0=20=EC=82=AC=EC=9A=A9=EB=9F=89=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=99=84=EB=A3=8C=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EA=B8=B0=EB=8B=A4=EB=A6=AC=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/feature/chat/ChatRoomViewModel.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt index 99943bec..175414a9 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt @@ -137,6 +137,18 @@ class ChatRoomViewModel @Inject constructor( reduce { state.copy(tokenUsagePercent = percent) } } + /** + * [onSend] 성공 직후의 백그라운드 갱신. 메시지를 반영하는 reduce와 분리된 별도 인텐트로 + * 띄워, 사용량 조회가 느려도 이미 도착한 메시지 표시가 지연되지 않게 한다. 실패 시엔 + * 이전 값을 조용히 유지한다. + */ + private fun refreshTokenUsageInBackground() { + intent { + val usagePercent = fetchTokenUsagePercent() + reduce { state.copy(tokenUsagePercent = usagePercent ?: state.tokenUsagePercent) } + } + } + /** * 조회 실패는 채팅 자체를 막을 이유가 없어 화면에는 조용히 null 로만 반영한다. */ @@ -194,9 +206,6 @@ class ChatRoomViewModel @Inject constructor( is AppResult.Success -> { val sent = result.data tokenUsageRefreshNotifier.requestRefresh() - // 메시지 반영과 같은 reduce 에 묶어야 한다 — 따로 reduce 하면 상태 스트림에 - // 댓글 노출 사이로 사용량 갱신용 상태가 하나 더 끼어든다. - val usagePercent = fetchTokenUsagePercent() reduce { state.copy( isSending = false, @@ -205,12 +214,12 @@ class ChatRoomViewModel @Inject constructor( pendingComments = sent.comments, input = if (state.input == sending.content) "" else state.input, replyTarget = state.replyTarget.takeIf { it?.messageId != sending.replyToMessageId }, - tokenUsagePercent = usagePercent ?: state.tokenUsagePercent, ) } launchCommentReveal() sent.commentStatus.toUserMessage()?.let { postSideEffect(ChatRoomSideEffect.ShowToast(it)) } session.finishSend() + refreshTokenUsageInBackground() } is AppResult.Failure -> { reduce { state.copy(isSending = false) } From ad8fce0eb6040ed7ad4ad88fa6f276d287317d32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Tue, 18 Aug 2026 11:44:04 +0900 Subject: [PATCH 29/43] =?UTF-8?q?fix:=20=EC=A0=84=EC=86=A1=20=EC=A4=91=20s?= =?UTF-8?q?end=20=EB=B2=84=ED=8A=BC=EB=A7=8C=20=EB=B9=84=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=20=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/core/designsystem/component/GamssInputBar.kt | 8 +++++++- .../android/feature/chat/component/MessageInputBar.kt | 4 ++++ .../com/gamss/android/feature/chat/ChatRoomRevealTest.kt | 3 +++ 3 files changed, 14 insertions(+), 1 deletion(-) 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 c114dace..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 @@ -65,6 +65,12 @@ fun GamssInputBar( placeholder: String = "", trailingContentDescription: String? = null, enabled: Boolean = true, + /** + * 전송 버튼(및 키보드 전송 액션)만 따로 잠글 때 쓴다. 기본은 [enabled]를 그대로 따른다. + * 입력칸은 계속 켜둔 채 전송만 잠깐 막고 싶을 때(예: 메시지 전송 중 포커스·키보드는 + * 유지하되 중복 전송만 막는 경우) [enabled]와 분리해 넘긴다. + */ + sendEnabled: Boolean = enabled, /** 후행 아이콘 앞에 놓이는 자리. 홈은 여기에 감정 선택 토글을 단다. */ trailingAction: @Composable (() -> Unit)? = null, /** 이 줄 수까지 늘어나고, 넘는 내용은 입력칸 안에서 스크롤된다. 화면별 글자 상한에 맞춰 조정한다. */ @@ -74,7 +80,7 @@ fun GamssInputBar( onReplyClear: () -> Unit = {}, replyClearContentDescription: String? = null, ) { - val canSubmit = enabled && value.isNotBlank() + val canSubmit = sendEnabled && value.isNotBlank() val verticalPadding = if (replyQuote != null) InputBarReplyVerticalPadding else 0.dp Column( diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt index 4f13f2dd..23c41c70 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt @@ -15,6 +15,7 @@ import com.gamss.android.feature.chat.ReplyTarget fun MessageInputBar( input: String, enabled: Boolean, + isSending: Boolean, replyTarget: ReplyTarget?, onInputChange: (String) -> Unit, onSendClick: () -> Unit, @@ -30,6 +31,9 @@ fun MessageInputBar( placeholder = stringResource(R.string.chat_room_input_placeholder), trailingContentDescription = "보내기", enabled = enabled, + // 전송 중에도 입력칸 포커스·키보드는 유지하되(enabled), 전송 버튼만 잠가 중복 전송을 + // 시각적으로도 막는다. + sendEnabled = enabled && !isSending, maxLines = MESSAGE_INPUT_MAX_LINES, replyQuote = replyTarget?.let { ChatReplyQuote( diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt index 985f0c9c..4b31ab6f 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt @@ -46,6 +46,7 @@ class ChatRoomRevealTest { afterSend.pendingComments.map { it.id }, ) assertTrue(afterSend.isAwaitingComments) + skipItems(1) // 전송 성공 뒤 백그라운드로 갱신되는 토큰 사용량 반영 val firstReveal = awaitState() assertEquals(COMMENT_ID_BASE + 0, firstReveal.messages.last().id) @@ -76,6 +77,7 @@ class ChatRoomRevealTest { val afterSend = awaitState() assertEquals(4, afterSend.pendingComments.size) + skipItems(1) // 전송 성공 뒤 백그라운드로 갱신되는 토큰 사용량 반영 containerHost.onInputChange(INPUT) skipItems(1) // input 반영 @@ -102,6 +104,7 @@ class ChatRoomRevealTest { val afterSend = awaitState() assertEquals(1, afterSend.pendingComments.size) assertEquals(1, afterSend.messages.size) + skipItems(1) // 전송 성공 뒤 백그라운드로 갱신되는 토큰 사용량 반영 val afterReveal = awaitState() assertTrue(afterReveal.pendingComments.isEmpty()) From ff9844e1c8ad69b0dc1bc936d206204c972e0781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Tue, 18 Aug 2026 12:40:42 +0900 Subject: [PATCH 30/43] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=83=88=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EA=B0=80=20=EC=8A=A4=ED=81=AC=EB=A1=A4=ED=95=B4?= =?UTF-8?q?=EB=8F=84=20=EC=95=88=20=EC=82=AC=EB=9D=BC=EC=A7=80=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20-=20newMessageToast?= =?UTF-8?q?=EB=A5=BC=20=EC=A7=80=EC=9A=B0=EB=8A=94=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EC=9D=84=20"=ED=86=A0=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EA=B0=80?= =?UTF-8?q?=EB=A6=AC=ED=82=A4=EB=8A=94=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EA=B0=80=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8A=94=EC=A7=80(visibleItemsInfo)"=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20-=20=EB=92=A4=EC=97=90=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EB=B2=84=EB=B8=94=20=EB=93=B1=20=EC=BD=98=ED=85=90=EC=B8=A0?= =?UTF-8?q?=EA=B0=80=20=EB=8D=94=20=EC=9E=88=EC=96=B4=20=EB=B0=94=EB=8B=A5?= =?UTF-8?q?=EC=97=90=20=EB=AA=BB=20=EB=8B=BF=EC=95=84=EB=8F=84,=20?= =?UTF-8?q?=EA=B7=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EB=A7=8C=20=EB=B3=B4=EC=9D=B4=EB=A9=B4=20=EC=B6=A9=EB=B6=84?= =?UTF-8?q?=ED=9E=88=20=EC=A7=80=EC=9B=8C=EC=A7=80=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/chat/util/ChatScrollState.kt | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt index 0df8714a..4b8e08c2 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt @@ -21,8 +21,8 @@ import kotlinx.coroutines.launch * - 최초 진입(과거 메시지 로딩 완료) 시 한 번 바닥으로 점프한다. * - 내가 메시지를 보내면(전송 완료 시점) 항상 바닥까지 스크롤한다. 사용자의 명시적 행동이라 * 상대 메시지 도착 시와 달리 자동 스크롤을 유지한다. - * - 상대 메시지는 자동 스크롤하지 않는다. 바닥이 아닌 상태에서 도착하면 [newMessageToast]를 - * 채워 화면에서 안내하고, 이미 바닥이면 무시한다. + * - 상대 메시지는 자동 스크롤하지 않는다. 도착 시점에 화면에 보이지 않으면 [newMessageToast]를 + * 채워 안내하고, 그 메시지가 화면에 들어오면(사용자가 스크롤해서 직접 봤으면) 지운다. */ internal class ChatScrollState( private val listState: LazyListState, @@ -86,13 +86,24 @@ internal class ChatScrollState( // 기존 토스트가 있다면 건드리지 않고 그대로 둔다. val isToastCandidate = latest.id != lastAutoScrolledMessageId && latest.sender != MessageSender.User if (isToastCandidate) { - newMessageToast = if (listState.canScrollForward) latest else null + // 도착한 시점에 이미 화면에 보이는 메시지라면(뷰포트에 여유가 있어 스크롤 없이도 + // 보이는 경우) 안내할 필요가 없다. + newMessageToast = if (isMessageVisible(latest.id)) null else latest } } - fun clearToastIfAtBottom() { - if (!listState.canScrollForward) newMessageToast = null + /** + * 화면에 보이는 아이템 목록이 바뀔 때마다(=스크롤할 때마다) 호출된다. 지금 토스트가 + * 가리키는 메시지가 화면에 들어왔으면 지운다. 리스트 맨 끝까지 스크롤하지 않아도, 그 + * 메시지 하나만 보이면 충분하다. + */ + fun clearToastIfMessageVisible() { + val toastMessageId = newMessageToast?.id ?: return + if (isMessageVisible(toastMessageId)) newMessageToast = null } + + private fun isMessageVisible(messageId: Long): Boolean = + listState.layoutInfo.visibleItemsInfo.any { it.key == messageId } } /** 가장 최근에 보이던 마지막 아이템의 인덱스. 코멘트 생성 표시(로딩)까지 바닥에 포함시킨다. */ @@ -123,8 +134,14 @@ internal fun rememberChatScrollState( scrollState.handleNewMessage(state) } - LaunchedEffect(listState) { - snapshotFlow { listState.canScrollForward }.collect { scrollState.clearToastIfAtBottom() } + // scrollState 를 key 에 반드시 포함해야 한다 — 홈에서 새로 보낸 첫 메시지처럼 conversationId 가 + // 나중에(전송 성공 시점에) 채워지는 대화는 remember(conversationId) 로 새 ChatScrollState 인스턴스가 + // 만들어지는데, listState 는 그대로라 key 에 scrollState 가 없으면 이 effect 는 재시작되지 않고 + // 이미 못 쓰게 된 옛 인스턴스의 클로저를 계속 붙든 채 실행된다 — 실제로 화면에 쓰이는(새) + // 인스턴스의 newMessageToast 는 영영 지워지지 않는다. + LaunchedEffect(scrollState, listState) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.map { it.key } } + .collect { scrollState.clearToastIfMessageVisible() } } return scrollState From 7fc5fd3248287b83147dd7fef33892116ac08cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Tue, 18 Aug 2026 12:50:41 +0900 Subject: [PATCH 31/43] =?UTF-8?q?fix:=20=EB=88=84=EB=9D=BD=20=EB=A7=A4?= =?UTF-8?q?=EA=B0=9C=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 2ad0441c..d1fc7ed4 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -396,7 +396,6 @@ private fun ChatMessageList( MessageBubble( message = message, messages = state.messages, - isReplyTarget = state.replyTarget?.messageId == message.id, onCharacterMessageClick = actions.onCharacterMessageClick, ) } From 3b34a1e91be2215fabac06f39ce4190303a1a800 Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 00:14:15 +0900 Subject: [PATCH 32/43] =?UTF-8?q?feat:=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=EC=9D=B4=20=EC=B5=9C=ED=95=98=EB=8B=A8=EC=9D=BC=20=EB=95=8C=20?= =?UTF-8?q?=EC=83=81=EB=8C=80=20=EB=A9=94=EC=8B=9C=EC=A7=80=EA=B0=80=20?= =?UTF-8?q?=EC=98=A4=EB=A9=B4=20=EC=9E=90=EB=8F=99=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=B5=9C=ED=95=98=EB=8B=A8=EC=9C=BC=EB=A1=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/chat/util/ChatScrollState.kt | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt index 4b8e08c2..28cc406e 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt @@ -21,8 +21,10 @@ import kotlinx.coroutines.launch * - 최초 진입(과거 메시지 로딩 완료) 시 한 번 바닥으로 점프한다. * - 내가 메시지를 보내면(전송 완료 시점) 항상 바닥까지 스크롤한다. 사용자의 명시적 행동이라 * 상대 메시지 도착 시와 달리 자동 스크롤을 유지한다. - * - 상대 메시지는 자동 스크롤하지 않는다. 도착 시점에 화면에 보이지 않으면 [newMessageToast]를 - * 채워 안내하고, 그 메시지가 화면에 들어오면(사용자가 스크롤해서 직접 봤으면) 지운다. + * - 상대 메시지가 도착했을 때, 도착 직전에 이미 바닥을 보고 있었다면(=직전 마지막 메시지가 + * 화면에 보이고 있었다면) 그대로 따라가며 바닥까지 자동 스크롤한다. 이미 위로 스크롤해 + * 과거를 보고 있었다면 자동 스크롤하지 않고 [newMessageToast]를 채워 안내하며, 그 메시지가 + * 화면에 들어오면(사용자가 스크롤해서 직접 봤으면) 지운다. */ internal class ChatScrollState( private val listState: LazyListState, @@ -80,12 +82,21 @@ internal class ChatScrollState( if (!hasScrolledToInitialBottom) return val latest = state.messages.lastOrNull() if (latest == null || latest.id == lastSeenMessageId) return + + // 새 메시지가 추가돼도 그 이전 메시지들의 화면상 위치는 바뀌지 않는다 — 그래서 이 메시지가 + // 새 메시지를 반영한 레이아웃 이후에 확인해도, "직전 마지막 메시지가 보이고 있었는지"는 + // 곧 "도착 직전에 바닥을 보고 있었는지"와 같은 뜻이다. + val wasAtBottom = lastSeenMessageId?.let(::isMessageVisible) ?: true lastSeenMessageId = latest.id - // 내 전송으로 이미 자동 스크롤된 메시지거나 내가 보낸 메시지면 토스트 대상이 아니다 — - // 기존 토스트가 있다면 건드리지 않고 그대로 둔다. + // 내 전송으로 이미 자동 스크롤된 메시지거나 내가 보낸 메시지면 토스트/자동 스크롤 대상이 + // 아니다 — 기존 토스트가 있다면 건드리지 않고 그대로 둔다. val isToastCandidate = latest.id != lastAutoScrolledMessageId && latest.sender != MessageSender.User - if (isToastCandidate) { + if (isToastCandidate && wasAtBottom) { + // 이미 바닥을 보고 있었다면 새 메시지를 놓치지 않도록 그대로 따라 내려간다. + newMessageToast = null + scrollToBottom(state) + } else if (isToastCandidate) { // 도착한 시점에 이미 화면에 보이는 메시지라면(뷰포트에 여유가 있어 스크롤 없이도 // 보이는 경우) 안내할 필요가 없다. newMessageToast = if (isMessageVisible(latest.id)) null else latest From ecd1fa98152da19ad96e317d5739e5d8ff166bbd Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 00:49:24 +0900 Subject: [PATCH 33/43] =?UTF-8?q?feat:=20=EB=8B=A4=EC=9D=8C=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20pending=EB=90=98=EC=97=88=EC=9D=84=EB=95=8C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=A4=91=20=EC=83=81=ED=83=9C=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=ED=98=84=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EA=B0=90=EC=A0=95=20=EC=9D=B4=EB=A6=84,=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=ED=91=9C=EC=8B=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/chat/GamssChatBubble.kt | 8 +++-- .../android/feature/chat/ChatRoomScreen.kt | 4 ++- .../feature/chat/component/MessageBubble.kt | 36 +++++++++++++------ 3 files changed, 35 insertions(+), 13 deletions(-) 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 4e8f5db2..0bf88010 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 @@ -165,10 +165,14 @@ fun GamssReceivedChatBubble( /** * 상대가 입력중일때 말풍선. 아바타 + 이름 + 말풍선이 왼쪽에 정렬된다. + * + * @param avatar 다음에 도착할 답장의 발신자를 이미 알 때(예: pendingComments) 그 캐릭터 아바타를 + * 넘기면 빈 프로필 대신 표시한다. */ @Composable fun GamssLoadingMessageBubble( - senderStatus: String + senderStatus: String, + avatar: (@Composable () -> Unit)? = null, ) { Row( horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), @@ -178,7 +182,7 @@ fun GamssLoadingMessageBubble( horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), verticalAlignment = Alignment.Top, ) { - ChatAvatar(avatar = null) + ChatAvatar(avatar = avatar) Column(verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing075)) { Text( text = senderStatus, diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index d1fc7ed4..3291eb10 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -401,7 +401,9 @@ private fun ChatMessageList( } } if (state.isAwaitingComments) { - item { LoadingMessageBubble() } + val nextCharacter = (state.pendingComments.firstOrNull()?.sender as? MessageSender.Character) + ?.character + item { LoadingMessageBubble(character = nextCharacter) } } } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt index c5fd3812..2c4aa881 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt @@ -18,6 +18,7 @@ import com.gamss.android.core.designsystem.component.chat.GamssSentChatBubble import com.gamss.android.core.designsystem.modifier.noRippleCombinedClickable import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.feature.chat.R /** @@ -49,14 +50,7 @@ fun MessageBubble( is MessageSender.Character -> GamssReceivedChatBubble( sender = ChatSender( name = sender.character.displayName, - avatar = { - Image( - painter = painterResource(sender.character.avatarIconRes), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - }, + avatar = { CharacterAvatarImage(sender.character) }, ), message = message.content, time = message.createdTime, @@ -80,9 +74,31 @@ fun MessageBubble( } } +/** + * @param character 다음에 도착할 답장의 발신자. pendingComments의 첫 메시지 등으로 미리 알 때 + * 넘기면 빈 프로필 대신 그 캐릭터 아바타와 이름을 보여준다. + */ +@Composable +fun LoadingMessageBubble(character: EmotionCharacter? = null) { + val senderStatus = if (character != null) { + character.displayName + } else { + stringResource(R.string.chat_room_message_writing_label) + } + GamssLoadingMessageBubble( + senderStatus = senderStatus, + avatar = character?.let { { CharacterAvatarImage(it) } }, + ) +} + @Composable -fun LoadingMessageBubble() { - GamssLoadingMessageBubble(senderStatus = stringResource(R.string.chat_room_message_writing_label)) +private fun CharacterAvatarImage(character: EmotionCharacter) { + Image( + painter = painterResource(character.avatarIconRes), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) } @Composable From b6ce06c4a8a73ed5572a402667bb0c0644911796 Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 01:07:11 +0900 Subject: [PATCH 34/43] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=EC=A0=84=EC=86=A1=EC=A4=91=EC=9D=BC=EB=95=8C?= =?UTF-8?q?=EB=8A=94=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EC=A4=91=20=EC=83=81=ED=83=9C=20=EB=9C=A8=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gamss/android/feature/chat/ChatRoomState.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt index 09c45e04..727bd1d8 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt @@ -25,7 +25,10 @@ data class ChatRoomState( val canSend: Boolean get() = input.isNotBlank() && !isSending && !isLoading && endFlow == EndFlow.NotStarted - val isAwaitingComments: Boolean get() = isSending || pendingComments.isNotEmpty() + // isSending(내 전송이 서버 왕복 중)은 여기 포함하지 않는다 — "입력중" 버블은 상대가 답장을 + // 준비 중일 때만 보여야 하고, 내 메시지 전송 중이라는 것과는 다른 신호다. 전송 중 UI 피드백은 + // 입력창의 전송 버튼 비활성화(MessageInputBar의 isSending)로 이미 충분하다. + val isAwaitingComments: Boolean get() = pendingComments.isNotEmpty() /** 보낸 메시지가 있어야 카드를 만들 감정과 요약이 나온다. 종료 단계와 무관한 조건이다. */ val endPreconditionsMet: Boolean From e62ca532ec5dd3171340ea4879ab00c8e9ea3fa1 Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 02:07:58 +0900 Subject: [PATCH 35/43] =?UTF-8?q?fix:=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EB=B0=8F=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20-=20showScrollToBottomButton?= =?UTF-8?q?=EC=9D=84=20derivedStateOf=EB=A1=9C=20=EA=B0=90=EC=8B=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=ED=94=84=EB=A0=88=EC=9E=84?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=83=81=EC=9C=84=20=EC=9E=AC=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20-=20MessageBubble=EC=97=90=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=84=EC=B2=B4=20=EB=8C=80=EC=8B=A0=20replyQuot?= =?UTF-8?q?e=EB=A7=8C=20=EB=84=98=EA=B2=A8=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EC=8B=9C=20=ED=99=94=EB=A9=B4=EC=97=90?= =?UTF-8?q?=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20=EB=8B=A4=EB=A5=B8=20=EB=B2=84?= =?UTF-8?q?=EB=B8=94=EB=93=A4=EA=B9=8C=EC=A7=80=20=EC=A0=84=EB=B6=80=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1=EB=90=98=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gamss/android/feature/chat/ChatRoomScreen.kt | 12 +++++++++++- .../feature/chat/component/MessageBubble.kt | 14 ++++++-------- .../android/feature/chat/util/ChatScrollState.kt | 6 ++++-- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 3291eb10..4c080c07 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -74,6 +74,7 @@ import com.gamss.android.feature.chat.component.MessageBubble import com.gamss.android.feature.chat.component.MessageInputBar import com.gamss.android.feature.chat.component.NewMessageToast import com.gamss.android.feature.chat.component.SupportAgencyDialog +import com.gamss.android.feature.chat.component.toReplyQuote import com.gamss.android.feature.chat.util.AnimatedChatMessage import com.gamss.android.feature.chat.util.ChatMessageAnimation import com.gamss.android.feature.chat.util.ChatScrollState @@ -362,6 +363,12 @@ private fun ChatMessageList( showScrollToBottomButton: Boolean, modifier: Modifier = Modifier, ) { + // 리스트 전체(state.messages)를 각 아이템에 그대로 넘기면, 메시지가 하나 추가될 때마다 리스트 + // 참조가 바뀌어 이미 떠 있던 다른 모든 말풍선까지 재구성 대상이 된다. 답장 대상 조회를 여기서 + // 한 번에 끝내고 아이템별로는 결과값(replyQuote)만 넘기면, 안 바뀐 아이템은 재구성을 건너뛸 수 + // 있다. LazyListScope 빌더 본문은 @Composable이 아니라 여기(바깥)서 remember해야 한다. + val messagesById = remember(state.messages) { state.messages.associateBy(Message::id) } + Box(modifier = modifier) { LazyColumn( state = listState, @@ -388,6 +395,9 @@ private fun ChatMessageList( } } items(state.messages, key = { it.id }) { message -> + val replyQuote = message.repliesToMessageId + ?.let { targetId -> messagesById[targetId] } + ?.toReplyQuote() AnimatedChatMessage( messageId = message.id, shouldAnimate = animationState.shouldAnimate(message.id), @@ -395,7 +405,7 @@ private fun ChatMessageList( ) { MessageBubble( message = message, - messages = state.messages, + replyQuote = replyQuote, onCharacterMessageClick = actions.onCharacterMessageClick, ) } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt index 2c4aa881..d333b30d 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt @@ -22,20 +22,17 @@ import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.feature.chat.R /** - * @param messages 답장 대상 메시지의 내용을 찾기 위한 전체 목록. [Message.repliesToMessageId] 가 - * 가리키는 메시지가 이 목록에 없으면(예: 아직 로드되지 않은 과거 메시지) 답장 인용 없이 표시한다. + * @param replyQuote [message]가 답장이면 그 대상의 인용 정보. 호출부(리스트)가 한 번에 미리 + * 찾아서 넘긴다 — 이 버블은 전체 메시지 목록을 몰라도 되고, 목록이 커져도(다른 메시지가 + * 추가돼도) 이 값이 그대로면 재구성을 건너뛸 수 있다. */ @Composable fun MessageBubble( message: Message, - messages: List, + replyQuote: ChatReplyQuote?, onCharacterMessageClick: (Message) -> Unit, modifier: Modifier = Modifier, ) { - val replyQuote = message.repliesToMessageId - ?.let { targetId -> messages.find { it.id == targetId } } - ?.toReplyQuote() - Box( modifier = modifier.fillMaxWidth(), ) { @@ -101,8 +98,9 @@ private fun CharacterAvatarImage(character: EmotionCharacter) { ) } +/** [ChatRoomScreen]의 리스트가 아이템별 [MessageBubble.replyQuote]를 미리 계산할 때도 쓴다. */ @Composable -private fun Message.toReplyQuote(): ChatReplyQuote { +internal fun Message.toReplyQuote(): ChatReplyQuote { val label = when (val target = sender) { is MessageSender.Character -> stringResource( R.string.chat_room_reply_to_character, diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt index 28cc406e..473a83eb 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt @@ -3,6 +3,7 @@ package com.gamss.android.feature.chat.util import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -42,8 +43,9 @@ internal class ChatScrollState( // 또 새 메시지로 처리해 토스트를 잠깐 띄웠다 지우는 걸 막는다. private var lastAutoScrolledMessageId: Long? = null - val showScrollToBottomButton: Boolean - get() = listState.canScrollForward && newMessageToast == null + val showScrollToBottomButton: Boolean by derivedStateOf { + listState.canScrollForward && newMessageToast == null + } fun dismissToastAndScrollToBottom(state: ChatRoomState) { newMessageToast = null From 60a2dbcf05abf646444367aa5dbbd12946fae166 Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 02:46:35 +0900 Subject: [PATCH 36/43] =?UTF-8?q?fix:=20merge=20conflict=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gamss/android/core/common/util/Date.kt | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt b/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt index 170da9fc..b6107a58 100644 --- a/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt +++ b/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt @@ -1,10 +1,8 @@ package com.gamss.android.core.common.util -import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException import java.util.Locale private const val KOREAN_TIME_PATTERN = "a h:mm" @@ -24,18 +22,6 @@ private val KoreanWallClockFormatter: DateTimeFormatter = private val ConversationDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern(CONVERSATION_DATE_PATTERN, Locale.ROOT) -/** - * UTC ISO-8601 날짜 문자열을 한국 시간의 `오전/오후 h:mm` 형식으로 변환한다. - * - * [isoDateTime]이 ISO-8601 형식이 아니면 `null`을 반환한다. - */ -fun formatKoreanTime(isoDateTime: String): String? = - try { - KoreanTimeFormatter.format(Instant.parse(isoDateTime)) - } catch (_: DateTimeParseException) { - null - } - /** * 이미 벽시계로 바뀐 시각을 `오전/오후 h:mm` 형식으로 변환한다. * From a127cf010288d39c6102d6ec9fce837544629aa9 Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 02:54:56 +0900 Subject: [PATCH 37/43] =?UTF-8?q?fix:=20=EB=84=A4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EB=B0=94=20=EB=9D=84=EC=9B=8C=EC=84=9C=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=EC=9E=85=EB=A0=A5=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gamss/android/feature/chat/ChatRoomScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 4c080c07..ea90d414 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -10,13 +10,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -243,7 +243,7 @@ private fun ChatRoomContent( .fillMaxSize() .background(GamssTheme.colors.background) .padding(innerPadding) - .windowInsetsPadding(WindowInsets.ime.exclude(WindowInsets.navigationBars)), + .windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)), ) { ChatMessageList( state = state, From ca646b8252c9f357d2da5f26d2af7995f381320d Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 03:02:13 +0900 Subject: [PATCH 38/43] =?UTF-8?q?fix:=20=EB=A7=A8=EB=B0=91=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=8B=9C=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=EC=9D=98=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=97=86=EC=9D=B4=20=EC=A6=89=EC=8B=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B2=A8=EC=A7=80=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/feature/chat/component/ChattingListTopBar.kt | 1 - .../com/gamss/android/feature/chat/util/ChatScrollState.kt | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) 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 d3c62a41..318585b8 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 @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -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 diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt index 473a83eb..5f44952e 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt @@ -55,7 +55,7 @@ internal class ChatScrollState( fun scrollToBottom(state: ChatRoomState) { val index = state.lastItemIndex if (index < 0) return - coroutineScope.launch { listState.animateScrollToItem(index) } + coroutineScope.launch { listState.scrollToItem(index) } } suspend fun handleInitialLoad(state: ChatRoomState) { @@ -75,7 +75,7 @@ internal class ChatScrollState( newMessageToast = null val index = state.lastItemIndex if (index >= 0) { - listState.animateScrollToItem(index) + listState.scrollToItem(index) } } From f7f484fc29a5849f2afe5d0956d428140d7718a6 Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 03:02:48 +0900 Subject: [PATCH 39/43] =?UTF-8?q?fix:=20unused=20import=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/gamss/android/core/common/util/Date.kt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt b/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt index b6107a58..e9023796 100644 --- a/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt +++ b/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt @@ -1,19 +1,13 @@ package com.gamss.android.core.common.util import java.time.LocalDateTime -import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale private const val KOREAN_TIME_PATTERN = "a h:mm" -val KoreanTimeZone: ZoneId = ZoneId.of("Asia/Seoul") -private val KoreanTimeFormatter: DateTimeFormatter = DateTimeFormatter - .ofPattern(KOREAN_TIME_PATTERN, Locale.KOREAN) - .withZone(KoreanTimeZone) private const val CONVERSATION_DATE_PATTERN = "yy.MM.dd" - // 존을 덮지 않는다. 이미 벽시계로 변환된 값을 그대로 찍는 용도다. private val KoreanWallClockFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern(KOREAN_TIME_PATTERN, Locale.KOREAN) From 005f90b06c80711782d4cd9b7242c7ddac80e685 Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 03:13:26 +0900 Subject: [PATCH 40/43] =?UTF-8?q?refactor:=20ChatScrollState=20=EC=9D=91?= =?UTF-8?q?=EC=A7=91=EB=8F=84=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EA=B0=90=EC=A7=80=20=EB=B9=84=EC=9A=A9=20?= =?UTF-8?q?=EC=A0=88=EA=B0=90=20-=20wasSending=20=EC=B6=94=EC=A0=81?= =?UTF-8?q?=EC=9D=84=20rememberChatScrollState=20=EB=B0=96=EC=97=90?= =?UTF-8?q?=EC=84=9C=20ChatScrollState=20=EB=82=B4=EB=B6=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99,=20handleSendingChanged=EA=B0=80=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EA=B0=90=EC=A7=80=EC=99=80=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=ED=95=A8=EA=BB=98=20=EC=BA=A1=EC=8A=90=ED=99=94?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=A0=95=EB=A6=AC=20-=20clearToa?= =?UTF-8?q?stIfMessageVisible=EC=9D=84=20=EA=B5=AC=EB=8F=99=ED=95=98?= =?UTF-8?q?=EB=8A=94=20snapshotFlow=EA=B0=80=20=EC=8A=A4=ED=81=AC=EB=A1=A4?= =?UTF-8?q?=20=EC=A4=91=20=EB=A7=A4=20=ED=94=84=EB=A0=88=EC=9E=84=20visibl?= =?UTF-8?q?eItemsInfo=EB=A5=BC=20=EC=83=88=20List=EB=A1=9C=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=ED=95=98=EB=8D=98=20=EA=B2=83=EC=9D=84,=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8A=94=20=EB=B2=94=EC=9C=84=EC=9D=98=20=EC=B2=AB/?= =?UTF-8?q?=EB=A7=88=EC=A7=80=EB=A7=89=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=B4=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=ED=95=A0=EB=8B=B9=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/chat/util/ChatScrollState.kt | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt index 5f44952e..4966797f 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt @@ -43,6 +43,9 @@ internal class ChatScrollState( // 또 새 메시지로 처리해 토스트를 잠깐 띄웠다 지우는 걸 막는다. private var lastAutoScrolledMessageId: Long? = null + // handleSendingChanged 가 true→false 전환만 골라내는 데 쓰는 직전 값. + private var wasSending = false + val showScrollToBottomButton: Boolean by derivedStateOf { listState.canScrollForward && newMessageToast == null } @@ -67,9 +70,18 @@ internal class ChatScrollState( hasScrolledToInitialBottom = true } + /** [state]의 isSending 값이 바뀔 때마다 호출한다. true→false 전환일 때만 전송 완료 처리를 한다. */ + suspend fun handleSendingChanged(state: ChatRoomState) { + val isSending = state.isSending + if (wasSending && !isSending) { + handleSendConcluded(state) + } + wasSending = isSending + } + // 스크롤(애니메이션이라 시간이 걸림)보다 커서 갱신이 먼저 반영돼야, 그 사이 handleNewMessage // 가 같은 메시지를 놓치고 토스트를 잠깐 띄우는 경합을 막을 수 있다. - suspend fun handleSendConcluded(state: ChatRoomState) { + private suspend fun handleSendConcluded(state: ChatRoomState) { if (!hasScrolledToInitialBottom) return lastAutoScrolledMessageId = state.messages.lastOrNull()?.id newMessageToast = null @@ -135,26 +147,23 @@ internal fun rememberChatScrollState( scrollState.handleInitialLoad(state) } - var wasSending by remember(state.conversationId) { mutableStateOf(false) } LaunchedEffect(state.isSending) { - if (wasSending && !state.isSending) { - scrollState.handleSendConcluded(state) - } - wasSending = state.isSending + scrollState.handleSendingChanged(state) } LaunchedEffect(state.messages.lastOrNull()?.id, scrollState.hasScrolledToInitialBottom) { scrollState.handleNewMessage(state) } - // scrollState 를 key 에 반드시 포함해야 한다 — 홈에서 새로 보낸 첫 메시지처럼 conversationId 가 - // 나중에(전송 성공 시점에) 채워지는 대화는 remember(conversationId) 로 새 ChatScrollState 인스턴스가 - // 만들어지는데, listState 는 그대로라 key 에 scrollState 가 없으면 이 effect 는 재시작되지 않고 - // 이미 못 쓰게 된 옛 인스턴스의 클로저를 계속 붙든 채 실행된다 — 실제로 화면에 쓰이는(새) - // 인스턴스의 newMessageToast 는 영영 지워지지 않는다. + LaunchedEffect(scrollState, listState) { - snapshotFlow { listState.layoutInfo.visibleItemsInfo.map { it.key } } - .collect { scrollState.clearToastIfMessageVisible() } + // 보이는 아이템 "목록"이 아니라 보이는 범위의 양 끝 인덱스만 본다 — 스크롤 중이면 매 + // 프레임 바뀌는 값이라, visibleItemsInfo 전체를 새 List로 매핑하는 비용을 피한다. 목록은 + // 항상 연속된 범위라 양 끝이 그대로면 그 안의 가시성도 그대로다. + snapshotFlow { + val visible = listState.layoutInfo.visibleItemsInfo + visible.firstOrNull()?.index to visible.lastOrNull()?.index + }.collect { scrollState.clearToastIfMessageVisible() } } return scrollState From 372e6d133c9cfd477fa1f9a220c43661f64b24ce Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 03:22:23 +0900 Subject: [PATCH 41/43] =?UTF-8?q?fix:=20develop=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=EB=90=9C=20KoreanTimeZone=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EC=B6=94=EA=B0=80=EB=A1=9C=20CI=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feature:archive(PR #43)가 core.common.util.KoreanTimeZone을 참조하는데 실제 정의가 어디에도 없어 컴파일이 깨져 있었다. develop 자체의 CI는 캐시 때문에 거짓으로 통과된 것으로 보인다. - ZoneId.of("Asia/Seoul") 상수로 정의해 추가 - 겸사겸사 merge 과정에서 생긴 ChatScrollState.kt의 연속 빈 줄 detekt 이슈 정리 --- .../src/main/java/com/gamss/android/core/common/util/Date.kt | 4 ++++ .../com/gamss/android/feature/chat/util/ChatScrollState.kt | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt b/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt index e9023796..32264b04 100644 --- a/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt +++ b/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt @@ -1,9 +1,13 @@ package com.gamss.android.core.common.util import java.time.LocalDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale +/** 기기 시간대와 무관하게 한국 기준으로 "오늘"/"이번 달"을 고정해야 할 때 쓴다. */ +val KoreanTimeZone: ZoneId = ZoneId.of("Asia/Seoul") + private const val KOREAN_TIME_PATTERN = "a h:mm" private const val CONVERSATION_DATE_PATTERN = "yy.MM.dd" diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt index 4966797f..3213710a 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt @@ -155,7 +155,6 @@ internal fun rememberChatScrollState( scrollState.handleNewMessage(state) } - LaunchedEffect(scrollState, listState) { // 보이는 아이템 "목록"이 아니라 보이는 범위의 양 끝 인덱스만 본다 — 스크롤 중이면 매 // 프레임 바뀌는 값이라, visibleItemsInfo 전체를 새 List로 매핑하는 비용을 피한다. 목록은 From 0da09535d9da72b19f2a158fc47512241c08e427 Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 17:56:06 +0900 Subject: [PATCH 42/43] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=20=ED=8C=9D=EC=97=85=EC=97=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=A4=91=20=EB=A1=9C=EB=94=A9=20=EC=9D=B8=EB=94=94?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=ED=84=B0=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/GamssTokenUsageTooltip.kt | 26 +++++++++++++++++-- .../android/feature/chat/ChatRoomScreen.kt | 3 +++ .../android/feature/chat/ChatRoomState.kt | 1 + .../android/feature/chat/ChatRoomViewModel.kt | 14 +++++++--- 4 files changed, 38 insertions(+), 6 deletions(-) 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 index a268ba2d..15e5345a 100644 --- 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 @@ -10,7 +10,9 @@ 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.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -28,12 +30,15 @@ private val TooltipContentGap = 6.dp private val TooltipBorderWidth = 1.dp private val ProgressBarHeight = 9.dp private val TooltipShadowElevation = 40.dp +private val LoadingIndicatorSize = 24.dp +private val LoadingIndicatorStrokeWidth = 2.dp /** * 상단 내비게이션의 토큰 사용량 아이콘을 눌렀을 때 그 아래 뜨는 툴팁. * * [usagePercent]는 0~100 사이 값을 그대로 받는다 — 계산(도메인 값 유무 판단 등)은 호출부 책임이다. - * null이면 조회에 실패한 것으로 보고 [failMessage]와 재시도 액션([retryLabel]/[onRetryClick])을 대신 보여준다. + * [isLoading]이 true면 조회 중으로 보고 로딩 인디케이터를 대신 보여준다. + * 그 외에 null이면 조회에 실패한 것으로 보고 [failMessage]와 재시도 액션([retryLabel]/[onRetryClick])을 대신 보여준다. */ @Composable fun GamssTokenUsageTooltip( @@ -45,6 +50,7 @@ fun GamssTokenUsageTooltip( retryLabel: String, onRetryClick: () -> Unit, modifier: Modifier = Modifier, + isLoading: Boolean = false, ) { Column( modifier = modifier @@ -55,7 +61,23 @@ fun GamssTokenUsageTooltip( .padding(TooltipPadding), verticalArrangement = Arrangement.spacedBy(TooltipContentGap), ) { - if (usagePercent != null) { + if (isLoading) { + Text( + text = title, + style = GamssTheme.typography.subtitle4, + color = GamssTheme.colors.gray900, + ) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(LoadingIndicatorSize), + color = GamssTheme.colors.gray900, + strokeWidth = LoadingIndicatorStrokeWidth, + ) + } + } else if (usagePercent != null) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index ea90d414..a321298d 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -305,6 +305,7 @@ private fun ChatRoomTopBar( if (state.isTokenUsagePopupExpanded) { TokenUsagePopup( usagePercent = state.tokenUsagePercent, + isLoading = state.isTokenUsageLoading, onDismissRequest = actions.onTokenUsageToggle, onRetryClick = actions.onTokenUsageRetry, ) @@ -319,6 +320,7 @@ private fun ChatRoomTopBar( @Composable private fun TokenUsagePopup( usagePercent: Int?, + isLoading: Boolean, onDismissRequest: () -> Unit, onRetryClick: () -> Unit, ) { @@ -349,6 +351,7 @@ private fun TokenUsagePopup( failMessage = stringResource(R.string.chat_room_check_token_usage_fail), retryLabel = stringResource(R.string.chat_room_check_token_usage_button_label), onRetryClick = onRetryClick, + isLoading = isLoading, ) } } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt index 727bd1d8..09696bf0 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt @@ -20,6 +20,7 @@ data class ChatRoomState( val useChatEndFeature: Boolean = false, val tokenUsagePercent: Int? = null, val isTokenUsagePopupExpanded: Boolean = false, + val isTokenUsageLoading: Boolean = false, ) { /** 종료 흐름이 시작된 뒤로는 막는다. 종료 API 가 도는 중에 보내면 저장 여부가 갈린다. */ val canSend: Boolean diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt index 175414a9..93e38fb7 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt @@ -118,10 +118,14 @@ class ChatRoomViewModel @Inject constructor( } fun onTokenUsageToggle() = intent { - if (!state.isTokenUsagePopupExpanded) { - refreshTokenUsage() + var opened = false + var needsFetch = false + reduce { + opened = !state.isTokenUsagePopupExpanded + needsFetch = opened && state.tokenUsagePercent == null + state.copy(isTokenUsagePopupExpanded = opened) } - reduce { state.copy(isTokenUsagePopupExpanded = !state.isTokenUsagePopupExpanded) } + if (needsFetch) refreshTokenUsage() } fun onTokenUsageRetry() = intent { @@ -131,10 +135,12 @@ class ChatRoomViewModel @Inject constructor( /** * 사용자가 직접 요청한 조회(팝업 열기·재시도)는 실패를 그대로 반영해야 재시도 UI가 뜬다. * [onSend]의 백그라운드 갱신처럼 조용히 이전 값을 유지하는 fallback을 여기선 쓰지 않는다. + * 팝업이 조회 중임을 알 수 있도록 요청 전후로 isTokenUsageLoading을 함께 반영한다. */ private suspend fun ChatRoomSyntax.refreshTokenUsage() { + reduce { state.copy(isTokenUsageLoading = true) } val percent = fetchTokenUsagePercent() - reduce { state.copy(tokenUsagePercent = percent) } + reduce { state.copy(tokenUsagePercent = percent, isTokenUsageLoading = false) } } /** From c923e92f292c826ae35217360d0143caa71f5269 Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 19:31:08 +0900 Subject: [PATCH 43/43] =?UTF-8?q?fix:=20=EC=A0=84=EC=86=A1=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EC=B1=84=ED=8C=85=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=EC=9D=B4=20=EB=B0=94=EB=8B=A5=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=90=ED=94=84=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/chat/util/ChatScrollState.kt | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt index 3213710a..81e05b90 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt @@ -46,6 +46,12 @@ internal class ChatScrollState( // handleSendingChanged 가 true→false 전환만 골라내는 데 쓰는 직전 값. private var wasSending = false + // 전송이 시작된 시점(isSending false→true)의 마지막 메시지 id. 전송이 끝났을 때(true→false) + // 이 값과 비교해 성공 여부를 판단한다 — 실패(네트워크 오류, 위험 감지 차단 등)하면 메시지가 추가되지 + // 않아 id가 그대로다. 이 비교 없이 isSending 전환만 보면, 위로 스크롤해 과거를 보는 중에 전송이 + // 실패해도 newMessageToast가 지워지고 바닥으로 점프해버린다. + private var lastMessageIdBeforeSend: Long? = null + val showScrollToBottomButton: Boolean by derivedStateOf { listState.canScrollForward && newMessageToast == null } @@ -73,16 +79,19 @@ internal class ChatScrollState( /** [state]의 isSending 값이 바뀔 때마다 호출한다. true→false 전환일 때만 전송 완료 처리를 한다. */ suspend fun handleSendingChanged(state: ChatRoomState) { val isSending = state.isSending - if (wasSending && !isSending) { - handleSendConcluded(state) + if (!wasSending && isSending) { + lastMessageIdBeforeSend = state.messages.lastOrNull()?.id + } else if (wasSending && !isSending) { + val succeeded = state.messages.lastOrNull()?.id != lastMessageIdBeforeSend + handleSendConcluded(state, succeeded) } wasSending = isSending } // 스크롤(애니메이션이라 시간이 걸림)보다 커서 갱신이 먼저 반영돼야, 그 사이 handleNewMessage // 가 같은 메시지를 놓치고 토스트를 잠깐 띄우는 경합을 막을 수 있다. - private suspend fun handleSendConcluded(state: ChatRoomState) { - if (!hasScrolledToInitialBottom) return + private suspend fun handleSendConcluded(state: ChatRoomState, succeeded: Boolean) { + if (!succeeded || !hasScrolledToInitialBottom) return lastAutoScrolledMessageId = state.messages.lastOrNull()?.id newMessageToast = null val index = state.lastItemIndex