From e4c2aafcce3ff6794bd4bd5601cfd1946609796b Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 23:28:01 +0900 Subject: [PATCH 01/24] =?UTF-8?q?fix:=20TopNavigation=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=84=A4=EC=A0=95=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=EC=9C=BC=EB=A1=9C=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/app/main/MainScreen.kt | 61 ++++++++++++++++--- .../topnavigation/GamssTopNavigation.kt | 7 ++- .../chat/component/ChattingListTopBar.kt | 6 +- 3 files changed, 61 insertions(+), 13 deletions(-) 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 250388a2..926884bb 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 @@ -182,7 +182,7 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { entry { ChattingListScreen( onChatClick = { navigator.navigate(ChatRoomKey(it)) }, - onMenuClick = { navigator.navigate(SettingKey) }, + onSettingClick = { navigator.navigate(SettingKey) }, ) } entry(metadata = detailSlideTransition) { key -> @@ -206,20 +206,67 @@ private val tabFadeThroughSpec: AnimatedContentTransitionScope>.() * 탭 내부의 상세 화면(Setting/AccountInfo/NicknameChange/ChatRoom/WebView) push·pop 전용 * 트랜지션. 계층 이동이라는 방향감을 주기 위해 좌우 슬라이드(shared axis X)를 쓴다. * + * slideIntoContainer/slideOutOfContainer는 initialOffset/targetOffset을 안 주면 화면 폭 전체를 + * 미는 거리로 슬라이드한다 — Material의 shared axis X는 폭 전체가 아니라 일부만 이동해야 가벼운 + * 느낌이 나는데, 전체 폭을 밀면 같은 duration이어도 이동 거리가 커서 둔탁하고 느리게 느껴진다. + * DETAIL_TRANSITION_SLIDE_FRACTION으로 이동 거리를 줄이고, duration도 같이 낮춘다. + * * 트리거 경로마다 참조하는 spec이 달라서, 셋 다 지정하지 않으면 모션이 갈립니다. */ private val detailSlideTransition: Map = NavDisplay.transitionSpec { - (slideIntoContainer(SlideDirection.Start, tween(300)) + fadeIn(tween(300))) togetherWith - (slideOutOfContainer(SlideDirection.Start, tween(300)) + fadeOut(tween(150))) + ( + slideIntoContainer( + SlideDirection.Start, + tween(DETAIL_TRANSITION_DURATION_MILLIS), + initialOffset = { fullWidth -> fullWidth / DETAIL_TRANSITION_SLIDE_FRACTION }, + ) + fadeIn(tween(DETAIL_TRANSITION_DURATION_MILLIS)) + ) togetherWith + ( + slideOutOfContainer( + SlideDirection.Start, + tween(DETAIL_TRANSITION_DURATION_MILLIS), + targetOffset = { fullWidth -> fullWidth / DETAIL_TRANSITION_SLIDE_FRACTION }, + ) + fadeOut(tween(DETAIL_TRANSITION_EXIT_FADE_DURATION_MILLIS)) + ) } + NavDisplay.popTransitionSpec { - (slideIntoContainer(SlideDirection.End, tween(300)) + fadeIn(tween(300))) togetherWith - (slideOutOfContainer(SlideDirection.End, tween(300)) + fadeOut(tween(150))) + ( + slideIntoContainer( + SlideDirection.End, + tween(DETAIL_TRANSITION_DURATION_MILLIS), + initialOffset = { fullWidth -> fullWidth / DETAIL_TRANSITION_SLIDE_FRACTION }, + ) + fadeIn(tween(DETAIL_TRANSITION_DURATION_MILLIS)) + ) togetherWith + ( + slideOutOfContainer( + SlideDirection.End, + tween(DETAIL_TRANSITION_DURATION_MILLIS), + targetOffset = { fullWidth -> fullWidth / DETAIL_TRANSITION_SLIDE_FRACTION }, + ) + fadeOut(tween(DETAIL_TRANSITION_EXIT_FADE_DURATION_MILLIS)) + ) } + NavDisplay.predictivePopTransitionSpec { swipeEdge -> val towards = if (swipeEdge == NavigationEvent.EDGE_RIGHT) SlideDirection.Start else SlideDirection.End - (slideIntoContainer(towards, tween(300)) + fadeIn(tween(300))) togetherWith - (slideOutOfContainer(towards, tween(300)) + fadeOut(tween(150))) + ( + slideIntoContainer( + towards, + tween(DETAIL_TRANSITION_DURATION_MILLIS), + initialOffset = { fullWidth -> fullWidth / DETAIL_TRANSITION_SLIDE_FRACTION }, + ) + fadeIn(tween(DETAIL_TRANSITION_DURATION_MILLIS)) + ) togetherWith + ( + slideOutOfContainer( + towards, + tween(DETAIL_TRANSITION_DURATION_MILLIS), + targetOffset = { fullWidth -> fullWidth / DETAIL_TRANSITION_SLIDE_FRACTION }, + ) + fadeOut(tween(DETAIL_TRANSITION_EXIT_FADE_DURATION_MILLIS)) + ) } +private const val DETAIL_TRANSITION_DURATION_MILLIS = 240 +private const val DETAIL_TRANSITION_EXIT_FADE_DURATION_MILLIS = 120 + +// 화면 폭의 1/4만 슬라이드한다(= shared axis X). 폭 전체를 밀지 않아야 가볍게 느껴진다. +private const val DETAIL_TRANSITION_SLIDE_FRACTION = 4 + /** * 홈 루트에서는 NavDisplay의 previousEntries가 비어 자체 back handler가 꺼지므로 이 handler가 받습니다. */ 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 e20e99c1..f6e96e12 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 @@ -40,7 +40,8 @@ enum class GamssTopNavigationIcon(@DrawableRes internal val drawableRes: Int) { LeftChevron(R.drawable.ic_left_chevron), CreateCard(R.drawable.ic_create_card), - Menu(R.drawable.ic_menu), + + Setting(R.drawable.ic_setting), CheckToken(R.drawable.ic_token_check), @@ -300,7 +301,7 @@ private fun TopNavigationPreviewContent() { GamssTopNavigation( content = GamssTopNavigationContent.Logo, rightActions = listOf( - GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Menu, onClick = {}), + GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Setting, onClick = {}), ), ) GamssTopNavigation( @@ -309,7 +310,7 @@ private fun TopNavigationPreviewContent() { showLeftIcon = true, rightActions = listOf( GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.CreateCard, onClick = {}), - GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Menu, onClick = {}), + GamssTopNavigationIconAction(icon = GamssTopNavigationIcon.Setting, onClick = {}), ), ) GamssTopNavigation( 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 318585b8..dd647b8e 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 @@ -22,7 +22,7 @@ internal fun ChattingListTopBar( isSelectionMode: Boolean, onSelectionCancel: () -> Unit, onSearchClick: () -> Unit, - onMenuClick: () -> Unit, + onSettingClick: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier) { @@ -46,8 +46,8 @@ internal fun ChattingListTopBar( null }, GamssTopNavigationIconAction( - icon = GamssTopNavigationIcon.Menu, - onClick = onMenuClick, + icon = GamssTopNavigationIcon.Setting, + onClick = onSettingClick, contentDescription = stringResource(R.string.chatting_list_menu_content_description), ), ), From 07dc67fcd09bb225349629b6f1067c7e96fc0ef4 Mon Sep 17 00:00:00 2001 From: seunghee Date: Wed, 19 Aug 2026 23:55:36 +0900 Subject: [PATCH 02/24] =?UTF-8?q?fix:=20TopNavigation=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EC=84=A4=EC=A0=95=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gamss/android/feature/chat/ChattingListScreen.kt | 4 ++-- feature/chat/src/main/res/values/strings.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListScreen.kt index e6f3a120..82f1bae7 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChattingListScreen.kt @@ -48,7 +48,7 @@ import org.orbitmvi.orbit.compose.collectSideEffect @Composable fun ChattingListScreen( onChatClick: (conversationId: Long) -> Unit, - onMenuClick: () -> Unit, + onSettingClick: () -> Unit, modifier: Modifier = Modifier, viewModel: ChattingListViewModel = hiltViewModel(), ) { @@ -81,7 +81,7 @@ fun ChattingListScreen( isSelectionMode = state.isSelectionMode, onSelectionCancel = viewModel::onSelectionCancel, onSearchClick = viewModel::onSearchModeEnter, - onMenuClick = onMenuClick, + onSettingClick = onSettingClick, ) ChattingSearchContent( diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml index c8bde7a3..55627952 100644 --- a/feature/chat/src/main/res/values/strings.xml +++ b/feature/chat/src/main/res/values/strings.xml @@ -30,7 +30,7 @@ 대화를 이어갈 수 없으며, 카드를 생성할 수 없습니다.\n채팅은 영구적으로 삭제됩니다. 삭제하기 뒤로가기 - 메뉴 + 설정 채팅방 검색 검색어 입력 검색어 지우기 From cb5041b6ffcdb0b7e85809b6ad9afbb1268c00f9 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 01:08:55 +0900 Subject: [PATCH 03/24] =?UTF-8?q?feat:=20=EC=8A=A4=EB=82=B5=EB=B0=94=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../designsystem/snackbar/GamssSnackBar.kt | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt new file mode 100644 index 00000000..17ef104c --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt @@ -0,0 +1,107 @@ +package com.gamss.android.core.designsystem.snackbar + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.component.GamssIcons +import com.gamss.android.core.designsystem.theme.GamssTheme + +private val SnackBarIconSize = 20.dp + +/** + * 화면 하단에 뜨는 스낵바. 아이콘은 화면마다 다른 것을 붙일 수 있어 nullable 로 둔다. + * 다른 디자인시스템 컴포넌트들과 마찬가지로 drawable을 painterResource()로 그려 넘기는 + * [Painter]를 받는다 — ImageVector는 material-icons 의존성이 따로 필요해 이 모듈엔 없다. + * 시안 문구는 한 줄 사용을 권장하지만, 길어지면 그대로 줄바꿈된다. + */ +@Composable +fun GamssSnackBar( + message: String, + modifier: Modifier = Modifier, + icon: Painter? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(GamssTheme.radius.radius100)) + .background(color = GamssTheme.colors.gray900) + .padding( + horizontal = GamssTheme.spacing.spacing300, + vertical = GamssTheme.spacing.spacing200, + ), + horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + painter = icon, + contentDescription = null, + tint = GamssTheme.colors.gray025, + modifier = Modifier.size(SnackBarIconSize), + ) + } + Text( + text = message, + style = GamssTheme.typography.body4Medium, + color = GamssTheme.colors.gray025, + ) + } +} + +@Preview(name = "Light", showBackground = true, widthDp = 402) +@Suppress("UnusedPrivateMember") +@Composable +private fun GamssSnackBarLightPreview() { + GamssTheme(darkTheme = false) { + GamssSnackBarPreviewContent() + } +} + +@Preview( + name = "Dark", + showBackground = true, + backgroundColor = 0xFF000000, + uiMode = Configuration.UI_MODE_NIGHT_YES, + widthDp = 402, +) +@Suppress("UnusedPrivateMember") +@Composable +private fun GamssSnackBarDarkPreview() { + GamssTheme(darkTheme = true) { + GamssSnackBarPreviewContent() + } +} + +@Composable +private fun GamssSnackBarPreviewContent() { + Column( + modifier = Modifier.padding(GamssTheme.spacing.spacing300), + verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing200), + ) { + // 아이콘이 있는 스낵바. + GamssSnackBar( + message = "이것은 아이콘이 있는 스낵바입니다.\n스낵바는 한 줄로 사용하기를 권장합니다.", + icon = painterResource(GamssIcons.CheckCircleOn), + ) + // 아이콘이 없는 스낵바. + GamssSnackBar( + message = "이것은 아이콘이 없는 스낵바입니다.", + ) + } +} From 869bb82447f18a7c9d96eb895ea677523a00a48f Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 01:10:41 +0900 Subject: [PATCH 04/24] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=EC=95=A0=EB=8B=88=EB=A9=94=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EC=95=88=EB=81=8A=EA=B8=B0=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=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 | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 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 99b6915f..228556f1 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 @@ -1,9 +1,9 @@ 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.Spring -import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInVertically import androidx.compose.foundation.lazy.LazyListState @@ -62,20 +62,18 @@ internal fun AnimatedChatMessage( } AnimatedVisibility( visibleState = visibilityState, - // slide 와 fade 를 동일한 스프링 스펙으로 묶어, 두 효과가 서로 다른 타이밍으로 끝나며 - // 끊겨 보이던 문제를 없애고 하나의 유려한 움직임으로 이어지게 한다. enter = slideInVertically( - animationSpec = spring( - dampingRatio = MESSAGE_ENTER_DAMPING_RATIO, - stiffness = MESSAGE_ENTER_STIFFNESS, + animationSpec = tween( + durationMillis = MESSAGE_ENTER_DURATION_MILLIS, + easing = FastOutSlowInEasing, ), initialOffsetY = { messageHeight -> listState.offsetFromInput(messageId, messageHeight) }, ) + fadeIn( - animationSpec = spring( - dampingRatio = MESSAGE_ENTER_DAMPING_RATIO, - stiffness = MESSAGE_ENTER_STIFFNESS, + animationSpec = tween( + durationMillis = MESSAGE_ENTER_DURATION_MILLIS, + easing = FastOutSlowInEasing, ), ), ) { @@ -89,5 +87,4 @@ private fun LazyListState.offsetFromInput(messageId: Long, messageHeight: Int): return (layoutInfo.viewportEndOffset - messageItem.offset).coerceAtLeast(messageHeight) } -private const val MESSAGE_ENTER_DAMPING_RATIO = Spring.DampingRatioLowBouncy -private const val MESSAGE_ENTER_STIFFNESS = Spring.StiffnessMediumLow +private const val MESSAGE_ENTER_DURATION_MILLIS = 260 From b034f713d2796835f3dca0571f590245e099ba84 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 05:20:24 +0900 Subject: [PATCH 05/24] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=2010%=20=EB=AF=B8=EB=A7=8C=C2=B7=EC=86=8C?= =?UTF-8?q?=EC=A7=84=20=EC=95=8C=EB=A6=BC=EC=9D=84=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?1=ED=9A=8C=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/designsystem/component/GamssIcons.kt | 3 + .../designsystem/snackbar/GamssSnackBar.kt | 23 ++++-- .../src/main/res/drawable/ic_alert.xml | 9 +++ .../TokenUsageRefreshNotifierImpl.kt | 68 +++++++++++++++- .../repository/TokenUsageRefreshNotifier.kt | 23 ++++++ .../android/feature/chat/ChatRoomScreen.kt | 77 ++++++++++++++++++- .../feature/chat/ChatRoomSideEffect.kt | 3 + .../android/feature/chat/ChatRoomViewModel.kt | 16 +++- feature/chat/src/main/res/values/strings.xml | 2 + .../android/feature/chat/ChatRoomTestFakes.kt | 10 ++- 10 files changed, 220 insertions(+), 14 deletions(-) create mode 100644 core/designsystem/src/main/res/drawable/ic_alert.xml 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 36a14196..c8af1ecc 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 @@ -46,4 +46,7 @@ object GamssIcons { @DrawableRes val ScrollDown: Int = R.drawable.ic_scroll_down + + @DrawableRes + val Alert: Int = R.drawable.ic_alert } diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt index 17ef104c..90155fcf 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt @@ -3,6 +3,7 @@ package com.gamss.android.core.designsystem.snackbar import android.content.res.Configuration import androidx.compose.foundation.background 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 @@ -34,7 +35,7 @@ private val SnackBarIconSize = 20.dp fun GamssSnackBar( message: String, modifier: Modifier = Modifier, - icon: Painter? = null, + snackBarIcon: (@Composable () -> Unit)? = null, ) { Row( modifier = modifier @@ -48,13 +49,13 @@ fun GamssSnackBar( horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), verticalAlignment = Alignment.CenterVertically, ) { - if (icon != null) { - Icon( - painter = icon, - contentDescription = null, - tint = GamssTheme.colors.gray025, + if (snackBarIcon != null) { + Box( modifier = Modifier.size(SnackBarIconSize), - ) + contentAlignment = Alignment.Center, + ) { + snackBarIcon() + } } Text( text = message, @@ -97,7 +98,13 @@ private fun GamssSnackBarPreviewContent() { // 아이콘이 있는 스낵바. GamssSnackBar( message = "이것은 아이콘이 있는 스낵바입니다.\n스낵바는 한 줄로 사용하기를 권장합니다.", - icon = painterResource(GamssIcons.CheckCircleOn), + snackBarIcon = { + Icon( + painter = painterResource(GamssIcons.Alert), + contentDescription = null, + tint = GamssTheme.colors.yellow, + ) + }, ) // 아이콘이 없는 스낵바. GamssSnackBar( diff --git a/core/designsystem/src/main/res/drawable/ic_alert.xml b/core/designsystem/src/main/res/drawable/ic_alert.xml new file mode 100644 index 00000000..f82d1bab --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_alert.xml @@ -0,0 +1,9 @@ + + + diff --git a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt index aec852c6..77afdf79 100644 --- a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt @@ -1,15 +1,28 @@ package com.gamss.android.data.repository +import com.gamss.android.core.common.AppResult +import com.gamss.android.core.common.util.calculateTokenUsagePercent +import com.gamss.android.data.di.ApplicationScope +import com.gamss.android.domain.repository.TokenUsageAlert import com.gamss.android.domain.repository.TokenUsageRefreshNotifier +import com.gamss.android.domain.user.UserRepository +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class TokenUsageRefreshNotifierImpl @Inject constructor() : TokenUsageRefreshNotifier { +internal class TokenUsageRefreshNotifierImpl @Inject constructor( + private val userRepository: UserRepository, + @ApplicationScope private val applicationScope: CoroutineScope, +) : TokenUsageRefreshNotifier { /** 신호는 마지막 한 번만 의미가 있다. 버퍼가 차면 오래된 쪽을 버려 tryEmit 이 실패하지 않게 한다. */ private val events = MutableSharedFlow( @@ -19,7 +32,60 @@ internal class TokenUsageRefreshNotifierImpl @Inject constructor() : TokenUsageR override val refreshEvents: Flow = events.asSharedFlow() + private val _usagePercent = MutableStateFlow(null) + override val usagePercent: StateFlow = _usagePercent.asStateFlow() + + /** 구독이 늦게 시작돼도 막 emit된 알림 하나는 놓치지 않도록 버퍼 1개만 둔다. */ + private val _alerts = MutableSharedFlow(extraBufferCapacity = 1) + override val alerts: Flow = _alerts.asSharedFlow() + + // 화면(ViewModel)을 나갔다 들어와도 안 사라지도록, "이미 알렸는지"는 프로세스가 사는 동안 + // 여기 싱글턴에만 기억한다. + private var hasAlertedLow = false + private var hasAlertedExhausted = false + + init { + applicationScope.launch { + refreshEvents.collect { refresh() } + } + } + override fun requestRefresh() { events.tryEmit(Unit) } + + private suspend fun refresh() { + val result = userRepository.getDailyTokenUsage() + if (result !is AppResult.Success) return + val usage = result.data + val percent = calculateTokenUsagePercent(usage.usedTokens, usage.dailyLimit) + + // 이전보다 사용량이 줄었거나(서버가 자정/오전 5시에 리셋) 더 이상 소진 상태가 아니라면 + // 새 하루로 보고 알림 상태를 초기화한다 — 클라이언트가 리셋 시각을 직접 계산하지 않는다. + val previousPercent = _usagePercent.value + val isNewDay = (previousPercent != null && percent != null && percent < previousPercent) || + (hasAlertedExhausted && !usage.exceeded) + if (isNewDay) { + hasAlertedLow = false + hasAlertedExhausted = false + } + _usagePercent.value = percent + + when { + // exceeded는 서버가 내려주는 확정값이라 반올림 오차가 있는 percent >= 100 보다 정확하다. + usage.exceeded && !hasAlertedExhausted -> { + hasAlertedExhausted = true + hasAlertedLow = true + _alerts.tryEmit(TokenUsageAlert.EXHAUSTED) + } + !usage.exceeded && percent != null && percent >= LOW_USAGE_THRESHOLD_PERCENT && !hasAlertedLow -> { + hasAlertedLow = true + _alerts.tryEmit(TokenUsageAlert.LOW) + } + } + } + + private companion object { + const val LOW_USAGE_THRESHOLD_PERCENT = 90 + } } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt b/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt index f8a40797..87e39d2a 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt @@ -1,9 +1,32 @@ package com.gamss.android.domain.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +/** + * 토큰 사용량 새로고침 신호 버스 + 전역 사용량 캐시. + * + * [requestRefresh]로 재조회를 요청하면, 구현체가 내부에서 조회해 [usagePercent]를 갱신하고 + * 10% 미만 남음/전부 소진 임계값을 넘었을 때 [alerts]로 한 번만 흘려보낸다. 여러 채팅방을 + * 오가도(대화방을 나갔다 들어와도) 알림이 중복으로 뜨지 않도록, "이미 알렸는지"는 화면(ViewModel) + * 로컬이 아니라 이 싱글턴 구현체가 기억한다. + */ interface TokenUsageRefreshNotifier { val refreshEvents: Flow + /** 가장 최근에 조회된 사용률(0~100). 아직 한 번도 조회되지 않았으면 null. */ + val usagePercent: StateFlow + + /** 10% 미만 남음/전부 소진을 감지했을 때 한 번만 흘려보내는 알림. */ + val alerts: Flow + fun requestRefresh() } + +enum class TokenUsageAlert { + /** 오늘 사용 가능한 토큰이 10% 미만 남음. */ + LOW, + + /** 오늘 사용 가능한 토큰을 전부 사용함. */ + EXHAUSTED, +} 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 a321298d..b3961dda 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 @@ -29,6 +29,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarVisuals import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -41,6 +45,7 @@ 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.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource @@ -57,6 +62,7 @@ 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.snackbar.GamssSnackBar import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationHeight @@ -68,6 +74,7 @@ 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.domain.repository.TokenUsageAlert import com.gamss.android.feature.chat.component.EndConversationDialog import com.gamss.android.feature.chat.component.LoadingMessageBubble import com.gamss.android.feature.chat.component.MessageBubble @@ -102,10 +109,27 @@ fun ChatRoomScreen( LaunchedEffect(conversationId) { viewModel.start(conversationId) } + // GamssSnackBar는 Scaffold(ChatRoomContent) 안에 있지만, 사이드이펙트 수집은 여기서 하는 게 + // Toast와 한곳에 모여 흐름을 따라가기 쉽다. 그래서 host만 여기서 만들어 아래로 넘긴다. + val snackbarHostState = remember { SnackbarHostState() } + val tokenUsageLowMessage = stringResource(R.string.chat_room_token_usage_low_snackbar) + val tokenUsageExhaustedMessage = stringResource(R.string.chat_room_token_usage_exhausted_snackbar) + viewModel.collectSideEffect { sideEffect -> when (sideEffect) { is ChatRoomSideEffect.ShowToast -> Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() + + is ChatRoomSideEffect.ShowTokenUsageAlert -> { + val message = when (sideEffect.alert) { + TokenUsageAlert.LOW -> tokenUsageLowMessage + TokenUsageAlert.EXHAUSTED -> tokenUsageExhaustedMessage + } + + snackbarHostState.showSnackbar( + TokenUsageSnackbarVisuals(message = message, alert = sideEffect.alert), + ) + } } } @@ -124,6 +148,7 @@ fun ChatRoomScreen( ChatRoomContent( state = state, actions = actions, + snackbarHostState = snackbarHostState, modifier = modifier, onBackClick = onBackClick, ) @@ -195,10 +220,45 @@ private data class ChatRoomActions( val onTokenUsageRetry: () -> Unit, ) +/** + * [alert] 종류에 따라 GamssSnackBar 아이콘을 달리 그리기 위한 커스텀 [SnackbarVisuals]. + * 기본 `showSnackbar(message)` 오버로드는 문자열만 실어 나를 수 있어, 렌더 시점에 아이콘을 + * 고를 근거가 메시지 문구밖에 안 남는다 — 문구가 바뀌면 아이콘도 같이 깨지므로 이 타입으로 감싼다. + */ +private data class TokenUsageSnackbarVisuals( + override val message: String, + val alert: TokenUsageAlert, +) : SnackbarVisuals { + override val actionLabel: String? = null + override val withDismissAction: Boolean = false + override val duration: SnackbarDuration = SnackbarDuration.Short +} + +/** + * 알림 종류별 GamssSnackBar 아이콘. LOW는 두 가지 색(원+체크)이 들어 있어 tint 하지 않고, + * EXHAUSTED(전부 소진)는 경고 색으로 강조해 심각도 차이를 보여준다. + */ +@Composable +private fun TokenUsageAlertIcon(alert: TokenUsageAlert?) { + when (alert) { + TokenUsageAlert.EXHAUSTED -> Icon( + painter = painterResource(GamssIcons.Alert), + contentDescription = null, + tint = GamssTheme.colors.red, + ) + TokenUsageAlert.LOW, null -> Icon( + painter = painterResource(GamssIcons.Alert), + contentDescription = null, + tint = GamssTheme.colors.yellow, + ) + } +} + @Composable private fun ChatRoomContent( state: ChatRoomState, actions: ChatRoomActions, + snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, onBackClick: () -> Unit, ) { @@ -235,6 +295,16 @@ private fun ChatRoomContent( Scaffold( modifier = modifier, topBar = { ChatRoomTopBar(state = state, actions = actions, onBackClick = onBackClick) }, + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + val alert = (data.visuals as? TokenUsageSnackbarVisuals)?.alert + GamssSnackBar( + message = data.visuals.message, + snackBarIcon = { TokenUsageAlertIcon(alert) }, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp), + ) + } + }, // 상위 Scaffold 가 인셋을 이미 적용해, imePadding 을 그대로 쓰면 이중 적용된다. contentWindowInsets = WindowInsets(0), ) { innerPadding -> @@ -569,5 +639,10 @@ private fun ChatRoomPreviewContent() { onTokenUsageToggle = {}, onTokenUsageRetry = {} ) - ChatRoomContent(state = state, actions = actions, onBackClick = {}) + ChatRoomContent( + state = state, + actions = actions, + snackbarHostState = remember { SnackbarHostState() }, + onBackClick = {}, + ) } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomSideEffect.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomSideEffect.kt index daa573b2..662b3738 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomSideEffect.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomSideEffect.kt @@ -1,5 +1,8 @@ package com.gamss.android.feature.chat +import com.gamss.android.domain.repository.TokenUsageAlert + sealed interface ChatRoomSideEffect { data class ShowToast(val message: String) : ChatRoomSideEffect + data class ShowTokenUsageAlert(val alert: TokenUsageAlert) : ChatRoomSideEffect } 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 93e38fb7..7a12292d 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 @@ -52,11 +52,22 @@ class ChatRoomViewModel @Inject constructor( started = true loadChatEndFeatureFlag() loadMessages(conversationId) + observeTokenUsageAlerts() // 결과를 기다리지 않는다 — 채팅방에 들어온 시점부터 온디바이스 모델 다운로드를 미리 // 걸어둬 첫 메시지/카드 생성 시점엔 이미 받아져 있을 확률을 높이는 순수 최적화용 호출이다. viewModelScope.launch { session.prefetchOnDeviceModels() } } + /** + * 10% 미만 남음/전부 소진 알림은 [tokenUsageRefreshNotifier]가 전역으로 한 번만 흘려보낸다 — + * 여러 채팅방을 오가도(대화방을 나갔다 들어와도) 중복으로 뜨지 않는다. + */ + private fun observeTokenUsageAlerts() = intent { + tokenUsageRefreshNotifier.alerts.collect { alert -> + postSideEffect(ChatRoomSideEffect.ShowTokenUsageAlert(alert)) + } + } + private fun loadChatEndFeatureFlag() = intent { val useChatEndFeature = getRemoteConfigFlag(RemoteConfigKey.UseChatEndFeature) reduce { state.copy(useChatEndFeature = useChatEndFeature) } @@ -344,10 +355,11 @@ class ChatRoomViewModel @Inject constructor( } } + // LIMIT_EXCEEDED는 토큰이 이미 소진됐을 때 나는 신호라, 아래에서 requestRefresh()가 트리거하는 + // tokenUsageRefreshNotifier의 EXHAUSTED 알림(GamssSnackBar)이 대신 안내한다 — 토스트를 따로 띄우지 않는다. private fun CommentGenerationStatus.toUserMessage(): String? = when (this) { - CommentGenerationStatus.DONE -> null + CommentGenerationStatus.DONE, CommentGenerationStatus.LIMIT_EXCEEDED -> null CommentGenerationStatus.FAILED -> "답장을 받지 못했어요. 잠시 후 다시 보내볼까요?" - CommentGenerationStatus.LIMIT_EXCEEDED -> "오늘은 대화를 많이 했어요. 내일 다시 이야기해요." } private data class PendingSend( diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml index 55627952..0033385f 100644 --- a/feature/chat/src/main/res/values/strings.xml +++ b/feature/chat/src/main/res/values/strings.xml @@ -14,6 +14,8 @@ 오전 5:00에 초기화됩니다 사용량을 불러오지 못했어요. 재시도 + 오늘 사용 가능한 토큰이 10% 남았어요. + 오늘 사용 가능한 토큰을 전부 사용했어요. 입력 중 혼자 감당하지 않아도 괜찮아요. 대화에서 도움이 필요하다는 신호가 확인됐어요.\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 1807f0ec..d6d7904c 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 @@ -31,6 +31,7 @@ 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.TokenUsageAlert import com.gamss.android.domain.repository.TokenUsageRefreshNotifier import com.gamss.android.domain.safety.DetectRiskInTextUseCase import com.gamss.android.domain.safety.RiskLexicon @@ -45,6 +46,8 @@ 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.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import java.time.LocalDate import java.time.YearMonth @@ -130,12 +133,15 @@ internal class FakeUserRepository( override suspend fun getDailyTokenUsage(): AppResult = AppResult.Success(usage) } -/** 갱신 요청 횟수만 센다. 홈 쪽 수신은 feature:home 테스트가 본다. */ -internal class RecordingTokenUsageRefreshNotifier : TokenUsageRefreshNotifier { +/** 갱신 요청 횟수만 센다. 알림 발생 자체를 검증하는 테스트는 별도 fake로 alerts를 채운다. */ +internal class RecordingTokenUsageRefreshNotifier( + override val alerts: Flow = emptyFlow(), +) : TokenUsageRefreshNotifier { var refreshCount = 0 private set override val refreshEvents: Flow = emptyFlow() + override val usagePercent: StateFlow = MutableStateFlow(null).asStateFlow() override fun requestRefresh() { refreshCount++ From fd399369d03758493ca4ec56e4d7ed6b41406968 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 06:12:15 +0900 Subject: [PATCH 06/24] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EB=B9=88=20=ED=99=94=EB=A9=B4=20=ED=83=AD=ED=95=A0=EB=95=8C=20?= =?UTF-8?q?=ED=82=A4=EB=B3=B4=EB=93=9C=20=EB=82=B4=EB=A0=A4=EA=B0=80?= =?UTF-8?q?=EB=8A=94=20=EB=8F=99=EC=9E=91=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/designsystem/modifier/ClearFocus.kt | 14 ++++++++++++++ .../gamss/android/feature/chat/ChatRoomScreen.kt | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/ClearFocus.kt diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/ClearFocus.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/ClearFocus.kt new file mode 100644 index 00000000..35ff5b9a --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/ClearFocus.kt @@ -0,0 +1,14 @@ +package com.gamss.android.core.designsystem.modifier + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.input.pointer.pointerInput + +fun Modifier.addFocusCleaner(focusManager: FocusManager): Modifier { + return this.pointerInput(Unit) { + detectTapGestures(onTap = { + focusManager.clearFocus() + }) + } +} \ 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 b3961dda..d853ac0b 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 @@ -38,16 +38,16 @@ 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.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -61,6 +61,7 @@ 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.addFocusCleaner import com.gamss.android.core.designsystem.modifier.gamssShadow import com.gamss.android.core.designsystem.snackbar.GamssSnackBar import com.gamss.android.core.designsystem.theme.GamssTheme @@ -263,6 +264,7 @@ private fun ChatRoomContent( onBackClick: () -> Unit, ) { val listState = rememberLazyListState() + val focusManager = LocalFocusManager.current val messageAnimationState = rememberChatMessageAnimationState( conversationId = state.conversationId, isLoading = state.isLoading, @@ -293,7 +295,7 @@ private fun ChatRoomContent( } Scaffold( - modifier = modifier, + modifier = modifier.addFocusCleaner(focusManager), topBar = { ChatRoomTopBar(state = state, actions = actions, onBackClick = onBackClick) }, snackbarHost = { SnackbarHost(snackbarHostState) { data -> From ac0e2cf63053698a9b47dd7bfff1c1de4da9244f Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 06:15:01 +0900 Subject: [PATCH 07/24] =?UTF-8?q?fix:=20=ED=98=84=EC=9E=AC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20=EB=B3=B4=EC=9D=B4=EB=8A=94=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=EA=B0=80=20=ED=82=A4=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=97=90=20=EC=9D=98=ED=95=B4=20?= =?UTF-8?q?=EA=B0=80=EB=A0=A4=EC=A7=80=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 --- .../android/feature/chat/ChatRoomScreen.kt | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 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 d853ac0b..e445a90c 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 @@ -272,13 +272,24 @@ private fun ChatRoomContent( ) val chatScrollState = rememberChatScrollState(state = state, listState = listState) - 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()) + // WindowInsets.ime 게터 자체가 @Composable이라 LaunchedEffect(코루틴) 안에서 직접 부를 수 + // 없다. 여기서 객체 참조만 한 번 얻어두면, 이후 getBottom() 호출은 일반 함수 호출이라 코루틴 + // 안에서도 매번 최신 값을 읽을 수 있다. + val imeInsets = WindowInsets.ime + val imeDensity = LocalDensity.current + val imeBottomPx = imeInsets.getBottom(imeDensity) + // 키보드 인셋은 시스템이 여러 프레임에 걸쳐 애니메이션으로 흘려보낸다. LaunchedEffect(key)로 + // 매번 새 코루틴을 띄우면, 이전 scrollBy()가 끝나기 전에 다음 인셋 값이 도착해 진행 중이던 + // scrollBy가 취소되면서 그 구간만큼 보정이 누락된다 — 그러면 이 보정 자체가 안 먹는 것처럼 + // 보인다. 코루틴 하나를 계속 살려두고 snapshotFlow로 값을 순서대로 받아야 delta가 안 끊긴다. + LaunchedEffect(listState, imeInsets, imeDensity) { + var previous = imeInsets.getBottom(imeDensity) + snapshotFlow { imeInsets.getBottom(imeDensity) }.collect { current -> + val delta = current - previous + previous = current + if (delta != 0) { + listState.scrollBy(delta.toFloat()) + } } } From d0a6ca2d0e806bfefa2964e585f0c30b60941365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=8A=B9=ED=9D=AC?= Date: Thu, 20 Aug 2026 16:46:58 +0900 Subject: [PATCH 08/24] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EC=86=8C?= =?UTF-8?q?=EB=AA=A8=20=EC=8A=A4=EB=82=B5=EB=B0=94=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD=20-=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=ED=86=A0=ED=81=B0=20=EC=86=8C=EB=AA=A8=20=EC=8A=A4?= =?UTF-8?q?=EB=82=B5=EB=B0=94=20=ED=91=9C=EC=8B=9C=EB=8A=94=20=ED=95=9C?= =?UTF-8?q?=EB=B2=88=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EA=B0=80=20=ED=86=A0=ED=81=B0=20=EC=A0=84=EB=B6=80=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EA=B3=A0=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=8B=9C=EB=8F=84=ED=95=A0=EB=95=8C?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EB=B0=9C=ED=96=89=ED=95=98=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 --- .../TokenUsageRefreshNotifierImpl.kt | 19 ++-- .../TokenUsageRefreshNotifierImplTest.kt | 107 ++++++++++++++++++ .../repository/TokenUsageRefreshNotifier.kt | 9 +- .../android/feature/chat/ChatRoomScreen.kt | 91 ++++++++++----- .../android/feature/chat/ChatRoomViewModel.kt | 5 +- .../feature/chat/component/MessageInputBar.kt | 3 +- 6 files changed, 188 insertions(+), 46 deletions(-) create mode 100644 data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt diff --git a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt index 77afdf79..96afa4e6 100644 --- a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt @@ -40,9 +40,11 @@ internal class TokenUsageRefreshNotifierImpl @Inject constructor( override val alerts: Flow = _alerts.asSharedFlow() // 화면(ViewModel)을 나갔다 들어와도 안 사라지도록, "이미 알렸는지"는 프로세스가 사는 동안 - // 여기 싱글턴에만 기억한다. + // 여기 싱글턴에만 기억한다. LOW는 하루 1회만 알리면 되지만, EXHAUSTED는 전송할 때마다(= + // refresh() 호출마다) 소진 상태면 매번 알려야 해서 별도 게이팅 플래그가 없다 — wasExceeded는 + // 알림 여부가 아니라 "새 하루가 시작됐는지"를 판단하는 용도로만 쓴다. private var hasAlertedLow = false - private var hasAlertedExhausted = false + private var wasExceeded = false init { applicationScope.launch { @@ -61,24 +63,25 @@ internal class TokenUsageRefreshNotifierImpl @Inject constructor( val percent = calculateTokenUsagePercent(usage.usedTokens, usage.dailyLimit) // 이전보다 사용량이 줄었거나(서버가 자정/오전 5시에 리셋) 더 이상 소진 상태가 아니라면 - // 새 하루로 보고 알림 상태를 초기화한다 — 클라이언트가 리셋 시각을 직접 계산하지 않는다. + // 새 하루로 보고 LOW 알림 상태를 초기화한다 — 클라이언트가 리셋 시각을 직접 계산하지 않는다. val previousPercent = _usagePercent.value val isNewDay = (previousPercent != null && percent != null && percent < previousPercent) || - (hasAlertedExhausted && !usage.exceeded) + (wasExceeded && !usage.exceeded) if (isNewDay) { hasAlertedLow = false - hasAlertedExhausted = false } + wasExceeded = usage.exceeded _usagePercent.value = percent when { // exceeded는 서버가 내려주는 확정값이라 반올림 오차가 있는 percent >= 100 보다 정확하다. - usage.exceeded && !hasAlertedExhausted -> { - hasAlertedExhausted = true + // requestRefresh()는 메시지 전송 성공 직후에만 호출되므로, refresh() 1회 = 전송 1회다. + // 즉 소진 상태에서 보낼 때마다(= 서버가 소진을 응답할 때마다) 매번 알린다. + usage.exceeded -> { hasAlertedLow = true _alerts.tryEmit(TokenUsageAlert.EXHAUSTED) } - !usage.exceeded && percent != null && percent >= LOW_USAGE_THRESHOLD_PERCENT && !hasAlertedLow -> { + percent != null && percent >= LOW_USAGE_THRESHOLD_PERCENT && !hasAlertedLow -> { hasAlertedLow = true _alerts.tryEmit(TokenUsageAlert.LOW) } diff --git a/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt new file mode 100644 index 00000000..4008bdde --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt @@ -0,0 +1,107 @@ +package com.gamss.android.data.repository + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.model.DailyTokenUsage +import com.gamss.android.domain.repository.TokenUsageAlert +import com.gamss.android.domain.user.UserRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TokenUsageRefreshNotifierImplTest { + + private val userRepository: UserRepository = mockk() + + @Test + fun `소진 상태면 요청할 때마다 EXHAUSTED를 매번 흘려보낸다`() = runTest { + coEvery { userRepository.getDailyTokenUsage() } returns + AppResult.Success(usage(usedTokens = 100, dailyLimit = 100, exceeded = true)) + val notifier = TokenUsageRefreshNotifierImpl(userRepository, backgroundScope) + val received = collectAlerts(notifier) + + repeat(3) { + notifier.requestRefresh() + advanceUntilIdle() + } + + assertEquals( + listOf(TokenUsageAlert.EXHAUSTED, TokenUsageAlert.EXHAUSTED, TokenUsageAlert.EXHAUSTED), + received, + ) + } + + @Test + fun `10퍼센트 미만 남음은 같은 날엔 한 번만 흘려보낸다`() = runTest { + coEvery { userRepository.getDailyTokenUsage() } returns + AppResult.Success(usage(usedTokens = 95, dailyLimit = 100, exceeded = false)) + val notifier = TokenUsageRefreshNotifierImpl(userRepository, backgroundScope) + val received = collectAlerts(notifier) + + repeat(3) { + notifier.requestRefresh() + advanceUntilIdle() + } + + assertEquals(listOf(TokenUsageAlert.LOW), received) + } + + @Test + fun `소진 이후 사용량이 리셋되면 10퍼센트 미만 남음 알림이 다시 발행된다`() = runTest { + val notifier = TokenUsageRefreshNotifierImpl(userRepository, backgroundScope) + val received = collectAlerts(notifier) + + coEvery { userRepository.getDailyTokenUsage() } returns + AppResult.Success(usage(usedTokens = 100, dailyLimit = 100, exceeded = true)) + notifier.requestRefresh() + advanceUntilIdle() + + // 자정/오전 5시 리셋으로 사용량이 다시 낮아졌다가 90%대로 재진입한 상황. + coEvery { userRepository.getDailyTokenUsage() } returns + AppResult.Success(usage(usedTokens = 0, dailyLimit = 100, exceeded = false)) + notifier.requestRefresh() + advanceUntilIdle() + + coEvery { userRepository.getDailyTokenUsage() } returns + AppResult.Success(usage(usedTokens = 92, dailyLimit = 100, exceeded = false)) + notifier.requestRefresh() + advanceUntilIdle() + + assertEquals(listOf(TokenUsageAlert.EXHAUSTED, TokenUsageAlert.LOW), received) + } + + /** + * [notifier.alerts]는 replay 없는 SharedFlow라, 구독이 emit보다 늦으면 그 알림을 영영 놓친다. + * UNDISPATCHED로 즉시 구독까지 실행해 이후의 requestRefresh() 호출보다 항상 먼저 구독되도록 + * 보장한다. 무한 수집이라 backgroundScope에 태워 테스트 종료 시 자동으로 취소되게 한다. + * + * [TokenUsageRefreshNotifierImpl]의 init도 내부적으로 refreshEvents를 구독하는 코루틴을 + * (UNDISPATCHED가 아닌 일반 launch로) 띄운다. 이 구독 역시 같은 replay=0 문제를 겪으므로, + * 여기서 advanceUntilIdle()을 한 번 태워 그 구독이 실제로 걸리게 한 뒤에 반환해야 + * 테스트에서의 첫 requestRefresh() 신호를 놓치지 않는다. + */ + private fun TestScope.collectAlerts( + notifier: TokenUsageRefreshNotifierImpl, + ): MutableList { + val received = mutableListOf() + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + notifier.alerts.toList(received) + } + advanceUntilIdle() + return received + } + + private fun usage(usedTokens: Long, dailyLimit: Long?, exceeded: Boolean) = DailyTokenUsage( + usedTokens = usedTokens, + dailyLimit = dailyLimit, + exceeded = exceeded, + ) +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt b/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt index 87e39d2a..51f4c2ae 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt @@ -7,9 +7,10 @@ import kotlinx.coroutines.flow.StateFlow * 토큰 사용량 새로고침 신호 버스 + 전역 사용량 캐시. * * [requestRefresh]로 재조회를 요청하면, 구현체가 내부에서 조회해 [usagePercent]를 갱신하고 - * 10% 미만 남음/전부 소진 임계값을 넘었을 때 [alerts]로 한 번만 흘려보낸다. 여러 채팅방을 - * 오가도(대화방을 나갔다 들어와도) 알림이 중복으로 뜨지 않도록, "이미 알렸는지"는 화면(ViewModel) - * 로컬이 아니라 이 싱글턴 구현체가 기억한다. + * [alerts]로 흘려보낸다. LOW(10% 미만 남음)는 하루 1회만, EXHAUSTED(전부 소진)는 소진 상태로 + * 재조회될 때마다 매번 흘려보낸다 — 두 알림의 발행 정책이 다르다. 여러 채팅방을 오가도(대화방을 + * 나갔다 들어와도) LOW 알림이 중복으로 뜨지 않도록, "이미 알렸는지"는 화면(ViewModel) 로컬이 + * 아니라 이 싱글턴 구현체가 기억한다. */ interface TokenUsageRefreshNotifier { val refreshEvents: Flow @@ -17,7 +18,7 @@ interface TokenUsageRefreshNotifier { /** 가장 최근에 조회된 사용률(0~100). 아직 한 번도 조회되지 않았으면 null. */ val usagePercent: StateFlow - /** 10% 미만 남음/전부 소진을 감지했을 때 한 번만 흘려보내는 알림. */ + /** LOW는 하루 1회만, EXHAUSTED는 소진 상태가 감지될 때마다 매번 흘려보내는 알림. */ val alerts: Flow fun requestRefresh() 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 e445a90c..5e6f3542 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 @@ -45,6 +45,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -53,6 +54,7 @@ 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.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup @@ -305,53 +307,78 @@ private fun ChatRoomContent( isImeInTransition = false } + // Scaffold의 기본 snackbarHost 슬롯은 화면 최하단(네비게이션 바 위)에 붙는다. 입력창은 + // bottomBar가 아니라 아래 Column의 마지막 자식이라 Scaffold가 그 높이를 모르므로, 여기선 + // snackbarHost를 쓰지 않고 입력창과 같은 Box 안에서 실측 높이를 기준으로 직접 띄운다. Scaffold( modifier = modifier.addFocusCleaner(focusManager), topBar = { ChatRoomTopBar(state = state, actions = actions, onBackClick = onBackClick) }, - snackbarHost = { - SnackbarHost(snackbarHostState) { data -> - val alert = (data.visuals as? TokenUsageSnackbarVisuals)?.alert - GamssSnackBar( - message = data.visuals.message, - snackBarIcon = { TokenUsageAlertIcon(alert) }, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 12.dp), - ) - } - }, // 상위 Scaffold 가 인셋을 이미 적용해, imePadding 을 그대로 쓰면 이중 적용된다. contentWindowInsets = WindowInsets(0), ) { innerPadding -> - Column( + Box( modifier = Modifier .fillMaxSize() .background(GamssTheme.colors.background) .padding(innerPadding) .windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)), ) { - ChatMessageList( - state = state, - actions = actions, - listState = listState, - animationState = messageAnimationState, - scrollState = chatScrollState, - showScrollToBottomButton = chatScrollState.showScrollToBottomButton && !isImeInTransition, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) + var inputSectionHeight by remember { mutableStateOf(0.dp) } + val density = LocalDensity.current - ChatRoomInputSection( - endFlow = state.endFlow, - input = state.input, - isInputEnabled = !state.isLoading && state.endFlow == EndFlow.NotStarted, - isSending = state.isSending, - replyTarget = state.replyTarget, - actions = actions, - ) + Column(modifier = Modifier.fillMaxSize()) { + ChatMessageList( + state = state, + actions = actions, + listState = listState, + animationState = messageAnimationState, + scrollState = chatScrollState, + showScrollToBottomButton = chatScrollState.showScrollToBottomButton && !isImeInTransition, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) + + ChatRoomInputSection( + endFlow = state.endFlow, + input = state.input, + isInputEnabled = !state.isLoading && state.endFlow == EndFlow.NotStarted, + isSending = state.isSending, + replyTarget = state.replyTarget, + actions = actions, + modifier = Modifier.onGloballyPositioned { coordinates -> + inputSectionHeight = with(density) { coordinates.size.height.toDp() } + }, + ) + } + + SnackbarHost( + snackbarHostState, + // 아래쪽은 입력창과의 간격을 여기 한 곳(TokenUsageSnackbarBottomGap)에서만 정확히 + // 10dp로 맞춘다 — GamssSnackBar 쪽에 별도 bottom padding을 더 두면 간격이 겹친다. + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = inputSectionHeight + TokenUsageSnackbarBottomGap), + ) { data -> + val alert = (data.visuals as? TokenUsageSnackbarVisuals)?.alert + GamssSnackBar( + message = data.visuals.message, + snackBarIcon = { TokenUsageAlertIcon(alert) }, + modifier = Modifier.padding( + top = 12.dp, + start = 18.dp, + end = 18.dp, + bottom = 0.dp, + ), + ) + } } } } +/** 토큰 안내 스낵바와 입력창 사이 간격. */ +private val TokenUsageSnackbarBottomGap: Dp = 10.dp + @Composable private fun ChatRoomTopBar( state: ChatRoomState, @@ -555,6 +582,7 @@ private fun ChatRoomInputSection( isSending: Boolean, replyTarget: ReplyTarget?, actions: ChatRoomActions, + modifier: Modifier = Modifier, ) { if (endFlow is EndFlow.Ended) { Text( @@ -562,7 +590,7 @@ private fun ChatRoomInputSection( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(16.dp), ) @@ -577,6 +605,7 @@ private fun ChatRoomInputSection( onInputChange = actions.onInputChange, onSendClick = actions.onSendClick, onReplyClear = actions.onReplyTargetClear, + modifier = modifier, ) } 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 7a12292d..1445a07d 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 @@ -59,8 +59,9 @@ class ChatRoomViewModel @Inject constructor( } /** - * 10% 미만 남음/전부 소진 알림은 [tokenUsageRefreshNotifier]가 전역으로 한 번만 흘려보낸다 — - * 여러 채팅방을 오가도(대화방을 나갔다 들어와도) 중복으로 뜨지 않는다. + * [tokenUsageRefreshNotifier]가 전역으로 흘려보내는 알림을 그대로 옮긴다. LOW(10% 미만 남음)는 + * 하루 1회만 오므로 여러 채팅방을 오가도(대화방을 나갔다 들어와도) 중복으로 뜨지 않지만, + * EXHAUSTED(전부 소진)는 보낼 때마다 소진 상태면 매번 온다. */ private fun observeTokenUsageAlerts() = intent { tokenUsageRefreshNotifier.alerts.collect { alert -> 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 23c41c70..14ab77e0 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 @@ -20,12 +20,13 @@ fun MessageInputBar( onInputChange: (String) -> Unit, onSendClick: () -> Unit, onReplyClear: () -> Unit, + modifier: Modifier = Modifier, ) { GamssInputBar( value = input, onValueChange = onInputChange, onTrailingClick = onSendClick, - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 18.dp, vertical = 12.dp), placeholder = stringResource(R.string.chat_room_input_placeholder), From 86a805f08bd591fa6437cc2f81b902b973f72e71 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 18:45:36 +0900 Subject: [PATCH 09/24] =?UTF-8?q?fix:=20=EB=8C=80=ED=99=94=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=86=90=EA=B7=B8=EB=A6=BC=20=EC=8B=9C=EC=95=88=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20-=20=EC=84=B8=EB=B6=80=EC=A0=81=EC=9D=B8?= =?UTF-8?q?=20=ED=8C=A8=EB=94=A9,=20=EC=BB=AC=EB=9F=AC=EA=B0=92=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 --- .../chat/component/ConversationCard.kt | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ConversationCard.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ConversationCard.kt index 3379beb2..43c9145b 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ConversationCard.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ConversationCard.kt @@ -1,7 +1,10 @@ package com.gamss.android.feature.chat.component import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background 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 @@ -14,13 +17,17 @@ 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.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics 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.card.GamssOutlinedCard +import com.gamss.android.core.designsystem.R import com.gamss.android.core.designsystem.checkbox.GamssCheckbox +import com.gamss.android.core.designsystem.modifier.noRippleCombinedClickable import com.gamss.android.core.designsystem.theme.GamssTheme /** @@ -32,6 +39,10 @@ import com.gamss.android.core.designsystem.theme.GamssTheme * 높이는 고정이 아니라 최소값입니다. 제목이 한 줄에 안 들어가면 두 줄까지 늘어나고 카드도 함께 * 커집니다. 콘텐츠 폭이 334dp 라 한 줄로는 20자 남짓만 들어가는데, 선택 모드에서는 체크박스가 * 32dp 를 더 먹어 그보다 짧아집니다. + * + * 테두리는 [GamssTextField][com.gamss.android.core.designsystem.textfield.GamssTextField]와 + * 같은 손그림 시안 `bg_textfield_default` 벡터를 깔고 색만 틴트한다. 이 표현은 채팅 목록에서만 + * 쓰여 디자인시스템 컴포넌트가 아니라 이 카드에 직접 넣는다. */ @Suppress("LongParameterList") @Composable @@ -44,32 +55,35 @@ internal fun ConversationCard( onLongClick: () -> Unit, modifier: Modifier = Modifier, ) { - GamssOutlinedCard( + + Box( modifier = modifier .fillMaxWidth() .heightIn(min = ConversationCardMinHeight) - .semantics { if (isSelectionMode) selected = isSelected }, - onClick = onClick, - onLongClick = onLongClick, - borderColor = if (isSelectionMode && !isSelected) { - GamssTheme.colors.gray200 - } else { - GamssTheme.colors.gray950 - }, - contentPadding = ConversationCardPadding, + .semantics { if (isSelectionMode) selected = isSelected } + .background(color = GamssTheme.colors.background) + .noRippleCombinedClickable(onClick = onClick, onLongClick = onLongClick), ) { + Image( + modifier = Modifier.matchParentSize(), + painter = painterResource(R.drawable.bg_textfield_default), + contentDescription = null, + contentScale = ContentScale.FillBounds, + colorFilter = ColorFilter.tint( GamssTheme.colors.gray950), + ) // SpaceBetween 은 쓸 수 없다. 제목이 짧으면 남는 폭이 자식 사이로 흩어져, 시각이 없는 // 행에서 제목이 오른쪽 끝으로 밀려난다. Row( modifier = Modifier .fillMaxWidth() - .align(Alignment.Center), + .align(Alignment.Center) + .padding(ConversationCardPadding), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { if (isSelectionMode) { GamssCheckbox(checked = isSelected) - Spacer(modifier = Modifier.width(GamssTheme.spacing.spacing200)) + Spacer(modifier = Modifier.width(GamssTheme.spacing.spacing050)) } Text( @@ -82,7 +96,7 @@ internal fun ConversationCard( ) if (timeLabel != null) { - Spacer(modifier = Modifier.width(GamssTheme.spacing.spacing100)) + Spacer(modifier = Modifier.width(GamssTheme.spacing.spacing075)) Text( text = timeLabel, style = GamssTheme.typography.body6Medium, @@ -161,4 +175,4 @@ private const val TITLE_MAX_LINES = 2 // 디자인 실측: 카드 366 폭에 내부 334, 좌우 16. 높이 59 는 한 줄일 때의 값이라 최소값으로 둔다. private val ConversationCardMinHeight = 59.dp -private val ConversationCardPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp) +private val ConversationCardPadding = PaddingValues(horizontal = 16.dp, vertical = 19.dp) From 0ccdfd4e252b547559bd7bcfa54c1281240419e9 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 18:52:53 +0900 Subject: [PATCH 10/24] =?UTF-8?q?fix:=20=EB=8C=80=ED=99=94=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=97=A4=EB=8D=94=20=EC=BB=AC=EB=9F=AC=EA=B0=92=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 --- .../feature/chat/component/ChattingListSectionHeader.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingListSectionHeader.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingListSectionHeader.kt index f6ff3ae3..c0721a02 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingListSectionHeader.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingListSectionHeader.kt @@ -53,11 +53,7 @@ internal fun ChattingListSectionHeader( ), text = stringResource(R.string.chatting_list_delete_action), style = GamssTheme.typography.body5Medium, - color = if (deleteActionEnabled) { - GamssTheme.colors.gray500 - } else { - GamssTheme.colors.gray300 - }, + color = GamssTheme.colors.gray500, ) } } From 8121cab851f6bfb3737777798deb2a4422d73a17 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 19:38:39 +0900 Subject: [PATCH 11/24] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=ED=95=84=EB=93=9C=20=ED=85=8C?= =?UTF-8?q?=EB=91=90=EB=A6=AC=20=EC=86=90=EA=B7=B8=EB=A6=BC=20=EC=8B=9C?= =?UTF-8?q?=EC=95=88=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EC=84=B8=EB=B6=80?= =?UTF-8?q?=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../designsystem/component/GamssInputBar.kt | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 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 671fe8e3..32213018 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 @@ -2,8 +2,6 @@ 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 @@ -20,7 +18,6 @@ import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role @@ -33,10 +30,9 @@ import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote import com.gamss.android.core.designsystem.theme.GamssTheme /** 입력바 아래에 무언가를 띄우는 화면이 있어 공개한다. */ -val GamssInputBarHeight = 53.dp +val GamssInputBarHeight = 48.dp private val InputBarHeight = GamssInputBarHeight -private val InputBarBorderWidth = 1.5.dp private val InputBarStartPadding = 16.dp /** 전송 버튼은 배경까지 담긴 32x32 에셋이라 tint 하지 않고 그대로 그린다. */ @@ -45,7 +41,11 @@ private val SendButtonSize = 32.dp private val SendButtonTouchSize = 48.dp private val InputBarEndPadding = 8.dp -// 답장 미리보기가 있을 때만 쓰는 세로 여백/간격. 없을 때는 기존처럼 최소 높이 + 가운데 정렬로 채운다. +// 답장 미리보기가 없을 때 글과 테두리 사이에 항상 남겨야 하는 여백. 줄 수와 무관하게 고정값으로 +// 둬야 두 줄 이상으로 늘어나도 글이 테두리에 붙지 않는다. +private val InputBarVerticalPadding = 8.dp + +// 답장 미리보기가 있을 때 쓰는 세로 여백/간격. private val InputBarReplyVerticalPadding = 14.dp private val InputBarReplyGap = 14.dp @@ -53,7 +53,7 @@ private val ReplyClearIconSize = 20.dp private val ReplyClearTouchSize = 32.dp // 140자 상한을 이 폭에서 담으려면 6줄이면 넉넉하다. 넘치면 입력칸 안에서 스크롤된다. -private const val INPUT_MAX_LINES = 6 +private const val INPUT_MAX_LINES = 5 @Suppress("LongParameterList") @Composable @@ -85,8 +85,8 @@ fun GamssInputBar( Column( modifier = modifier - .background(GamssTheme.colors.white, RectangleShape) - .border(InputBarBorderWidth, GamssTheme.colors.black, RectangleShape) + .gamssSketchyBox(artRes = R.drawable.bg_textfield_default, background = GamssTheme.colors.white) + .heightIn(min = GamssInputBarHeight) .padding(start = InputBarStartPadding, top = verticalPadding, bottom = verticalPadding), ) { if (replyQuote != null) { @@ -101,7 +101,8 @@ fun GamssInputBar( } Row( modifier = Modifier - // 140자를 채우면 여러 줄이 된다. 한 줄일 때의 높이를 최소치로만 잡고 아래로 늘어나게 둔다. + // 전송 버튼 터치 영역(48dp)을 최소치로 잡는다. 140자를 채워 여러 줄이 되면 그 + // 이상으로 늘어난다. .heightIn(min = InputBarHeight) .padding(end = InputBarEndPadding), verticalAlignment = Alignment.CenterVertically, @@ -109,7 +110,9 @@ fun GamssInputBar( BasicTextField( value = value, onValueChange = onValueChange, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .let { if (replyQuote == null) it.padding(vertical = InputBarVerticalPadding) else it }, enabled = enabled, // 한 줄로 묶으면 140자 상한에 걸려도 가로로 스크롤만 되어 잘린 게 보이지 않는다. // 상한을 안 걸면 남은 세로 공간을 전부 채워 버리므로 줄 수로 묶는다. @@ -130,7 +133,7 @@ fun GamssInputBar( GamssText( text = placeholder, style = GamssTheme.typography.body4Medium, - color = GamssTheme.colors.gray500, + color = GamssTheme.colors.gray400, ) } innerTextField() @@ -192,7 +195,7 @@ private fun ReplyPreviewRow( contentAlignment = Alignment.Center, ) { Icon( - painter = painterResource(R.drawable.ic_close), + painter = painterResource(GamssIcons.ClearButton), contentDescription = clearContentDescription, modifier = Modifier.size(ReplyClearIconSize), tint = GamssTheme.colors.gray300, From 6113dd7606e399c72085e7638b23e2a8db543a85 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 19:38:54 +0900 Subject: [PATCH 12/24] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt | 3 --- 1 file changed, 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 5e6f3542..04dea78a 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 @@ -307,9 +307,6 @@ private fun ChatRoomContent( isImeInTransition = false } - // Scaffold의 기본 snackbarHost 슬롯은 화면 최하단(네비게이션 바 위)에 붙는다. 입력창은 - // bottomBar가 아니라 아래 Column의 마지막 자식이라 Scaffold가 그 높이를 모르므로, 여기선 - // snackbarHost를 쓰지 않고 입력창과 같은 Box 안에서 실측 높이를 기준으로 직접 띄운다. Scaffold( modifier = modifier.addFocusCleaner(focusManager), topBar = { ChatRoomTopBar(state = state, actions = actions, onBackClick = onBackClick) }, From f16754356ee6f184247508db89321b5af0742a33 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 20:39:03 +0900 Subject: [PATCH 13/24] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=86=8C?= =?UTF-8?q?=EB=AA=A8=20=EC=95=8C=EB=A6=BC=EC=9D=84=20=EC=8A=A4=EB=82=B5?= =?UTF-8?q?=EB=B0=94=EC=97=90=EC=84=9C=20=ED=86=A0=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=98=EA=B3=A0=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=B2=84=ED=8A=BC=20=EC=9E=A0=EA=B8=88=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokenUsageRefreshNotifierImpl.kt | 16 +-- .../TokenUsageRefreshNotifierImplTest.kt | 55 ++++++-- .../repository/TokenUsageRefreshNotifier.kt | 13 +- .../android/feature/chat/ChatRoomScreen.kt | 131 ++++-------------- .../android/feature/chat/ChatRoomState.kt | 2 + .../android/feature/chat/ChatRoomViewModel.kt | 34 +++-- .../feature/chat/component/MessageInputBar.kt | 7 +- feature/chat/src/main/res/values/strings.xml | 4 +- .../android/feature/chat/ChatRoomTestFakes.kt | 1 + 9 files changed, 122 insertions(+), 141 deletions(-) diff --git a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt index 96afa4e6..2cd49c83 100644 --- a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt @@ -35,16 +35,17 @@ internal class TokenUsageRefreshNotifierImpl @Inject constructor( private val _usagePercent = MutableStateFlow(null) override val usagePercent: StateFlow = _usagePercent.asStateFlow() + private val _isExhausted = MutableStateFlow(false) + override val isExhausted: StateFlow = _isExhausted.asStateFlow() + /** 구독이 늦게 시작돼도 막 emit된 알림 하나는 놓치지 않도록 버퍼 1개만 둔다. */ private val _alerts = MutableSharedFlow(extraBufferCapacity = 1) override val alerts: Flow = _alerts.asSharedFlow() // 화면(ViewModel)을 나갔다 들어와도 안 사라지도록, "이미 알렸는지"는 프로세스가 사는 동안 // 여기 싱글턴에만 기억한다. LOW는 하루 1회만 알리면 되지만, EXHAUSTED는 전송할 때마다(= - // refresh() 호출마다) 소진 상태면 매번 알려야 해서 별도 게이팅 플래그가 없다 — wasExceeded는 - // 알림 여부가 아니라 "새 하루가 시작됐는지"를 판단하는 용도로만 쓴다. + // refresh() 호출마다) 소진 상태면 매번 알려야 해서 별도 게이팅 플래그가 없다. private var hasAlertedLow = false - private var wasExceeded = false init { applicationScope.launch { @@ -62,21 +63,20 @@ internal class TokenUsageRefreshNotifierImpl @Inject constructor( val usage = result.data val percent = calculateTokenUsagePercent(usage.usedTokens, usage.dailyLimit) - // 이전보다 사용량이 줄었거나(서버가 자정/오전 5시에 리셋) 더 이상 소진 상태가 아니라면 + // 이전보다 사용량이 줄었거나(서버가 오전 5시에 리셋) 더 이상 소진 상태가 아니라면 // 새 하루로 보고 LOW 알림 상태를 초기화한다 — 클라이언트가 리셋 시각을 직접 계산하지 않는다. val previousPercent = _usagePercent.value val isNewDay = (previousPercent != null && percent != null && percent < previousPercent) || - (wasExceeded && !usage.exceeded) + (_isExhausted.value && !usage.exceeded) if (isNewDay) { hasAlertedLow = false } - wasExceeded = usage.exceeded + _isExhausted.value = usage.exceeded _usagePercent.value = percent when { // exceeded는 서버가 내려주는 확정값이라 반올림 오차가 있는 percent >= 100 보다 정확하다. - // requestRefresh()는 메시지 전송 성공 직후에만 호출되므로, refresh() 1회 = 전송 1회다. - // 즉 소진 상태에서 보낼 때마다(= 서버가 소진을 응답할 때마다) 매번 알린다. + // requestRefresh()가 불릴 때마다(채팅방 진입, 전송 성공 등) 소진 상태면 매번 알린다. usage.exceeded -> { hasAlertedLow = true _alerts.tryEmit(TokenUsageAlert.EXHAUSTED) diff --git a/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt index 4008bdde..a6b30437 100644 --- a/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt @@ -6,11 +6,13 @@ import com.gamss.android.domain.repository.TokenUsageAlert import com.gamss.android.domain.user.UserRepository import io.mockk.coEvery import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -25,7 +27,7 @@ class TokenUsageRefreshNotifierImplTest { fun `소진 상태면 요청할 때마다 EXHAUSTED를 매번 흘려보낸다`() = runTest { coEvery { userRepository.getDailyTokenUsage() } returns AppResult.Success(usage(usedTokens = 100, dailyLimit = 100, exceeded = true)) - val notifier = TokenUsageRefreshNotifierImpl(userRepository, backgroundScope) + val notifier = newNotifier() val received = collectAlerts(notifier) repeat(3) { @@ -43,7 +45,7 @@ class TokenUsageRefreshNotifierImplTest { fun `10퍼센트 미만 남음은 같은 날엔 한 번만 흘려보낸다`() = runTest { coEvery { userRepository.getDailyTokenUsage() } returns AppResult.Success(usage(usedTokens = 95, dailyLimit = 100, exceeded = false)) - val notifier = TokenUsageRefreshNotifierImpl(userRepository, backgroundScope) + val notifier = newNotifier() val received = collectAlerts(notifier) repeat(3) { @@ -56,7 +58,7 @@ class TokenUsageRefreshNotifierImplTest { @Test fun `소진 이후 사용량이 리셋되면 10퍼센트 미만 남음 알림이 다시 발행된다`() = runTest { - val notifier = TokenUsageRefreshNotifierImpl(userRepository, backgroundScope) + val notifier = newNotifier() val received = collectAlerts(notifier) coEvery { userRepository.getDailyTokenUsage() } returns @@ -78,24 +80,53 @@ class TokenUsageRefreshNotifierImplTest { assertEquals(listOf(TokenUsageAlert.EXHAUSTED, TokenUsageAlert.LOW), received) } + @Test + fun `isExhausted는 소진 여부를 그대로 반영하고 리셋되면 다시 false가 된다`() = runTest { + val notifier = newNotifier() + assertEquals(false, notifier.isExhausted.value) + + coEvery { userRepository.getDailyTokenUsage() } returns + AppResult.Success(usage(usedTokens = 100, dailyLimit = 100, exceeded = true)) + notifier.requestRefresh() + advanceUntilIdle() + assertEquals(true, notifier.isExhausted.value) + + // 자정/오전 5시 리셋으로 더 이상 소진 상태가 아니게 된 상황. + coEvery { userRepository.getDailyTokenUsage() } returns + AppResult.Success(usage(usedTokens = 0, dailyLimit = 100, exceeded = false)) + notifier.requestRefresh() + advanceUntilIdle() + assertEquals(false, notifier.isExhausted.value) + } + + /** + * [TokenUsageRefreshNotifierImpl]의 `init`이 내부적으로 `refreshEvents`를 구독하는 코루틴을 + * 일반 `launch`(UNDISPATCHED가 아닌)로 띄운다. 이 프로젝트의 `TestScope.backgroundScope` + * (StandardTestDispatcher)에서는 그런 일반 `launch`가 `advanceUntilIdle()`로 실행 큐에 + * 올라오지 않는 채로 남는 문제가 있어 — 실제로 원인을 좁혀보면, launch 시작 시점에는 + * 스케줄러에 태스크가 등록되지만 그 뒤로 전혀 드레인되지 않는다 — 여기선 그 문제를 피하려고 + * `applicationScope`를 `UnconfinedTestDispatcher`로 구성한다. Unconfined 코루틴은 `launch()` + * 호출 시점에 첫 중단점까지 즉시(같은 스레드에서) 실행되므로, `advanceUntilIdle()`에 기대지 + * 않고도 `refreshEvents.collect { refresh() }` 구독이 노티파이어 생성 즉시 걸린다. + */ + private fun TestScope.newNotifier(): TokenUsageRefreshNotifierImpl = + TokenUsageRefreshNotifierImpl(userRepository, CoroutineScope(UnconfinedTestDispatcher(testScheduler))) + /** * [notifier.alerts]는 replay 없는 SharedFlow라, 구독이 emit보다 늦으면 그 알림을 영영 놓친다. - * UNDISPATCHED로 즉시 구독까지 실행해 이후의 requestRefresh() 호출보다 항상 먼저 구독되도록 - * 보장한다. 무한 수집이라 backgroundScope에 태워 테스트 종료 시 자동으로 취소되게 한다. - * - * [TokenUsageRefreshNotifierImpl]의 init도 내부적으로 refreshEvents를 구독하는 코루틴을 - * (UNDISPATCHED가 아닌 일반 launch로) 띄운다. 이 구독 역시 같은 replay=0 문제를 겪으므로, - * 여기서 advanceUntilIdle()을 한 번 태워 그 구독이 실제로 걸리게 한 뒤에 반환해야 - * 테스트에서의 첫 requestRefresh() 신호를 놓치지 않는다. + * 구독을 `UnconfinedTestDispatcher`로 돌려 subscribe든 emit 이후의 재개든 모두 즉시(같은 + * 스레드에서) 일어나게 한다 — `backgroundScope`의 기본 `StandardTestDispatcher`로 구독을 + * 걸면, 최초 구독(UNDISPATCHED 덕에 즉시 실행)까지는 되지만 emit으로 깨어난 뒤의 "재개"는 + * 다시 그 디스패처의 큐를 타 버려 `advanceUntilIdle()`로도 실행되지 않는 경우가 있었다. + * 취소는 여전히 `backgroundScope`의 Job을 부모로 물려받아 테스트 종료 시 자동으로 된다. */ private fun TestScope.collectAlerts( notifier: TokenUsageRefreshNotifierImpl, ): MutableList { val received = mutableListOf() - backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler), start = CoroutineStart.UNDISPATCHED) { notifier.alerts.toList(received) } - advanceUntilIdle() return received } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt b/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt index 51f4c2ae..f045f58c 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt @@ -6,9 +6,9 @@ import kotlinx.coroutines.flow.StateFlow /** * 토큰 사용량 새로고침 신호 버스 + 전역 사용량 캐시. * - * [requestRefresh]로 재조회를 요청하면, 구현체가 내부에서 조회해 [usagePercent]를 갱신하고 - * [alerts]로 흘려보낸다. LOW(10% 미만 남음)는 하루 1회만, EXHAUSTED(전부 소진)는 소진 상태로 - * 재조회될 때마다 매번 흘려보낸다 — 두 알림의 발행 정책이 다르다. 여러 채팅방을 오가도(대화방을 + * [requestRefresh]로 재조회를 요청하면, 구현체가 내부에서 조회해 [usagePercent]/[isExhausted]를 + * 갱신하고 [alerts]로 흘려보낸다. LOW(10% 미만 남음)는 하루 1회만, EXHAUSTED(전부 소진)는 소진 + * 상태로 재조회될 때마다 매번 흘려보낸다 — 두 알림의 발행 정책이 다르다. 여러 채팅방을 오가도(대화방을 * 나갔다 들어와도) LOW 알림이 중복으로 뜨지 않도록, "이미 알렸는지"는 화면(ViewModel) 로컬이 * 아니라 이 싱글턴 구현체가 기억한다. */ @@ -18,6 +18,13 @@ interface TokenUsageRefreshNotifier { /** 가장 최근에 조회된 사용률(0~100). 아직 한 번도 조회되지 않았으면 null. */ val usagePercent: StateFlow + /** + * 가장 최근 조회에서 서버가 확정한 소진 여부. 화면은 이 값으로 전송 버튼을 잠근다 — + * 반올림 오차가 있는 [usagePercent] >= 100 보다 정확하다. 아직 한 번도 조회되지 않았으면 + * `false`(비소진으로 간주)다. + */ + val isExhausted: StateFlow + /** LOW는 하루 1회만, EXHAUSTED는 소진 상태가 감지될 때마다 매번 흘려보내는 알림. */ val alerts: Flow 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 04dea78a..9e960f5e 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 @@ -29,10 +29,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarVisuals import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -45,7 +41,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager @@ -54,7 +49,6 @@ 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.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup @@ -65,7 +59,6 @@ import com.gamss.android.core.designsystem.component.GamssIcons import com.gamss.android.core.designsystem.component.GamssTokenUsageTooltip import com.gamss.android.core.designsystem.modifier.addFocusCleaner import com.gamss.android.core.designsystem.modifier.gamssShadow -import com.gamss.android.core.designsystem.snackbar.GamssSnackBar import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationHeight @@ -112,11 +105,8 @@ fun ChatRoomScreen( LaunchedEffect(conversationId) { viewModel.start(conversationId) } - // GamssSnackBar는 Scaffold(ChatRoomContent) 안에 있지만, 사이드이펙트 수집은 여기서 하는 게 - // Toast와 한곳에 모여 흐름을 따라가기 쉽다. 그래서 host만 여기서 만들어 아래로 넘긴다. - val snackbarHostState = remember { SnackbarHostState() } - val tokenUsageLowMessage = stringResource(R.string.chat_room_token_usage_low_snackbar) - val tokenUsageExhaustedMessage = stringResource(R.string.chat_room_token_usage_exhausted_snackbar) + val tokenUsageLowMessage = stringResource(R.string.chat_room_token_usage_low_toast) + val tokenUsageExhaustedMessage = stringResource(R.string.chat_room_token_usage_exhausted_toast) viewModel.collectSideEffect { sideEffect -> when (sideEffect) { @@ -128,10 +118,7 @@ fun ChatRoomScreen( TokenUsageAlert.LOW -> tokenUsageLowMessage TokenUsageAlert.EXHAUSTED -> tokenUsageExhaustedMessage } - - snackbarHostState.showSnackbar( - TokenUsageSnackbarVisuals(message = message, alert = sideEffect.alert), - ) + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } } @@ -151,7 +138,6 @@ fun ChatRoomScreen( ChatRoomContent( state = state, actions = actions, - snackbarHostState = snackbarHostState, modifier = modifier, onBackClick = onBackClick, ) @@ -223,45 +209,10 @@ private data class ChatRoomActions( val onTokenUsageRetry: () -> Unit, ) -/** - * [alert] 종류에 따라 GamssSnackBar 아이콘을 달리 그리기 위한 커스텀 [SnackbarVisuals]. - * 기본 `showSnackbar(message)` 오버로드는 문자열만 실어 나를 수 있어, 렌더 시점에 아이콘을 - * 고를 근거가 메시지 문구밖에 안 남는다 — 문구가 바뀌면 아이콘도 같이 깨지므로 이 타입으로 감싼다. - */ -private data class TokenUsageSnackbarVisuals( - override val message: String, - val alert: TokenUsageAlert, -) : SnackbarVisuals { - override val actionLabel: String? = null - override val withDismissAction: Boolean = false - override val duration: SnackbarDuration = SnackbarDuration.Short -} - -/** - * 알림 종류별 GamssSnackBar 아이콘. LOW는 두 가지 색(원+체크)이 들어 있어 tint 하지 않고, - * EXHAUSTED(전부 소진)는 경고 색으로 강조해 심각도 차이를 보여준다. - */ -@Composable -private fun TokenUsageAlertIcon(alert: TokenUsageAlert?) { - when (alert) { - TokenUsageAlert.EXHAUSTED -> Icon( - painter = painterResource(GamssIcons.Alert), - contentDescription = null, - tint = GamssTheme.colors.red, - ) - TokenUsageAlert.LOW, null -> Icon( - painter = painterResource(GamssIcons.Alert), - contentDescription = null, - tint = GamssTheme.colors.yellow, - ) - } -} - @Composable private fun ChatRoomContent( state: ChatRoomState, actions: ChatRoomActions, - snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, onBackClick: () -> Unit, ) { @@ -313,69 +264,38 @@ private fun ChatRoomContent( // 상위 Scaffold 가 인셋을 이미 적용해, imePadding 을 그대로 쓰면 이중 적용된다. contentWindowInsets = WindowInsets(0), ) { innerPadding -> - Box( + Column( modifier = Modifier .fillMaxSize() .background(GamssTheme.colors.background) .padding(innerPadding) .windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)), ) { - var inputSectionHeight by remember { mutableStateOf(0.dp) } - val density = LocalDensity.current - - Column(modifier = Modifier.fillMaxSize()) { - ChatMessageList( - state = state, - actions = actions, - listState = listState, - animationState = messageAnimationState, - scrollState = chatScrollState, - showScrollToBottomButton = chatScrollState.showScrollToBottomButton && !isImeInTransition, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) - - ChatRoomInputSection( - endFlow = state.endFlow, - input = state.input, - isInputEnabled = !state.isLoading && state.endFlow == EndFlow.NotStarted, - isSending = state.isSending, - replyTarget = state.replyTarget, - actions = actions, - modifier = Modifier.onGloballyPositioned { coordinates -> - inputSectionHeight = with(density) { coordinates.size.height.toDp() } - }, - ) - } - - SnackbarHost( - snackbarHostState, - // 아래쪽은 입력창과의 간격을 여기 한 곳(TokenUsageSnackbarBottomGap)에서만 정확히 - // 10dp로 맞춘다 — GamssSnackBar 쪽에 별도 bottom padding을 더 두면 간격이 겹친다. + ChatMessageList( + state = state, + actions = actions, + listState = listState, + animationState = messageAnimationState, + scrollState = chatScrollState, + showScrollToBottomButton = chatScrollState.showScrollToBottomButton && !isImeInTransition, modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = inputSectionHeight + TokenUsageSnackbarBottomGap), - ) { data -> - val alert = (data.visuals as? TokenUsageSnackbarVisuals)?.alert - GamssSnackBar( - message = data.visuals.message, - snackBarIcon = { TokenUsageAlertIcon(alert) }, - modifier = Modifier.padding( - top = 12.dp, - start = 18.dp, - end = 18.dp, - bottom = 0.dp, - ), - ) - } + .weight(1f) + .fillMaxWidth(), + ) + + ChatRoomInputSection( + endFlow = state.endFlow, + input = state.input, + isInputEnabled = !state.isLoading && state.endFlow == EndFlow.NotStarted, + isSending = state.isSending, + isTokenExhausted = state.isTokenExhausted, + replyTarget = state.replyTarget, + actions = actions, + ) } } } -/** 토큰 안내 스낵바와 입력창 사이 간격. */ -private val TokenUsageSnackbarBottomGap: Dp = 10.dp - @Composable private fun ChatRoomTopBar( state: ChatRoomState, @@ -577,6 +497,7 @@ private fun ChatRoomInputSection( input: String, isInputEnabled: Boolean, isSending: Boolean, + isTokenExhausted: Boolean, replyTarget: ReplyTarget?, actions: ChatRoomActions, modifier: Modifier = Modifier, @@ -598,6 +519,7 @@ private fun ChatRoomInputSection( input = input, enabled = isInputEnabled, isSending = isSending, + isTokenExhausted = isTokenExhausted, replyTarget = replyTarget, onInputChange = actions.onInputChange, onSendClick = actions.onSendClick, @@ -681,7 +603,6 @@ private fun ChatRoomPreviewContent() { ChatRoomContent( state = state, actions = actions, - snackbarHostState = remember { SnackbarHostState() }, 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 09696bf0..0b5f929e 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 @@ -21,6 +21,8 @@ data class ChatRoomState( val tokenUsagePercent: Int? = null, val isTokenUsagePopupExpanded: Boolean = false, val isTokenUsageLoading: Boolean = false, + /** 서버가 확정한 소진 여부. true면 입력창의 전송 버튼을 잠근다. */ + val isTokenExhausted: 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 1445a07d..1ccb71f7 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 @@ -53,15 +53,26 @@ class ChatRoomViewModel @Inject constructor( loadChatEndFeatureFlag() loadMessages(conversationId) observeTokenUsageAlerts() + observeTokenExhausted() + // 방에 들어오자마자 소진 여부를 알아야 전송 버튼을 처음부터 올바르게 잠글 수 있다 — + // 이전에 이미 소진된 채로 재입장한 경우, 한 번도 안 보내봤어도 버튼이 바로 잠겨야 한다. + tokenUsageRefreshNotifier.requestRefresh() // 결과를 기다리지 않는다 — 채팅방에 들어온 시점부터 온디바이스 모델 다운로드를 미리 // 걸어둬 첫 메시지/카드 생성 시점엔 이미 받아져 있을 확률을 높이는 순수 최적화용 호출이다. viewModelScope.launch { session.prefetchOnDeviceModels() } } /** - * [tokenUsageRefreshNotifier]가 전역으로 흘려보내는 알림을 그대로 옮긴다. LOW(10% 미만 남음)는 - * 하루 1회만 오므로 여러 채팅방을 오가도(대화방을 나갔다 들어와도) 중복으로 뜨지 않지만, - * EXHAUSTED(전부 소진)는 보낼 때마다 소진 상태면 매번 온다. + * [tokenUsageRefreshNotifier]가 전역으로 흘려보내는 알림을 그대로 토스트로 옮긴다. LOW(10% + * 미만 남음)는 하루 1회만 오므로 여러 채팅방을 오가도(대화방을 나갔다 들어와도) 중복으로 뜨지 + * 않지만, EXHAUSTED(전부 소진)는 refresh()가 소진을 확인할 때마다(전송 성공, 채팅방 진입 등) + * 매번 온다. + * + * EXHAUSTED를 여기서만 다루는 이유: [CommentGenerationStatus.LIMIT_EXCEEDED]는 "이미 소진된 + * 상태에서 보냈다"는 신호라, 정작 이번 전송으로 막 소진된 순간(댓글 자체는 정상 생성되고 그 + * 직후 조회에서 exceeded=true로 확인되는 경우)에는 오지 않는다 — 전송 버튼이 소진 즉시 + * 잠기는 지금 구조에서는 그 순간을 놓치면 다시 보낼 방법이 없어 토스트가 영영 안 뜬다. + * 그래서 실측(exceeded)을 직접 보는 이 스트림을 유일한 소스로 쓴다(아래 [onSend] 참고). */ private fun observeTokenUsageAlerts() = intent { tokenUsageRefreshNotifier.alerts.collect { alert -> @@ -69,6 +80,12 @@ class ChatRoomViewModel @Inject constructor( } } + private fun observeTokenExhausted() = intent { + tokenUsageRefreshNotifier.isExhausted.collect { exhausted -> + reduce { state.copy(isTokenExhausted = exhausted) } + } + } + private fun loadChatEndFeatureFlag() = intent { val useChatEndFeature = getRemoteConfigFlag(RemoteConfigKey.UseChatEndFeature) reduce { state.copy(useChatEndFeature = useChatEndFeature) } @@ -235,7 +252,7 @@ class ChatRoomViewModel @Inject constructor( ) } launchCommentReveal() - sent.commentStatus.toUserMessage()?.let { postSideEffect(ChatRoomSideEffect.ShowToast(it)) } + sent.commentStatus.toSideEffect()?.let { postSideEffect(it) } session.finishSend() refreshTokenUsageInBackground() } @@ -356,11 +373,12 @@ class ChatRoomViewModel @Inject constructor( } } - // LIMIT_EXCEEDED는 토큰이 이미 소진됐을 때 나는 신호라, 아래에서 requestRefresh()가 트리거하는 - // tokenUsageRefreshNotifier의 EXHAUSTED 알림(GamssSnackBar)이 대신 안내한다 — 토스트를 따로 띄우지 않는다. - private fun CommentGenerationStatus.toUserMessage(): String? = when (this) { + // LIMIT_EXCEEDED는 토큰이 이미 소진됐을 때 나는 신호라, onSend() 성공 분기에서 이미 호출하는 + // requestRefresh()가 트리거하는 tokenUsageRefreshNotifier의 EXHAUSTED 알림 + // (observeTokenUsageAlerts 참고)이 대신 안내한다 — 토스트를 따로 띄우지 않는다. + private fun CommentGenerationStatus.toSideEffect(): ChatRoomSideEffect? = when (this) { CommentGenerationStatus.DONE, CommentGenerationStatus.LIMIT_EXCEEDED -> null - CommentGenerationStatus.FAILED -> "답장을 받지 못했어요. 잠시 후 다시 보내볼까요?" + CommentGenerationStatus.FAILED -> ChatRoomSideEffect.ShowToast("답장을 받지 못했어요. 잠시 후 다시 보내볼까요?") } private data class PendingSend( 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 14ab77e0..54976ecc 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 @@ -16,6 +16,7 @@ fun MessageInputBar( input: String, enabled: Boolean, isSending: Boolean, + isTokenExhausted: Boolean, replyTarget: ReplyTarget?, onInputChange: (String) -> Unit, onSendClick: () -> Unit, @@ -32,9 +33,9 @@ fun MessageInputBar( placeholder = stringResource(R.string.chat_room_input_placeholder), trailingContentDescription = "보내기", enabled = enabled, - // 전송 중에도 입력칸 포커스·키보드는 유지하되(enabled), 전송 버튼만 잠가 중복 전송을 - // 시각적으로도 막는다. - sendEnabled = enabled && !isSending, + // 전송 중에도, 토큰이 소진됐을 때도 입력칸 포커스·키보드는 유지하되(enabled), 전송 + // 버튼만 잠가 중복 전송/불가능한 전송을 시각적으로도 막는다. + sendEnabled = enabled && !isSending && !isTokenExhausted, maxLines = MESSAGE_INPUT_MAX_LINES, replyQuote = replyTarget?.let { ChatReplyQuote( diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml index 0033385f..332f75b3 100644 --- a/feature/chat/src/main/res/values/strings.xml +++ b/feature/chat/src/main/res/values/strings.xml @@ -14,8 +14,8 @@ 오전 5:00에 초기화됩니다 사용량을 불러오지 못했어요. 재시도 - 오늘 사용 가능한 토큰이 10% 남았어요. - 오늘 사용 가능한 토큰을 전부 사용했어요. + 오늘 사용 가능한 토큰이 10% 남았어요. + 오늘 사용 가능한 토큰을 전부 사용했어요. 입력 중 혼자 감당하지 않아도 괜찮아요. 대화에서 도움이 필요하다는 신호가 확인됐어요.\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 d6d7904c..aa233564 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 @@ -136,6 +136,7 @@ internal class FakeUserRepository( /** 갱신 요청 횟수만 센다. 알림 발생 자체를 검증하는 테스트는 별도 fake로 alerts를 채운다. */ internal class RecordingTokenUsageRefreshNotifier( override val alerts: Flow = emptyFlow(), + override val isExhausted: StateFlow = MutableStateFlow(false).asStateFlow(), ) : TokenUsageRefreshNotifier { var refreshCount = 0 private set From a9f92cdfefa63dcb9408cfd698c67d39a741b03b Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 21:08:36 +0900 Subject: [PATCH 14/24] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EC=86=8C?= =?UTF-8?q?=EC=A7=84=20=ED=86=A0=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=ED=95=98?= =?UTF-8?q?=EB=A3=A8=201=ED=9A=8C=EB=A1=9C=20=EC=A0=9C=ED=95=9C=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EC=86=8C=EC=A7=84=20=EC=8B=9C=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=EC=B0=BD=20=EC=9E=A0=EA=B8=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokenUsageRefreshNotifierImpl.kt | 18 ++++++++++++------ .../TokenUsageRefreshNotifierImplTest.kt | 8 +++----- .../repository/TokenUsageRefreshNotifier.kt | 13 +++++++------ .../android/feature/chat/ChatRoomViewModel.kt | 12 ++++++------ .../feature/chat/component/MessageInputBar.kt | 12 ++++++++---- feature/chat/src/main/res/values/strings.xml | 1 + 6 files changed, 37 insertions(+), 27 deletions(-) diff --git a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt index 2cd49c83..5de9bbd2 100644 --- a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt @@ -42,10 +42,12 @@ internal class TokenUsageRefreshNotifierImpl @Inject constructor( private val _alerts = MutableSharedFlow(extraBufferCapacity = 1) override val alerts: Flow = _alerts.asSharedFlow() - // 화면(ViewModel)을 나갔다 들어와도 안 사라지도록, "이미 알렸는지"는 프로세스가 사는 동안 - // 여기 싱글턴에만 기억한다. LOW는 하루 1회만 알리면 되지만, EXHAUSTED는 전송할 때마다(= - // refresh() 호출마다) 소진 상태면 매번 알려야 해서 별도 게이팅 플래그가 없다. + // 화면(ViewModel)을 나갔다 들어오거나 다른 채팅방에 진입해도 중복으로 안 뜨도록, "이미 + // 알렸는지"는 프로세스가 사는 동안 여기 싱글턴에만 기억한다. isExhausted는 이 플래그와 + // 무관하게 refresh() 때마다 항상 최신화된다 — 알림은 한 번만, 상태 반영(입력창 잠금)은 + // 매번. private var hasAlertedLow = false + private var hasAlertedExhausted = false init { applicationScope.launch { @@ -64,20 +66,24 @@ internal class TokenUsageRefreshNotifierImpl @Inject constructor( val percent = calculateTokenUsagePercent(usage.usedTokens, usage.dailyLimit) // 이전보다 사용량이 줄었거나(서버가 오전 5시에 리셋) 더 이상 소진 상태가 아니라면 - // 새 하루로 보고 LOW 알림 상태를 초기화한다 — 클라이언트가 리셋 시각을 직접 계산하지 않는다. + // 새 하루로 보고 알림 상태를 초기화한다 — 클라이언트가 리셋 시각을 직접 계산하지 않는다. val previousPercent = _usagePercent.value val isNewDay = (previousPercent != null && percent != null && percent < previousPercent) || (_isExhausted.value && !usage.exceeded) if (isNewDay) { hasAlertedLow = false + hasAlertedExhausted = false } _isExhausted.value = usage.exceeded _usagePercent.value = percent when { // exceeded는 서버가 내려주는 확정값이라 반올림 오차가 있는 percent >= 100 보다 정확하다. - // requestRefresh()가 불릴 때마다(채팅방 진입, 전송 성공 등) 소진 상태면 매번 알린다. - usage.exceeded -> { + // 알림은 하루 1회만 — 소진시킨 그 전송 시점에만 뜨고, 이후 다른 채팅방에 들어가거나 + // 같은 방에 재진입해 refresh()가 다시 돌아도 다시 뜨지 않는다. isExhausted 자체는 + // 이 게이팅과 무관하게 위에서 매번 최신화되므로 입력창 잠금은 계속 정확하다. + usage.exceeded && !hasAlertedExhausted -> { + hasAlertedExhausted = true hasAlertedLow = true _alerts.tryEmit(TokenUsageAlert.EXHAUSTED) } diff --git a/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt index a6b30437..88b3a194 100644 --- a/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt @@ -24,21 +24,19 @@ class TokenUsageRefreshNotifierImplTest { private val userRepository: UserRepository = mockk() @Test - fun `소진 상태면 요청할 때마다 EXHAUSTED를 매번 흘려보낸다`() = runTest { + fun `소진 상태면 같은 날엔 EXHAUSTED를 한 번만 흘려보낸다`() = runTest { coEvery { userRepository.getDailyTokenUsage() } returns AppResult.Success(usage(usedTokens = 100, dailyLimit = 100, exceeded = true)) val notifier = newNotifier() val received = collectAlerts(notifier) + // 소진시킨 전송 직후 1회 + 이후 다른 채팅방 진입 등으로 재조회되는 경우를 흉내낸다. repeat(3) { notifier.requestRefresh() advanceUntilIdle() } - assertEquals( - listOf(TokenUsageAlert.EXHAUSTED, TokenUsageAlert.EXHAUSTED, TokenUsageAlert.EXHAUSTED), - received, - ) + assertEquals(listOf(TokenUsageAlert.EXHAUSTED), received) } @Test diff --git a/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt b/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt index f045f58c..b87e82bf 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/repository/TokenUsageRefreshNotifier.kt @@ -7,10 +7,11 @@ import kotlinx.coroutines.flow.StateFlow * 토큰 사용량 새로고침 신호 버스 + 전역 사용량 캐시. * * [requestRefresh]로 재조회를 요청하면, 구현체가 내부에서 조회해 [usagePercent]/[isExhausted]를 - * 갱신하고 [alerts]로 흘려보낸다. LOW(10% 미만 남음)는 하루 1회만, EXHAUSTED(전부 소진)는 소진 - * 상태로 재조회될 때마다 매번 흘려보낸다 — 두 알림의 발행 정책이 다르다. 여러 채팅방을 오가도(대화방을 - * 나갔다 들어와도) LOW 알림이 중복으로 뜨지 않도록, "이미 알렸는지"는 화면(ViewModel) 로컬이 - * 아니라 이 싱글턴 구현체가 기억한다. + * 갱신하고 [alerts]로 흘려보낸다. LOW(10% 미만 남음)·EXHAUSTED(전부 소진) 모두 하루 1회만 + * 흘려보낸다 — 채팅방을 나갔다 들어오거나 다른 채팅방에 진입할 때마다 재조회가 일어나도 이미 + * 알린 알림은 중복으로 뜨지 않는다. [isExhausted]는 알림과 무관하게 조회될 때마다 항상 + * 최신값으로 갱신되므로, 화면은 이 값으로 입력창을 계속 잠글 수 있다. "이미 알렸는지"는 + * 화면(ViewModel) 로컬이 아니라 이 싱글턴 구현체가 기억한다. */ interface TokenUsageRefreshNotifier { val refreshEvents: Flow @@ -19,13 +20,13 @@ interface TokenUsageRefreshNotifier { val usagePercent: StateFlow /** - * 가장 최근 조회에서 서버가 확정한 소진 여부. 화면은 이 값으로 전송 버튼을 잠근다 — + * 가장 최근 조회에서 서버가 확정한 소진 여부. 화면은 이 값으로 입력창·전송 버튼을 잠근다 — * 반올림 오차가 있는 [usagePercent] >= 100 보다 정확하다. 아직 한 번도 조회되지 않았으면 * `false`(비소진으로 간주)다. */ val isExhausted: StateFlow - /** LOW는 하루 1회만, EXHAUSTED는 소진 상태가 감지될 때마다 매번 흘려보내는 알림. */ + /** LOW·EXHAUSTED 모두 하루 1회만 흘려보내는 알림. */ val alerts: Flow fun requestRefresh() 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 1ccb71f7..168d48d6 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 @@ -64,15 +64,15 @@ class ChatRoomViewModel @Inject constructor( /** * [tokenUsageRefreshNotifier]가 전역으로 흘려보내는 알림을 그대로 토스트로 옮긴다. LOW(10% - * 미만 남음)는 하루 1회만 오므로 여러 채팅방을 오가도(대화방을 나갔다 들어와도) 중복으로 뜨지 - * 않지만, EXHAUSTED(전부 소진)는 refresh()가 소진을 확인할 때마다(전송 성공, 채팅방 진입 등) - * 매번 온다. + * 미만 남음)·EXHAUSTED(전부 소진) 모두 하루 1회만 오므로, 여러 채팅방을 오가거나 같은 방에 + * 재진입해도 중복으로 뜨지 않는다 — 소진시킨 그 전송 시점에 딱 한 번만 보인다. * * EXHAUSTED를 여기서만 다루는 이유: [CommentGenerationStatus.LIMIT_EXCEEDED]는 "이미 소진된 * 상태에서 보냈다"는 신호라, 정작 이번 전송으로 막 소진된 순간(댓글 자체는 정상 생성되고 그 - * 직후 조회에서 exceeded=true로 확인되는 경우)에는 오지 않는다 — 전송 버튼이 소진 즉시 - * 잠기는 지금 구조에서는 그 순간을 놓치면 다시 보낼 방법이 없어 토스트가 영영 안 뜬다. - * 그래서 실측(exceeded)을 직접 보는 이 스트림을 유일한 소스로 쓴다(아래 [onSend] 참고). + * 직후 조회에서 exceeded=true로 확인되는 경우)에는 오지 않는다. 실측(exceeded)을 직접 보는 + * 이 스트림을 유일한 소스로 쓴다(아래 [onSend] 참고). 이후 다른 채팅방에 들어가 다시 + * `isTokenExhausted`가 반영되는 건 [observeTokenExhausted]가 맡고, 그건 알림이 아니라 + * 입력창 잠금용 상태라 몇 번이든 다시 반영돼도 된다. */ private fun observeTokenUsageAlerts() = intent { tokenUsageRefreshNotifier.alerts.collect { alert -> 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 54976ecc..3464dcfb 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 @@ -30,11 +30,15 @@ fun MessageInputBar( modifier = modifier .fillMaxWidth() .padding(horizontal = 18.dp, vertical = 12.dp), - placeholder = stringResource(R.string.chat_room_input_placeholder), + placeholder = if (isTokenExhausted) { + stringResource(R.string.chat_room_input_placeholder_exhausted) + } else { + stringResource(R.string.chat_room_input_placeholder) + }, trailingContentDescription = "보내기", - enabled = enabled, - // 전송 중에도, 토큰이 소진됐을 때도 입력칸 포커스·키보드는 유지하되(enabled), 전송 - // 버튼만 잠가 중복 전송/불가능한 전송을 시각적으로도 막는다. + // 토큰이 소진되면 다시 채울 방법이 없으니(다음 날까지) 입력칸 자체를 잠가 자리표시자 + // 문구로 안내한다. 전송 중일 때는 입력은 유지하고 전송 버튼만 잠근다. + enabled = enabled && !isTokenExhausted, sendEnabled = enabled && !isSending && !isTokenExhausted, maxLines = MESSAGE_INPUT_MAX_LINES, replyQuote = replyTarget?.let { diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml index 332f75b3..4c7bfd48 100644 --- a/feature/chat/src/main/res/values/strings.xml +++ b/feature/chat/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ 뒤로가기 감정 확인하기 메시지 입력 + 토큰이 모두 소모되어 메시지를 전송할 수 없습니다 %1$s에게 답장 나에게 답장 답장 From a68e7a8285b8563cb01c0b057ffc7511ee1a8cf9 Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 21:12:40 +0900 Subject: [PATCH 15/24] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EC=86=8C?= =?UTF-8?q?=EC=A7=84=20=EC=8B=9C=20canSend=EC=97=90=EC=84=9C=EB=8F=84=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=EC=9D=84=20=EB=A7=89=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gamss/android/feature/chat/ChatRoomState.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 0b5f929e..e92fb2cd 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 @@ -24,9 +24,16 @@ data class ChatRoomState( /** 서버가 확정한 소진 여부. true면 입력창의 전송 버튼을 잠근다. */ val isTokenExhausted: Boolean = false, ) { - /** 종료 흐름이 시작된 뒤로는 막는다. 종료 API 가 도는 중에 보내면 저장 여부가 갈린다. */ + /** + * 종료 흐름이 시작된 뒤로는 막는다. 종료 API 가 도는 중에 보내면 저장 여부가 갈린다. + * + * isTokenExhausted 도 여기서 막아야 한다 — 전송 버튼 비활성화는 UI 레이어일 뿐이라, 물리 + * 키보드 엔터 등 버튼을 거치지 않는 경로로 onSend() 가 불려도 이미 소진된 걸 알면서 서버에 + * 요청을 보내면 안 된다. + */ val canSend: Boolean - get() = input.isNotBlank() && !isSending && !isLoading && endFlow == EndFlow.NotStarted + get() = input.isNotBlank() && !isSending && !isLoading && !isTokenExhausted && + endFlow == EndFlow.NotStarted // isSending(내 전송이 서버 왕복 중)은 여기 포함하지 않는다 — "입력중" 버블은 상대가 답장을 // 준비 중일 때만 보여야 하고, 내 메시지 전송 중이라는 것과는 다른 신호다. 전송 중 UI 피드백은 From 9ac7f8028012edae362a52e3555a2e89b9a3e45c Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 21:13:38 +0900 Subject: [PATCH 16/24] =?UTF-8?q?chore:=20detekt=20=EC=A0=95=EB=A0=AC=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 --- .../com/gamss/android/core/designsystem/modifier/ClearFocus.kt | 3 +-- .../gamss/android/feature/chat/component/ConversationCard.kt | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/ClearFocus.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/ClearFocus.kt index 35ff5b9a..afb1e5c3 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/ClearFocus.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/ClearFocus.kt @@ -1,5 +1,4 @@ package com.gamss.android.core.designsystem.modifier - import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusManager @@ -11,4 +10,4 @@ fun Modifier.addFocusCleaner(focusManager: FocusManager): Modifier { focusManager.clearFocus() }) } -} \ No newline at end of file +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ConversationCard.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ConversationCard.kt index 43c9145b..1da275d2 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ConversationCard.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ConversationCard.kt @@ -55,7 +55,6 @@ internal fun ConversationCard( onLongClick: () -> Unit, modifier: Modifier = Modifier, ) { - Box( modifier = modifier .fillMaxWidth() @@ -69,7 +68,7 @@ internal fun ConversationCard( painter = painterResource(R.drawable.bg_textfield_default), contentDescription = null, contentScale = ContentScale.FillBounds, - colorFilter = ColorFilter.tint( GamssTheme.colors.gray950), + colorFilter = ColorFilter.tint(GamssTheme.colors.gray950), ) // SpaceBetween 은 쓸 수 없다. 제목이 짧으면 남는 폭이 자식 사이로 흩어져, 시각이 없는 // 행에서 제목이 오른쪽 끝으로 밀려난다. From 8d340dbc327c0112c82facfc2b8344711bea879c Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 21:30:13 +0900 Subject: [PATCH 17/24] =?UTF-8?q?fix:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=8B=9C=20=EC=84=9C=EB=B2=84=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EA=B8=B0=EB=8B=A4=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B3=A0=20=EC=9E=85=EB=A0=A5=EC=B9=B8=EC=9D=84=20?= =?UTF-8?q?=EC=A6=89=EC=8B=9C=20=EB=B9=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/feature/chat/ChatRoomViewModel.kt | 15 ++++++-- .../feature/chat/ChatRoomRevealTest.kt | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 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 168d48d6..ffbea961 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 @@ -207,7 +207,9 @@ class ChatRoomViewModel @Inject constructor( } else { null } - if (pending == null) state else state.copy(isSending = true) + // 서버 왕복이 끝날 때까지 기다리지 않고 버튼을 누른 즉시 입력칸을 비운다. 아래 + // 위험 신호 차단·전송 실패 분기에서 되돌리지 않는 한 이 상태로 남는다. + if (pending == null) state else state.copy(isSending = true, input = "") } val sending = pending ?: return@intent @@ -223,6 +225,8 @@ class ChatRoomViewModel @Inject constructor( } else { state.isSending }, + // 방금 비운 입력칸을 되돌린다. 그 사이 사용자가 새로 타이핑했다면 덮어쓰지 않는다. + input = if (detection.shouldBlock && state.input.isEmpty()) sending.content else state.input, ) } @@ -247,7 +251,6 @@ class ChatRoomViewModel @Inject constructor( conversationId = sent.message.conversationId, messages = state.messages + sent.message, pendingComments = sent.comments, - input = if (state.input == sending.content) "" else state.input, replyTarget = state.replyTarget.takeIf { it?.messageId != sending.replyToMessageId }, ) } @@ -257,7 +260,13 @@ class ChatRoomViewModel @Inject constructor( refreshTokenUsageInBackground() } is AppResult.Failure -> { - reduce { state.copy(isSending = false) } + reduce { + state.copy( + isSending = false, + // 방금 비운 입력칸을 되돌린다. 그 사이 사용자가 새로 타이핑했다면 덮어쓰지 않는다. + input = if (state.input.isEmpty()) sending.content else state.input, + ) + } postSideEffect(ChatRoomSideEffect.ShowToast(SEND_FAILED)) } } 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 4b31ab6f..af07f0e0 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 @@ -202,6 +202,44 @@ class ChatRoomRevealTest { } } + @Test + fun 전송_버튼을_누르면_응답을_기다리지_않고_입력칸이_바로_비워진다() = runTest { + val viewModel = viewModel(commentCount = 1) + + viewModel.test(this) { + containerHost.onInputChange(INPUT) + skipItems(1) // input 반영 + containerHost.onSend() + + val afterSendPressed = awaitState() + assertTrue(afterSendPressed.isSending) + assertEquals("", afterSendPressed.input) + + cancelAndIgnoreRemainingItems() + } + } + + @Test + fun 전송이_실패하면_입력칸에_보낸_내용이_복구된다() = runTest { + val repository = FakeConversationRepository(commentCount = 1, failing = true) + val viewModel = viewModel(repository) + + viewModel.test(this) { + containerHost.onInputChange(INPUT) + awaitState() + containerHost.onSend() + + val afterSendPressed = awaitState() + assertEquals("", afterSendPressed.input) + + val afterFailure = awaitState() + expectSideEffect(ChatRoomSideEffect.ShowToast(SEND_FAILED_MESSAGE)) + + assertFalse(afterFailure.isSending) + assertEquals(INPUT, afterFailure.input) + } + } + @Test fun 전송이_실패하면_토큰_사용량_갱신을_요청하지_않는다() = runTest { val notifier = RecordingTokenUsageRefreshNotifier() From 99408ae08cfe0512d39b7fff79821cf301a764ed Mon Sep 17 00:00:00 2001 From: seunghee Date: Thu, 20 Aug 2026 21:45:27 +0900 Subject: [PATCH 18/24] =?UTF-8?q?fix:=20=EC=95=84=EC=9D=B4=EC=BD=98?= =?UTF-8?q?=EB=AA=85=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gamss/android/core/designsystem/snackbar/GamssSnackBar.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt index 90155fcf..3e4024f8 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt @@ -100,7 +100,7 @@ private fun GamssSnackBarPreviewContent() { message = "이것은 아이콘이 있는 스낵바입니다.\n스낵바는 한 줄로 사용하기를 권장합니다.", snackBarIcon = { Icon( - painter = painterResource(GamssIcons.Alert), + painter = painterResource(GamssIcons.InfoFilled), contentDescription = null, tint = GamssTheme.colors.yellow, ) From 4a4252640d0e4e55ede2ac058e3735ce50c23136 Mon Sep 17 00:00:00 2001 From: seunghee Date: Fri, 21 Aug 2026 18:08:11 +0900 Subject: [PATCH 19/24] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gamss/android/feature/chat/ChatRoomViewModel.kt | 5 ----- 1 file changed, 5 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 e6b05975..e5e8e27f 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 @@ -82,11 +82,6 @@ class ChatRoomViewModel @Inject constructor( } } - private fun loadChatEndFeatureFlag() = intent { - val useChatEndFeature = getRemoteConfigFlag(RemoteConfigKey.UseChatEndFeature) - reduce { state.copy(useChatEndFeature = useChatEndFeature) } - } - private fun loadMessages(conversationId: Long) = intent { reduce { state.copy(conversationId = conversationId, isLoading = true) } From abb9bfd43ad48db0e3b2415e27fcbf023bbfd76f Mon Sep 17 00:00:00 2001 From: seunghee Date: Fri, 21 Aug 2026 18:19:12 +0900 Subject: [PATCH 20/24] =?UTF-8?q?fix:=20IME=20=EC=9D=B8=EC=85=8B=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=9D=B4=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=8A=A4=ED=81=AC=EB=A1=A4=EC=97=90=20?= =?UTF-8?q?=EB=AE=A4=ED=85=8D=EC=8A=A4=EB=A5=BC=20=EB=BA=8F=EA=B2=A8?= =?UTF-8?q?=EB=8F=84=20=EB=A9=88=EC=B6=94=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 --- .../com/gamss/android/feature/chat/ChatRoomScreen.kt | 12 +++++++++--- 1 file changed, 9 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 5760f0d9..ff0dd9de 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 @@ -39,7 +39,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -52,7 +51,6 @@ import com.gamss.android.core.common.util.formatConversationDate import com.gamss.android.core.designsystem.component.GamssScrollToBottomButton import com.gamss.android.core.designsystem.component.GamssTokenUsageTooltip import com.gamss.android.core.designsystem.modifier.addFocusCleaner -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 @@ -78,9 +76,12 @@ 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.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect +import kotlin.coroutines.cancellation.CancellationException /** * @param onCardClose 카드 시트를 닫을 때 호출한다. 이 화면을 실제로 벗어나야 한다. @@ -211,6 +212,7 @@ private fun ChatRoomContent( onBackClick: () -> Unit, ) { val listState = rememberLazyListState() + val focusManager = LocalFocusManager.current val messageAnimationState = rememberChatMessageAnimationState( conversationId = state.conversationId, isLoading = state.isLoading, @@ -234,7 +236,11 @@ private fun ChatRoomContent( val delta = current - previous previous = current if (delta != 0) { - listState.scrollBy(delta.toFloat()) + try { + listState.scrollBy(delta.toFloat()) + } catch (_: CancellationException) { + currentCoroutineContext().ensureActive() + } } } } From b9c9540418ecdbe02f4de58372ec89dcd862b821 Mon Sep 17 00:00:00 2001 From: seunghee Date: Fri, 21 Aug 2026 18:23:17 +0900 Subject: [PATCH 21/24] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EC=86=8C?= =?UTF-8?q?=EC=A7=84=EC=9C=BC=EB=A1=9C=20=EC=9E=85=EB=A0=A5=EC=B0=BD?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=A0=EA=B8=B8=20=EB=95=8C=20=EB=82=A8=EC=95=84?= =?UTF-8?q?=EC=9E=88=EB=8D=98=20=EC=9E=85=EB=A0=A5=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=EC=9D=84=20=EB=B9=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/gamss/android/feature/chat/ChatRoomViewModel.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 e5e8e27f..4cc8ab82 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,12 @@ class ChatRoomViewModel @Inject constructor( private fun observeTokenExhausted() = intent { tokenUsageRefreshNotifier.isExhausted.collect { exhausted -> - reduce { state.copy(isTokenExhausted = exhausted) } + reduce { + state.copy( + isTokenExhausted = exhausted, + input = if (exhausted) "" else state.input, + ) + } } } From ee4162e8369eee5fffde90d8924224049a854779 Mon Sep 17 00:00:00 2001 From: seunghee Date: Fri, 21 Aug 2026 19:27:59 +0900 Subject: [PATCH 22/24] =?UTF-8?q?fix:=20=ED=86=A0=ED=81=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=20=EC=95=8C=EB=A6=BC=EC=9D=B4=20=EC=86=8C?= =?UTF-8?q?=EC=A7=84=20=EC=8B=9C=EC=A0=90=20=ED=99=94=EB=A9=B4=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=A7=8C,=20=EC=A0=95=ED=99=95=ED=9E=88=20=ED=95=9C?= =?UTF-8?q?=20=EB=B2=88=EB=A7=8C=20=EB=9C=A8=EA=B2=8C=20=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokenUsageRefreshNotifierImpl.kt | 15 ++++++--- .../TokenUsageRefreshNotifierImplTest.kt | 3 +- feature/chat/build.gradle.kts | 3 ++ .../android/feature/chat/ChatRoomScreen.kt | 10 ++++++ .../android/feature/chat/ChatRoomViewModel.kt | 33 +++++++++++-------- .../android/feature/chat/ChatRoomTestFakes.kt | 1 + 6 files changed, 46 insertions(+), 19 deletions(-) diff --git a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt index 5de9bbd2..b5b8f344 100644 --- a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt @@ -8,12 +8,14 @@ import com.gamss.android.domain.repository.TokenUsageRefreshNotifier import com.gamss.android.domain.user.UserRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -38,9 +40,12 @@ internal class TokenUsageRefreshNotifierImpl @Inject constructor( private val _isExhausted = MutableStateFlow(false) override val isExhausted: StateFlow = _isExhausted.asStateFlow() - /** 구독이 늦게 시작돼도 막 emit된 알림 하나는 놓치지 않도록 버퍼 1개만 둔다. */ - private val _alerts = MutableSharedFlow(extraBufferCapacity = 1) - override val alerts: Flow = _alerts.asSharedFlow() + /** 알림은 하루/타입당 정확히 1번만 소비돼야 해서, 재생되는 Channel을 쓴다. */ + private val _alerts = Channel( + capacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val alerts: Flow = _alerts.receiveAsFlow() // 화면(ViewModel)을 나갔다 들어오거나 다른 채팅방에 진입해도 중복으로 안 뜨도록, "이미 // 알렸는지"는 프로세스가 사는 동안 여기 싱글턴에만 기억한다. isExhausted는 이 플래그와 @@ -85,11 +90,11 @@ internal class TokenUsageRefreshNotifierImpl @Inject constructor( usage.exceeded && !hasAlertedExhausted -> { hasAlertedExhausted = true hasAlertedLow = true - _alerts.tryEmit(TokenUsageAlert.EXHAUSTED) + _alerts.trySend(TokenUsageAlert.EXHAUSTED) } percent != null && percent >= LOW_USAGE_THRESHOLD_PERCENT && !hasAlertedLow -> { hasAlertedLow = true - _alerts.tryEmit(TokenUsageAlert.LOW) + _alerts.trySend(TokenUsageAlert.LOW) } } } diff --git a/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt b/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt index 88b3a194..f0b52a6f 100644 --- a/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt +++ b/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt @@ -111,7 +111,8 @@ class TokenUsageRefreshNotifierImplTest { TokenUsageRefreshNotifierImpl(userRepository, CoroutineScope(UnconfinedTestDispatcher(testScheduler))) /** - * [notifier.alerts]는 replay 없는 SharedFlow라, 구독이 emit보다 늦으면 그 알림을 영영 놓친다. + * [notifier.alerts]는 Channel 기반이라, 값이 정확히 한 번만 그 순간 receive() 중인 구독자에게 + * 간다 — 구독이 emit보다 늦으면(그리고 그 사이 다른 구독자가 먼저 받아가면) 놓친다. * 구독을 `UnconfinedTestDispatcher`로 돌려 subscribe든 emit 이후의 재개든 모두 즉시(같은 * 스레드에서) 일어나게 한다 — `backgroundScope`의 기본 `StandardTestDispatcher`로 구독을 * 걸면, 최초 구독(UNDISPATCHED 덕에 즉시 실행)까지는 되지만 emit으로 깨어난 뒤의 "재개"는 diff --git a/feature/chat/build.gradle.kts b/feature/chat/build.gradle.kts index f1343e06..543e0867 100644 --- a/feature/chat/build.gradle.kts +++ b/feature/chat/build.gradle.kts @@ -16,6 +16,9 @@ dependencies { // 선택 모드를 뒤로가기로 빠져나가기 위한 BackHandler. compose-ui 에는 없는 API 다. implementation(libs.androidx.activity.compose) + // 토큰 소진 알림 구독을 화면이 RESUMED일 때만 걸기 위한 LifecycleEventEffect. + implementation(libs.androidx.lifecycle.runtime.compose) + // orbit-test 가 전이로 가져오지만, 직접 쓰는 API 라 명시한다. testImplementation(libs.kotlinx.coroutines.test) } 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 ff0dd9de..cba6280c 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 @@ -47,6 +47,7 @@ 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 androidx.lifecycle.compose.LifecycleResumeEffect import com.gamss.android.core.common.util.formatConversationDate import com.gamss.android.core.designsystem.component.GamssScrollToBottomButton import com.gamss.android.core.designsystem.component.GamssTokenUsageTooltip @@ -100,6 +101,15 @@ fun ChatRoomScreen( LaunchedEffect(conversationId) { viewModel.start(conversationId) } + // 토큰 알림은 화면이 보이는 동안만 구독해야 다른 채팅방이 가로채지 않는다. Nav3의 + // SinglePaneSceneStrategy는 다른 방으로 넘어가면 이 컴포저블을 ON_PAUSE 이벤트 없이 그냥 + // 컴포지션에서 제거한다 — LifecycleEventEffect(ON_PAUSE)는 이 경우 못 잡아서(dispose 시 + // 콜백을 안 부른다), dispose 시에도 정리 콜백이 보장되는 LifecycleResumeEffect를 쓴다. + LifecycleResumeEffect(conversationId) { + viewModel.onScreenResumed() + onPauseOrDispose { viewModel.onScreenPaused() } + } + val tokenUsageLowMessage = stringResource(R.string.chat_room_token_usage_low_toast) val tokenUsageExhaustedMessage = stringResource(R.string.chat_room_token_usage_exhausted_toast) 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 4cc8ab82..017eee10 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 @@ -44,11 +44,13 @@ class ChatRoomViewModel @Inject constructor( @Volatile private var revealJob: Job? = null + @Volatile + private var tokenAlertJob: Job? = null + fun start(conversationId: Long) { if (started) return started = true loadMessages(conversationId) - observeTokenUsageAlerts() observeTokenExhausted() // 방에 들어오자마자 소진 여부를 알아야 전송 버튼을 처음부터 올바르게 잠글 수 있다 — // 이전에 이미 소진된 채로 재입장한 경우, 한 번도 안 보내봤어도 버튼이 바로 잠겨야 한다. @@ -59,23 +61,28 @@ class ChatRoomViewModel @Inject constructor( } /** - * [tokenUsageRefreshNotifier]가 전역으로 흘려보내는 알림을 그대로 토스트로 옮긴다. LOW(10% - * 미만 남음)·EXHAUSTED(전부 소진) 모두 하루 1회만 오므로, 여러 채팅방을 오가거나 같은 방에 - * 재진입해도 중복으로 뜨지 않는다 — 소진시킨 그 전송 시점에 딱 한 번만 보인다. + * [tokenUsageRefreshNotifier]의 알림(LOW/EXHAUSTED)을 토스트로 옮긴다. EXHAUSTED를 여기서 + * 다루는 이유: [CommentGenerationStatus.LIMIT_EXCEEDED]는 "이미 소진된 채로 보냈다"는 + * 신호라 막 소진된 순간을 놓친다 — 실측(exceeded)을 보는 이 스트림이 유일한 소스다(아래 + * [onSend] 참고). * - * EXHAUSTED를 여기서만 다루는 이유: [CommentGenerationStatus.LIMIT_EXCEEDED]는 "이미 소진된 - * 상태에서 보냈다"는 신호라, 정작 이번 전송으로 막 소진된 순간(댓글 자체는 정상 생성되고 그 - * 직후 조회에서 exceeded=true로 확인되는 경우)에는 오지 않는다. 실측(exceeded)을 직접 보는 - * 이 스트림을 유일한 소스로 쓴다(아래 [onSend] 참고). 이후 다른 채팅방에 들어가 다시 - * `isTokenExhausted`가 반영되는 건 [observeTokenExhausted]가 맡고, 그건 알림이 아니라 - * 입력창 잠금용 상태라 몇 번이든 다시 반영돼도 된다. + * alerts는 Channel이라 값을 그 순간 receive() 중인 구독자 한 곳에만 준다. 백스택에 남은 + * 다른 채팅방이 먼저 가로채지 않도록, [start]가 아니라 화면이 RESUMED일 때만 구독한다. */ - private fun observeTokenUsageAlerts() = intent { - tokenUsageRefreshNotifier.alerts.collect { alert -> - postSideEffect(ChatRoomSideEffect.ShowTokenUsageAlert(alert)) + fun onScreenResumed() { + if (tokenAlertJob?.isActive == true) return + tokenAlertJob = intent { + tokenUsageRefreshNotifier.alerts.collect { alert -> + postSideEffect(ChatRoomSideEffect.ShowTokenUsageAlert(alert)) + } } } + fun onScreenPaused() { + tokenAlertJob?.cancel() + tokenAlertJob = null + } + private fun observeTokenExhausted() = intent { tokenUsageRefreshNotifier.isExhausted.collect { exhausted -> reduce { 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 7e7f5e28..210ab29a 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 @@ -28,6 +28,7 @@ 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.TokenUsageAlert import com.gamss.android.domain.repository.TokenUsageRefreshNotifier import com.gamss.android.domain.safety.DetectRiskInTextUseCase import com.gamss.android.domain.safety.RiskLexicon From 6895469916fe9532a980429d803a7e44144a6611 Mon Sep 17 00:00:00 2001 From: seunghee Date: Fri, 21 Aug 2026 19:31:55 +0900 Subject: [PATCH 23/24] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=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 --- .../android/data/repository/TokenUsageRefreshNotifierImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt index b5b8f344..715caf67 100644 --- a/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImpl.kt @@ -40,7 +40,7 @@ internal class TokenUsageRefreshNotifierImpl @Inject constructor( private val _isExhausted = MutableStateFlow(false) override val isExhausted: StateFlow = _isExhausted.asStateFlow() - /** 알림은 하루/타입당 정확히 1번만 소비돼야 해서, 재생되는 Channel을 쓴다. */ + /** 알림은 하루/타입당 정확히 1번만 소비돼야 해서, 재생되지 않는 Channel을 쓴다. */ private val _alerts = Channel( capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST, From dd18b3f4fd455bf5f32e36a48d05b554d3395ad4 Mon Sep 17 00:00:00 2001 From: seunghee Date: Fri, 21 Aug 2026 19:45:02 +0900 Subject: [PATCH 24/24] =?UTF-8?q?fix:=20ChatRoomScreen=20import=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=EB=A5=BC=20detekt=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=A0=95=EB=A0=AC=ED=95=A9?= =?UTF-8?q?=EB=8B=88=EB=8B=A4?= 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 79d6fd40..8adf0f5d 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 @@ -62,8 +62,8 @@ import com.gamss.android.core.ui.chat.rememberReplyQuoteLookup 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.CardFoldOverlay import com.gamss.android.domain.repository.TokenUsageAlert +import com.gamss.android.feature.chat.component.CardFoldOverlay import com.gamss.android.feature.chat.component.EndConversationDialog import com.gamss.android.feature.chat.component.LoadingMessageBubble import com.gamss.android.feature.chat.component.MessageInputBar @@ -80,8 +80,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect -import kotlin.coroutines.cancellation.CancellationException import java.time.LocalDate +import kotlin.coroutines.cancellation.CancellationException /** * 두 콜백 모두 이 화면을 실제로 벗어나야 한다. 머무르면 카드 단계가 그대로라 접기 연출이 다시 열린다.