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 a9d9836a..338d5ec6 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 @@ -189,7 +189,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 -> @@ -217,20 +217,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/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, 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..afb1e5c3 --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/modifier/ClearFocus.kt @@ -0,0 +1,13 @@ +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() + }) + } +} 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..3e4024f8 --- /dev/null +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/snackbar/GamssSnackBar.kt @@ -0,0 +1,114 @@ +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 +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, + snackBarIcon: (@Composable () -> Unit)? = 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 (snackBarIcon != null) { + Box( + modifier = Modifier.size(SnackBarIconSize), + contentAlignment = Alignment.Center, + ) { + snackBarIcon() + } + } + 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스낵바는 한 줄로 사용하기를 권장합니다.", + snackBarIcon = { + Icon( + painter = painterResource(GamssIcons.InfoFilled), + contentDescription = null, + tint = GamssTheme.colors.yellow, + ) + }, + ) + // 아이콘이 없는 스낵바. + GamssSnackBar( + message = "이것은 아이콘이 없는 스낵바입니다.", + ) + } +} 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/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..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 @@ -1,15 +1,30 @@ 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.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 @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 +34,72 @@ internal class TokenUsageRefreshNotifierImpl @Inject constructor() : TokenUsageR override val refreshEvents: Flow = events.asSharedFlow() + private val _usagePercent = MutableStateFlow(null) + override val usagePercent: StateFlow = _usagePercent.asStateFlow() + + private val _isExhausted = MutableStateFlow(false) + override val isExhausted: StateFlow = _isExhausted.asStateFlow() + + /** 알림은 하루/타입당 정확히 1번만 소비돼야 해서, 재생되지 않는 Channel을 쓴다. */ + private val _alerts = Channel( + capacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val alerts: Flow = _alerts.receiveAsFlow() + + // 화면(ViewModel)을 나갔다 들어오거나 다른 채팅방에 진입해도 중복으로 안 뜨도록, "이미 + // 알렸는지"는 프로세스가 사는 동안 여기 싱글턴에만 기억한다. isExhausted는 이 플래그와 + // 무관하게 refresh() 때마다 항상 최신화된다 — 알림은 한 번만, 상태 반영(입력창 잠금)은 + // 매번. + 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) || + (_isExhausted.value && !usage.exceeded) + if (isNewDay) { + hasAlertedLow = false + hasAlertedExhausted = false + } + _isExhausted.value = usage.exceeded + _usagePercent.value = percent + + when { + // exceeded는 서버가 내려주는 확정값이라 반올림 오차가 있는 percent >= 100 보다 정확하다. + // 알림은 하루 1회만 — 소진시킨 그 전송 시점에만 뜨고, 이후 다른 채팅방에 들어가거나 + // 같은 방에 재진입해 refresh()가 다시 돌아도 다시 뜨지 않는다. isExhausted 자체는 + // 이 게이팅과 무관하게 위에서 매번 최신화되므로 입력창 잠금은 계속 정확하다. + usage.exceeded && !hasAlertedExhausted -> { + hasAlertedExhausted = true + hasAlertedLow = true + _alerts.trySend(TokenUsageAlert.EXHAUSTED) + } + percent != null && percent >= LOW_USAGE_THRESHOLD_PERCENT && !hasAlertedLow -> { + hasAlertedLow = true + _alerts.trySend(TokenUsageAlert.LOW) + } + } + } + + private companion object { + const val LOW_USAGE_THRESHOLD_PERCENT = 90 + } } 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..f0b52a6f --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/repository/TokenUsageRefreshNotifierImplTest.kt @@ -0,0 +1,137 @@ +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.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 +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 = newNotifier() + val received = collectAlerts(notifier) + + // 소진시킨 전송 직후 1회 + 이후 다른 채팅방 진입 등으로 재조회되는 경우를 흉내낸다. + repeat(3) { + notifier.requestRefresh() + advanceUntilIdle() + } + + assertEquals(listOf(TokenUsageAlert.EXHAUSTED), received) + } + + @Test + fun `10퍼센트 미만 남음은 같은 날엔 한 번만 흘려보낸다`() = runTest { + coEvery { userRepository.getDailyTokenUsage() } returns + AppResult.Success(usage(usedTokens = 95, dailyLimit = 100, exceeded = false)) + val notifier = newNotifier() + val received = collectAlerts(notifier) + + repeat(3) { + notifier.requestRefresh() + advanceUntilIdle() + } + + assertEquals(listOf(TokenUsageAlert.LOW), received) + } + + @Test + fun `소진 이후 사용량이 리셋되면 10퍼센트 미만 남음 알림이 다시 발행된다`() = runTest { + val notifier = newNotifier() + 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) + } + + @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]는 Channel 기반이라, 값이 정확히 한 번만 그 순간 receive() 중인 구독자에게 + * 간다 — 구독이 emit보다 늦으면(그리고 그 사이 다른 구독자가 먼저 받아가면) 놓친다. + * 구독을 `UnconfinedTestDispatcher`로 돌려 subscribe든 emit 이후의 재개든 모두 즉시(같은 + * 스레드에서) 일어나게 한다 — `backgroundScope`의 기본 `StandardTestDispatcher`로 구독을 + * 걸면, 최초 구독(UNDISPATCHED 덕에 즉시 실행)까지는 되지만 emit으로 깨어난 뒤의 "재개"는 + * 다시 그 디스패처의 큐를 타 버려 `advanceUntilIdle()`로도 실행되지 않는 경우가 있었다. + * 취소는 여전히 `backgroundScope`의 Job을 부모로 물려받아 테스트 종료 시 자동으로 된다. + */ + private fun TestScope.collectAlerts( + notifier: TokenUsageRefreshNotifierImpl, + ): MutableList { + val received = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler), start = CoroutineStart.UNDISPATCHED) { + notifier.alerts.toList(received) + } + 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 f8a40797..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 @@ -1,9 +1,41 @@ package com.gamss.android.domain.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +/** + * 토큰 사용량 새로고침 신호 버스 + 전역 사용량 캐시. + * + * [requestRefresh]로 재조회를 요청하면, 구현체가 내부에서 조회해 [usagePercent]/[isExhausted]를 + * 갱신하고 [alerts]로 흘려보낸다. LOW(10% 미만 남음)·EXHAUSTED(전부 소진) 모두 하루 1회만 + * 흘려보낸다 — 채팅방을 나갔다 들어오거나 다른 채팅방에 진입할 때마다 재조회가 일어나도 이미 + * 알린 알림은 중복으로 뜨지 않는다. [isExhausted]는 알림과 무관하게 조회될 때마다 항상 + * 최신값으로 갱신되므로, 화면은 이 값으로 입력창을 계속 잠글 수 있다. "이미 알렸는지"는 + * 화면(ViewModel) 로컬이 아니라 이 싱글턴 구현체가 기억한다. + */ interface TokenUsageRefreshNotifier { val refreshEvents: Flow + /** 가장 최근에 조회된 사용률(0~100). 아직 한 번도 조회되지 않았으면 null. */ + val usagePercent: StateFlow + + /** + * 가장 최근 조회에서 서버가 확정한 소진 여부. 화면은 이 값으로 입력창·전송 버튼을 잠근다 — + * 반올림 오차가 있는 [usagePercent] >= 100 보다 정확하다. 아직 한 번도 조회되지 않았으면 + * `false`(비소진으로 간주)다. + */ + val isExhausted: StateFlow + + /** LOW·EXHAUSTED 모두 하루 1회만 흘려보내는 알림. */ + val alerts: Flow + fun requestRefresh() } + +enum class TokenUsageAlert { + /** 오늘 사용 가능한 토큰이 10% 미만 남음. */ + LOW, + + /** 오늘 사용 가능한 토큰을 전부 사용함. */ + EXHAUSTED, +} 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 9393ae14..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 @@ -28,14 +28,15 @@ 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.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -44,9 +45,11 @@ 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 +import com.gamss.android.core.designsystem.modifier.addFocusCleaner import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationHeight @@ -59,6 +62,7 @@ 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.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 @@ -71,10 +75,13 @@ 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 java.time.LocalDate +import kotlin.coroutines.cancellation.CancellationException /** * 두 콜백 모두 이 화면을 실제로 벗어나야 한다. 머무르면 카드 단계가 그대로라 접기 연출이 다시 열린다. @@ -98,10 +105,30 @@ 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) + 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 + } + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } } } @@ -196,6 +223,7 @@ private fun ChatRoomContent( onBackClick: () -> Unit, ) { val listState = rememberLazyListState() + val focusManager = LocalFocusManager.current val messageAnimationState = rememberChatMessageAnimationState( conversationId = state.conversationId, isLoading = state.isLoading, @@ -203,13 +231,25 @@ 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(listState, imeInsets, imeDensity) { + var previous = imeInsets.getBottom(imeDensity) + snapshotFlow { imeInsets.getBottom(imeDensity) }.collect { current -> + val delta = current - previous + previous = current + if (delta != 0) { + try { + listState.scrollBy(delta.toFloat()) + } catch (_: CancellationException) { + currentCoroutineContext().ensureActive() + } + } } } @@ -226,7 +266,7 @@ private fun ChatRoomContent( } Scaffold( - modifier = modifier, + modifier = modifier.addFocusCleaner(focusManager), topBar = { ChatRoomTopBar(state = state, actions = actions, onBackClick = onBackClick) }, // 상위 Scaffold 가 인셋을 이미 적용해, imePadding 을 그대로 쓰면 이중 적용된다. contentWindowInsets = WindowInsets(0), @@ -255,6 +295,7 @@ private fun ChatRoomContent( input = state.input, isInputEnabled = !state.isLoading && state.endFlow == EndFlow.NotStarted, isSending = state.isSending, + isTokenExhausted = state.isTokenExhausted, replyTarget = state.replyTarget, actions = actions, ) @@ -434,8 +475,10 @@ private fun ChatRoomInputSection( input: String, isInputEnabled: Boolean, isSending: Boolean, + isTokenExhausted: Boolean, replyTarget: ReplyTarget?, actions: ChatRoomActions, + modifier: Modifier = Modifier, ) { if (endFlow is EndFlow.Ended) { Text( @@ -443,7 +486,7 @@ private fun ChatRoomInputSection( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(16.dp), ) @@ -454,10 +497,12 @@ private fun ChatRoomInputSection( input = input, enabled = isInputEnabled, isSending = isSending, + isTokenExhausted = isTokenExhausted, replyTarget = replyTarget, onInputChange = actions.onInputChange, onSendClick = actions.onSendClick, onReplyClear = actions.onReplyTargetClear, + modifier = Modifier, ) } @@ -532,5 +577,9 @@ private fun ChatRoomPreviewContent() { onTokenUsageToggle = {}, onTokenUsageRetry = {} ) - ChatRoomContent(state = state, actions = actions, onBackClick = {}) + ChatRoomContent( + state = state, + actions = actions, + 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/ChatRoomState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt index a17c3819..a950bb2b 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 @@ -19,10 +19,19 @@ data class ChatRoomState( val tokenUsagePercent: Int? = null, val isTokenUsagePopupExpanded: Boolean = false, val isTokenUsageLoading: Boolean = false, + /** 서버가 확정한 소진 여부. 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 피드백은 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 baa4bcfe..1415d355 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,15 +44,56 @@ 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) + observeTokenExhausted() + // 방에 들어오자마자 소진 여부를 알아야 전송 버튼을 처음부터 올바르게 잠글 수 있다 — + // 이전에 이미 소진된 채로 재입장한 경우, 한 번도 안 보내봤어도 버튼이 바로 잠겨야 한다. + tokenUsageRefreshNotifier.requestRefresh() // 결과를 기다리지 않는다 — 채팅방에 들어온 시점부터 온디바이스 모델 다운로드를 미리 // 걸어둬 첫 메시지/카드 생성 시점엔 이미 받아져 있을 확률을 높이는 순수 최적화용 호출이다. viewModelScope.launch { session.prefetchOnDeviceModels() } } + /** + * [tokenUsageRefreshNotifier]의 알림(LOW/EXHAUSTED)을 토스트로 옮긴다. EXHAUSTED를 여기서 + * 다루는 이유: [CommentGenerationStatus.LIMIT_EXCEEDED]는 "이미 소진된 채로 보냈다"는 + * 신호라 막 소진된 순간을 놓친다 — 실측(exceeded)을 보는 이 스트림이 유일한 소스다(아래 + * [onSend] 참고). + * + * alerts는 Channel이라 값을 그 순간 receive() 중인 구독자 한 곳에만 준다. 백스택에 남은 + * 다른 채팅방이 먼저 가로채지 않도록, [start]가 아니라 화면이 RESUMED일 때만 구독한다. + */ + 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 { + state.copy( + isTokenExhausted = exhausted, + input = if (exhausted) "" else state.input, + ) + } + } + } + private fun loadMessages(conversationId: Long) = intent { reduce { state.copy(conversationId = conversationId, isLoading = true) } @@ -169,7 +210,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 @@ -185,6 +228,8 @@ class ChatRoomViewModel @Inject constructor( } else { state.isSending }, + // 방금 비운 입력칸을 되돌린다. 그 사이 사용자가 새로 타이핑했다면 덮어쓰지 않는다. + input = if (detection.shouldBlock && state.input.isEmpty()) sending.content else state.input, ) } @@ -209,17 +254,22 @@ 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 }, ) } launchCommentReveal() - sent.commentStatus.toUserMessage()?.let { postSideEffect(ChatRoomSideEffect.ShowToast(it)) } + sent.commentStatus.toSideEffect()?.let { postSideEffect(it) } session.finishSend() 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)) } } @@ -343,10 +393,12 @@ class ChatRoomViewModel @Inject constructor( } } - private fun CommentGenerationStatus.toUserMessage(): String? = when (this) { - CommentGenerationStatus.DONE -> null - CommentGenerationStatus.FAILED -> "답장을 받지 못했어요. 잠시 후 다시 보내볼까요?" - CommentGenerationStatus.LIMIT_EXCEEDED -> "오늘은 대화를 많이 했어요. 내일 다시 이야기해요." + // LIMIT_EXCEEDED는 토큰이 이미 소진됐을 때 나는 신호라, onSend() 성공 분기에서 이미 호출하는 + // requestRefresh()가 트리거하는 tokenUsageRefreshNotifier의 EXHAUSTED 알림 + // (observeTokenUsageAlerts 참고)이 대신 안내한다 — 토스트를 따로 띄우지 않는다. + private fun CommentGenerationStatus.toSideEffect(): ChatRoomSideEffect? = when (this) { + CommentGenerationStatus.DONE, CommentGenerationStatus.LIMIT_EXCEEDED -> null + CommentGenerationStatus.FAILED -> ChatRoomSideEffect.ShowToast("답장을 받지 못했어요. 잠시 후 다시 보내볼까요?") } private data class PendingSend( 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/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, ) } } 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), ), ), 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..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 @@ -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,34 @@ 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 +95,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 +174,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) 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 1e302d3c..f92baa09 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 @@ -17,24 +17,30 @@ fun MessageInputBar( input: String, enabled: Boolean, isSending: Boolean, + isTokenExhausted: Boolean, replyTarget: ReplyTarget?, 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), + placeholder = if (isTokenExhausted) { + stringResource(R.string.chat_room_input_placeholder_exhausted) + } else { + stringResource(R.string.chat_room_input_placeholder) + }, trailingContentDescription = "보내기", - enabled = enabled, - // 전송 중에도 입력칸 포커스·키보드는 유지하되(enabled), 전송 버튼만 잠가 중복 전송을 - // 시각적으로도 막는다. - sendEnabled = enabled && !isSending, + // 토큰이 소진되면 다시 채울 방법이 없으니(다음 날까지) 입력칸 자체를 잠가 자리표시자 + // 문구로 안내한다. 전송 중일 때는 입력은 유지하고 전송 버튼만 잠근다. + enabled = enabled && !isTokenExhausted, + sendEnabled = enabled && !isSending && !isTokenExhausted, maxLines = MESSAGE_INPUT_MAX_LINES, replyQuote = replyTarget?.let { ChatReplyQuote( 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 diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml index cbc24c7b..229eb738 100644 --- a/feature/chat/src/main/res/values/strings.xml +++ b/feature/chat/src/main/res/values/strings.xml @@ -5,12 +5,18 @@ 뒤로가기 감정 확인하기 메시지 입력 + 토큰이 모두 소모되어 메시지를 전송할 수 없습니다 + %1$s에게 답장 + 나에게 답장 + 답장 최신 메시지로 이동 토큰 사용량 %1$d%% 사용됨 오전 5:00에 초기화됩니다 사용량을 불러오지 못했어요. 재시도 + 오늘 사용 가능한 토큰이 10% 남았어요. + 오늘 사용 가능한 토큰을 전부 사용했어요. 입력 중 종이를 눌러 2번 접어주세요! 내려서 버려주세요 @@ -31,7 +37,7 @@ 대화를 이어갈 수 없으며, 카드를 생성할 수 없습니다.\n채팅은 영구적으로 삭제됩니다. 삭제하기 뒤로가기 - 메뉴 + 설정 채팅방 검색 검색어 입력 검색어 지우기 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() 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 6d0a58b5..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 @@ -41,6 +42,9 @@ import com.gamss.android.domain.user.UserProfile import com.gamss.android.domain.user.UserRepository import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow import java.time.LocalDate import java.time.YearMonth @@ -111,12 +115,16 @@ 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(), + override val isExhausted: StateFlow = MutableStateFlow(false).asStateFlow(), +) : TokenUsageRefreshNotifier { var refreshCount = 0 private set override val refreshEvents: Flow = emptyFlow() + override val usagePercent: StateFlow = MutableStateFlow(null).asStateFlow() override fun requestRefresh() { refreshCount++