diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index face70ad..f07930cb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -51,6 +51,7 @@ android:exported="true" android:screenOrientation="portrait" android:theme="@style/Theme.GAMSS" + android:windowSoftInputMode="adjustResize" tools:ignore="DiscouragedApi,LockedOrientationActivity"> diff --git a/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt b/app/src/main/kotlin/com/gamss/android/app/main/MainScreen.kt index e59dab7c..02a2f3fb 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 @@ -178,6 +178,7 @@ private fun mainEntryProvider(navigator: Navigator) = entryProvider { ChatRoomScreen( conversationId = key.conversationId, onCardClose = navigator::goBack, + onBackClick = navigator::goBack, ) } } diff --git a/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt b/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt index 8f04f186..32264b04 100644 --- a/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt +++ b/core/common/src/main/java/com/gamss/android/core/common/util/Date.kt @@ -1,34 +1,24 @@ package com.gamss.android.core.common.util -import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.time.format.DateTimeParseException import java.util.Locale +/** 기기 시간대와 무관하게 한국 기준으로 "오늘"/"이번 달"을 고정해야 할 때 쓴다. */ +val KoreanTimeZone: ZoneId = ZoneId.of("Asia/Seoul") + private const val KOREAN_TIME_PATTERN = "a h:mm" -val KoreanTimeZone: ZoneId = ZoneId.of("Asia/Seoul") -private val KoreanTimeFormatter: DateTimeFormatter = DateTimeFormatter - .ofPattern(KOREAN_TIME_PATTERN, Locale.KOREAN) - .withZone(KoreanTimeZone) +private const val CONVERSATION_DATE_PATTERN = "yy.MM.dd" // 존을 덮지 않는다. 이미 벽시계로 변환된 값을 그대로 찍는 용도다. private val KoreanWallClockFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern(KOREAN_TIME_PATTERN, Locale.KOREAN) -/** - * UTC ISO-8601 날짜 문자열을 한국 시간의 `오전/오후 h:mm` 형식으로 변환한다. - * - * [isoDateTime]이 ISO-8601 형식이 아니면 `null`을 반환한다. - */ -fun formatKoreanTime(isoDateTime: String): String? = - try { - KoreanTimeFormatter.format(Instant.parse(isoDateTime)) - } catch (_: DateTimeParseException) { - null - } +// 불교력이나 일본력 로케일에서 연도가 밀리지 않게 ROOT 로 고정한다. +private val ConversationDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern(CONVERSATION_DATE_PATTERN, Locale.ROOT) /** * 이미 벽시계로 바뀐 시각을 `오전/오후 h:mm` 형식으로 변환한다. @@ -37,3 +27,6 @@ fun formatKoreanTime(isoDateTime: String): String? = * 기기 로케일이 영어여도 `AM/PM` 으로 바뀌지 않는다. */ fun formatKoreanTime(dateTime: LocalDateTime): String = KoreanWallClockFormatter.format(dateTime) + +/** 대화방 생성 시각을 `yy.MM.dd` 형식의 날짜로 표시한다. */ +fun formatConversationDate(dateTime: LocalDateTime): String = ConversationDateFormatter.format(dateTime) diff --git a/core/common/src/main/java/com/gamss/android/core/common/util/TokenUsage.kt b/core/common/src/main/java/com/gamss/android/core/common/util/TokenUsage.kt new file mode 100644 index 00000000..84101dbd --- /dev/null +++ b/core/common/src/main/java/com/gamss/android/core/common/util/TokenUsage.kt @@ -0,0 +1,20 @@ +package com.gamss.android.core.common.util + +import kotlin.math.roundToInt + +/** + * 오늘 사용한 토큰량을 오늘 하루 총 한도 대비 백분율로 환산한다. + * + * @param usedTokens 오늘 사용한 토큰 수. + * @param dailyLimit 오늘 하루 총 한도. 서버가 "한도 없음"을 null 로 내려줄 수 있어 nullable 이다. + * @return 0~100 사이로 clamp 된 정수 퍼센트. [dailyLimit] 이 null 이거나 0 이하라 나눌 수 없으면 + * "0%"로 거짓 표시하지 않도록 null 을 반환한다 — 호출부가 무제한 상태를 구분해서 다뤄야 한다. + */ +fun calculateTokenUsagePercent(usedTokens: Long, dailyLimit: Long?): Int? { + if (dailyLimit == null || dailyLimit <= 0) return null + + val percent = (usedTokens.toDouble() / dailyLimit.toDouble() * PERCENT_SCALE).roundToInt() + return percent.coerceIn(0, 100) +} + +private const val PERCENT_SCALE = 100 diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssTokenUsageTooltip.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssTokenUsageTooltip.kt index a268ba2d..15e5345a 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssTokenUsageTooltip.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/GamssTokenUsageTooltip.kt @@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -28,12 +30,15 @@ private val TooltipContentGap = 6.dp private val TooltipBorderWidth = 1.dp private val ProgressBarHeight = 9.dp private val TooltipShadowElevation = 40.dp +private val LoadingIndicatorSize = 24.dp +private val LoadingIndicatorStrokeWidth = 2.dp /** * 상단 내비게이션의 토큰 사용량 아이콘을 눌렀을 때 그 아래 뜨는 툴팁. * * [usagePercent]는 0~100 사이 값을 그대로 받는다 — 계산(도메인 값 유무 판단 등)은 호출부 책임이다. - * null이면 조회에 실패한 것으로 보고 [failMessage]와 재시도 액션([retryLabel]/[onRetryClick])을 대신 보여준다. + * [isLoading]이 true면 조회 중으로 보고 로딩 인디케이터를 대신 보여준다. + * 그 외에 null이면 조회에 실패한 것으로 보고 [failMessage]와 재시도 액션([retryLabel]/[onRetryClick])을 대신 보여준다. */ @Composable fun GamssTokenUsageTooltip( @@ -45,6 +50,7 @@ fun GamssTokenUsageTooltip( retryLabel: String, onRetryClick: () -> Unit, modifier: Modifier = Modifier, + isLoading: Boolean = false, ) { Column( modifier = modifier @@ -55,7 +61,23 @@ fun GamssTokenUsageTooltip( .padding(TooltipPadding), verticalArrangement = Arrangement.spacedBy(TooltipContentGap), ) { - if (usagePercent != null) { + if (isLoading) { + Text( + text = title, + style = GamssTheme.typography.subtitle4, + color = GamssTheme.colors.gray900, + ) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(LoadingIndicatorSize), + color = GamssTheme.colors.gray900, + strokeWidth = LoadingIndicatorStrokeWidth, + ) + } + } else if (usagePercent != null) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/chat/GamssChatBubble.kt b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/chat/GamssChatBubble.kt index 4e8f5db2..0bf88010 100644 --- a/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/chat/GamssChatBubble.kt +++ b/core/designsystem/src/main/java/com/gamss/android/core/designsystem/component/chat/GamssChatBubble.kt @@ -165,10 +165,14 @@ fun GamssReceivedChatBubble( /** * 상대가 입력중일때 말풍선. 아바타 + 이름 + 말풍선이 왼쪽에 정렬된다. + * + * @param avatar 다음에 도착할 답장의 발신자를 이미 알 때(예: pendingComments) 그 캐릭터 아바타를 + * 넘기면 빈 프로필 대신 표시한다. */ @Composable fun GamssLoadingMessageBubble( - senderStatus: String + senderStatus: String, + avatar: (@Composable () -> Unit)? = null, ) { Row( horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), @@ -178,7 +182,7 @@ fun GamssLoadingMessageBubble( horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), verticalAlignment = Alignment.Top, ) { - ChatAvatar(avatar = null) + ChatAvatar(avatar = avatar) Column(verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing075)) { Text( text = senderStatus, diff --git a/data/src/main/java/com/gamss/android/data/remote/conversation/ConversationService.kt b/data/src/main/java/com/gamss/android/data/remote/conversation/ConversationService.kt index 226d4039..ea7d8626 100644 --- a/data/src/main/java/com/gamss/android/data/remote/conversation/ConversationService.kt +++ b/data/src/main/java/com/gamss/android/data/remote/conversation/ConversationService.kt @@ -3,7 +3,7 @@ package com.gamss.android.data.remote.conversation import com.gamss.android.data.remote.conversation.model.request.SaveMessageRequest import com.gamss.android.data.remote.conversation.model.request.UpdateConversationTitleRequest import com.gamss.android.data.remote.conversation.model.response.ChattingRoomSearchResponse -import com.gamss.android.data.remote.conversation.model.response.ConversationMessage +import com.gamss.android.data.remote.conversation.model.response.ConversationDetailResponse import com.gamss.android.data.remote.conversation.model.response.ConversationResponse import com.gamss.android.data.remote.conversation.model.response.SaveMessageResponse import com.gamss.android.data.remote.model.response.ApiResponse @@ -23,8 +23,8 @@ internal interface ConversationService { @POST("/api/conversations/messages") suspend fun saveMessage(@Body request: SaveMessageRequest): ApiResponse - @GET("/api/conversations/{conversationId}/messages") - suspend fun getMessages(@Path("conversationId") conversationId: Long): ApiResponse> + @GET("/api/conversations/{conversationId}") + suspend fun getConversation(@Path("conversationId") conversationId: Long): ApiResponse @PATCH("/api/conversations/{conversationId}/title") suspend fun updateTitle( diff --git a/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationDetailResponse.kt b/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationDetailResponse.kt new file mode 100644 index 00000000..7e2e1c0f --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationDetailResponse.kt @@ -0,0 +1,18 @@ +package com.gamss.android.data.remote.conversation.model.response + +import com.gamss.android.domain.conversation.ConversationDetail +import kotlinx.serialization.Serializable + +@Serializable +internal data class ConversationDetailResponse( + val conversation: ConversationResponse, + val messages: List = emptyList(), +) + +internal fun ConversationDetailResponse.toDomain(): ConversationDetail? = + conversation.toDomain()?.let { conversation -> + ConversationDetail( + conversation = conversation, + messages = messages.map(ConversationMessage::toDomain), + ) + } diff --git a/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationMessage.kt b/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationMessage.kt index 18fa28d8..42de644b 100644 --- a/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationMessage.kt +++ b/data/src/main/java/com/gamss/android/data/remote/conversation/model/response/ConversationMessage.kt @@ -47,7 +47,7 @@ private fun ConversationMessage.toMessage(sender: MessageSender): Message = Mess sender = sender, content = content, repliesToMessageId = repliesToMessageId, - createdTime = formatKoreanTime(createdAt) + createdTime = parseConversationCreatedAt(createdAt)?.let(::formatKoreanTime) ) private fun ConversationMessage.resolveSender(): MessageSender = when (senderType) { diff --git a/data/src/main/java/com/gamss/android/data/repository/ConversationRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/repository/ConversationRepositoryImpl.kt index 13dbd06f..7d5caca2 100644 --- a/data/src/main/java/com/gamss/android/data/repository/ConversationRepositoryImpl.kt +++ b/data/src/main/java/com/gamss/android/data/repository/ConversationRepositoryImpl.kt @@ -11,15 +11,14 @@ import com.gamss.android.data.di.ApplicationScope import com.gamss.android.data.remote.conversation.ConversationService import com.gamss.android.data.remote.conversation.model.request.SaveMessageRequest import com.gamss.android.data.remote.conversation.model.request.UpdateConversationTitleRequest -import com.gamss.android.data.remote.conversation.model.response.ConversationMessage import com.gamss.android.data.remote.conversation.model.response.ConversationResponse import com.gamss.android.data.remote.conversation.model.response.toDomain import com.gamss.android.data.remote.emotion.toServerEmotionType import com.gamss.android.data.remote.runCatchingApiCall import com.gamss.android.data.remote.throwIfFailed import com.gamss.android.domain.conversation.Conversation +import com.gamss.android.domain.conversation.ConversationDetail import com.gamss.android.domain.conversation.ConversationRepository -import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.SentMessage import com.gamss.android.domain.conversation.chattingsearch.ChattingRoomSummary import com.gamss.android.domain.emotion.EmotionCharacter @@ -62,10 +61,9 @@ internal class ConversationRepositoryImpl @Inject constructor( checkNotNull(response.data) { "No available saved message data" }.toDomain() } - override suspend fun getMessages(conversationId: Long): AppResult> = runCatchingApiCall { - val response = conversationService.getMessages(conversationId) - checkNotNull(response.data) { "No available message data" } - .mapNotNull(ConversationMessage::toDomain) + override suspend fun getConversation(conversationId: Long): AppResult = runCatchingApiCall { + val response = conversationService.getConversation(conversationId) + checkNotNull(response.data?.toDomain()) { "No available conversation detail data" } } override suspend fun updateTitle(conversationId: Long, title: String): AppResult = diff --git a/data/src/test/kotlin/com/gamss/android/data/remote/conversation/ConversationResponseTest.kt b/data/src/test/kotlin/com/gamss/android/data/remote/conversation/ConversationResponseTest.kt index f4882b4a..3114ed0d 100644 --- a/data/src/test/kotlin/com/gamss/android/data/remote/conversation/ConversationResponseTest.kt +++ b/data/src/test/kotlin/com/gamss/android/data/remote/conversation/ConversationResponseTest.kt @@ -1,5 +1,7 @@ package com.gamss.android.data.remote.conversation +import com.gamss.android.data.remote.conversation.model.response.ConversationDetailResponse +import com.gamss.android.data.remote.conversation.model.response.ConversationMessage import com.gamss.android.data.remote.conversation.model.response.ConversationResponse import com.gamss.android.data.remote.conversation.model.response.parseConversationCreatedAt import com.gamss.android.data.remote.conversation.model.response.toDomain @@ -75,4 +77,27 @@ class ConversationResponseTest { fun 빈_문자열_시각은_null_이다() { assertNull(parseConversationCreatedAt(" ")) } + + @Test + fun 대화_상세는_생성_시각과_메시지를_도메인으로_옮긴다() { + val detail = ConversationDetailResponse( + conversation = ConversationResponse( + id = 37, + title = "비 오는 날의 짜증", + createdAt = "2026-08-15T17:16:52.320", + ), + messages = listOf( + ConversationMessage( + id = 1, + conversationId = 37, + senderType = ConversationMessage.SENDER_USER, + content = "오늘 억울한 일이 있었어", + createdAt = "2026-08-15T17:16:52.320", + ), + ), + ).toDomain() + + assertEquals(LocalDateTime.of(2026, 8, 15, 17, 16, 52, 320_000_000), detail?.conversation?.createdAt) + assertEquals(listOf(1L), detail?.messages?.map { it.id }) + } } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationDetail.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationDetail.kt new file mode 100644 index 00000000..38497cef --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationDetail.kt @@ -0,0 +1,6 @@ +package com.gamss.android.domain.conversation + +data class ConversationDetail( + val conversation: Conversation, + val messages: List, +) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationRepository.kt index 70456456..10b1bcbf 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationRepository.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationRepository.kt @@ -22,7 +22,7 @@ interface ConversationRepository { excludeCharacters: Set, ): AppResult - suspend fun getMessages(conversationId: Long): AppResult> + suspend fun getConversation(conversationId: Long): AppResult suspend fun updateTitle(conversationId: Long, title: String): AppResult diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt index cda89e1c..d84c3221 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/ConversationSession.kt @@ -17,7 +17,7 @@ import javax.inject.Inject @Suppress("LongParameterList") class ConversationSession @Inject constructor( private val sendMessage: SendMessageUseCase, - private val getMessages: GetMessagesUseCase, + private val getConversation: GetConversationUseCase, private val updateConversationTitle: UpdateConversationTitleUseCase, private val endConversation: EndConversationUseCase, private val createConversationCard: CreateConversationCardUseCase, @@ -30,12 +30,12 @@ class ConversationSession @Inject constructor( private var pendingTitle: PendingTitle? = null - suspend fun restore(conversationId: Long): AppResult> { + suspend fun restore(conversationId: Long): AppResult { clearPendingTitle() - val result = getMessages(conversationId) + val result = getConversation(conversationId) when (result) { is AppResult.Success -> { - val utterances = result.data.userUtterances() + val utterances = result.data.messages.userUtterances() summaryStore.restore(utterances) emotionAccumulator.restore(utterances) } @@ -83,12 +83,12 @@ class ConversationSession @Inject constructor( } /** 홈 쪽 인스턴스에서만 채워진 요약·감정 상태를, [restore] 와 같은 방식으로 이 인스턴스에도 시드해 둔다. */ - suspend fun consumePendingReveal(conversationId: Long): SentMessage? { - val sent = pendingReveal.consume(conversationId) ?: return null - val utterances = listOf(sent.message).userUtterances() + suspend fun consumePendingReveal(conversationId: Long): PendingReveal? { + val pending = pendingReveal.consume(conversationId) ?: return null + val utterances = listOf(pending.sent.message).userUtterances() summaryStore.restore(utterances) emotionAccumulator.restore(utterances) - return sent + return pending } suspend fun finishSend() = coroutineScope { diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/GetMessagesUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/GetConversationUseCase.kt similarity index 50% rename from domain/src/main/kotlin/com/gamss/android/domain/conversation/GetMessagesUseCase.kt rename to domain/src/main/kotlin/com/gamss/android/domain/conversation/GetConversationUseCase.kt index 7e89f2d0..30a2d3ec 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/GetMessagesUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/GetConversationUseCase.kt @@ -4,10 +4,10 @@ import com.gamss.android.core.common.AppResult import com.gamss.android.domain.usecase.UseCase import javax.inject.Inject -class GetMessagesUseCase @Inject constructor( +class GetConversationUseCase @Inject constructor( private val conversationRepository: ConversationRepository, -) : UseCase>> { +) : UseCase> { - override suspend fun invoke(params: Long): AppResult> = - conversationRepository.getMessages(params) + override suspend fun invoke(params: Long): AppResult = + conversationRepository.getConversation(params) } diff --git a/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt index 9e555a5d..cb24d6db 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/conversation/PendingConversationReveal.kt @@ -2,6 +2,9 @@ package com.gamss.android.domain.conversation import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton @@ -18,12 +21,26 @@ class PendingConversationReveal @Inject constructor() { mutex.withLock { pending = Entry(sent, now) } } - suspend fun consume(conversationId: Long, now: Long = System.currentTimeMillis()): SentMessage? = + suspend fun consume(conversationId: Long, now: Long = System.currentTimeMillis()): PendingReveal? = mutex.withLock { val current = pending?.takeIf { it.sent.message.conversationId == conversationId } ?: return@withLock null pending = null - current.sent.takeIf { now - current.savedAtMillis <= PENDING_REVEAL_EXPIRY_MILLIS } + current.takeIf { now - it.savedAtMillis <= PENDING_REVEAL_EXPIRY_MILLIS }?.toPendingReveal() } - private data class Entry(val sent: SentMessage, val savedAtMillis: Long) + private data class Entry(val sent: SentMessage, val savedAtMillis: Long) { + fun toPendingReveal() = PendingReveal( + sent = sent, + createdAt = Instant.ofEpochMilli(savedAtMillis).atZone(ZoneId.systemDefault()).toLocalDateTime(), + ) + } } + +/** + * [PendingConversationReveal.consume] 결과. 서버 대화 상세를 다시 받아오기 전이라 [createdAt] 은 + * 서버 값이 아니라 [PendingConversationReveal.save] 가 호출된(=대화가 막 생긴) 시각이다. + */ +data class PendingReveal( + val sent: SentMessage, + val createdAt: LocalDateTime, +) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/model/DailyTokenUsage.kt b/domain/src/main/kotlin/com/gamss/android/domain/model/DailyTokenUsage.kt index 0264bdfd..a9632390 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/model/DailyTokenUsage.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/model/DailyTokenUsage.kt @@ -4,4 +4,5 @@ data class DailyTokenUsage( val usedTokens: Long, val dailyLimit: Long?, val exceeded: Boolean, + val usagePercent: Int? = null, ) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCase.kt index 6ccf398d..ff304fd6 100644 --- a/domain/src/main/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCase.kt +++ b/domain/src/main/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCase.kt @@ -1,6 +1,8 @@ package com.gamss.android.domain.usecase import com.gamss.android.core.common.AppResult +import com.gamss.android.core.common.map +import com.gamss.android.core.common.util.calculateTokenUsagePercent import com.gamss.android.domain.model.DailyTokenUsage import com.gamss.android.domain.user.UserRepository import javax.inject.Inject @@ -10,5 +12,12 @@ class GetDailyTokenUsageUseCase @Inject constructor( ) : NoParamUseCase> { override suspend fun invoke(): AppResult = - userRepository.getDailyTokenUsage() + userRepository.getDailyTokenUsage().map { usage -> + usage.copy( + usagePercent = calculateTokenUsagePercent( + usedTokens = usage.usedTokens, + dailyLimit = usage.dailyLimit, + ), + ) + } } diff --git a/domain/src/test/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt index 50656fea..4f81158b 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt @@ -3,8 +3,8 @@ package com.gamss.android.domain.chattingsearch import androidx.paging.PagingData import com.gamss.android.core.common.AppResult import com.gamss.android.domain.conversation.Conversation +import com.gamss.android.domain.conversation.ConversationDetail import com.gamss.android.domain.conversation.ConversationRepository -import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.SentMessage import com.gamss.android.domain.conversation.chattingsearch.ChattingRoomSearchException import com.gamss.android.domain.conversation.chattingsearch.ChattingRoomSummary @@ -76,7 +76,7 @@ class SearchChattingRoomsUseCaseTest { excludeCharacters: Set, ): AppResult = unused() - override suspend fun getMessages(conversationId: Long): AppResult> = unused() + override suspend fun getConversation(conversationId: Long): AppResult = unused() override suspend fun updateTitle(conversationId: Long, title: String): AppResult = unused() diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt index 7b1a36b0..bcbf658c 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/ConversationSessionTest.kt @@ -163,7 +163,7 @@ class ConversationSessionTest { val result = homeSession.send(conversationId = null, content = SEED, replyToMessageId = null) val sent = (result as AppResult.Success).data - assertEquals(sent, chatSession.consumePendingReveal(ROOM_ID)) + assertEquals(sent, chatSession.consumePendingReveal(ROOM_ID)?.sent) } @Test @@ -258,7 +258,7 @@ class ConversationSessionTest { pendingReveal: PendingConversationReveal = PendingConversationReveal(), ) = ConversationSession( sendMessage = SendMessageUseCase(repository), - getMessages = GetMessagesUseCase(repository), + getConversation = GetConversationUseCase(repository), updateConversationTitle = UpdateConversationTitleUseCase(repository), endConversation = EndConversationUseCase(repository), createConversationCard = CreateConversationCardUseCase( @@ -345,8 +345,13 @@ class ConversationSessionTest { override suspend fun getOngoingConversations(): AppResult> = AppResult.Success(emptyList()) - override suspend fun getMessages(conversationId: Long): AppResult> = - AppResult.Success(emptyList()) + override suspend fun getConversation(conversationId: Long): AppResult = + AppResult.Success( + ConversationDetail( + conversation = Conversation(id = conversationId, title = null), + messages = emptyList(), + ), + ) override suspend fun updateTitle(conversationId: Long, title: String): AppResult { updatedTitles += conversationId to title diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationUseCaseTest.kt index a23a0632..da987ba7 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationUseCaseTest.kt @@ -51,7 +51,7 @@ class DeleteConversationUseCaseTest { override suspend fun getOngoingConversations(): AppResult> = throw UnsupportedOperationException() - override suspend fun getMessages(conversationId: Long): AppResult> = + override suspend fun getConversation(conversationId: Long): AppResult = throw UnsupportedOperationException() override suspend fun updateTitle(conversationId: Long, title: String): AppResult = diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationsUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationsUseCaseTest.kt index a51160dd..f9a0a3ee 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationsUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/DeleteConversationsUseCaseTest.kt @@ -205,7 +205,7 @@ class DeleteConversationsUseCaseTest { override suspend fun getOngoingConversations(): AppResult> = throw UnsupportedOperationException() - override suspend fun getMessages(conversationId: Long): AppResult> = + override suspend fun getConversation(conversationId: Long): AppResult = throw UnsupportedOperationException() override suspend fun updateTitle(conversationId: Long, title: String): AppResult = diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt index a471121e..409aed23 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/PendingConversationRevealTest.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import java.time.Instant +import java.time.ZoneId class PendingConversationRevealTest { @@ -14,7 +16,20 @@ class PendingConversationRevealTest { reveal.save(sent, now = BASE_TIME) - assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)) + assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)?.sent) + } + + @Test + fun 저장_시각이_생성_일시로_즉시_채워진다() = runBlocking { + val reveal = PendingConversationReveal() + reveal.save(sentMessage(), now = BASE_TIME) + + val result = reveal.consume(ROOM_ID, now = BASE_TIME) + + assertEquals( + Instant.ofEpochMilli(BASE_TIME).atZone(ZoneId.systemDefault()).toLocalDateTime(), + result?.createdAt, + ) } @Test @@ -34,7 +49,7 @@ class PendingConversationRevealTest { reveal.save(sent, now = BASE_TIME) assertNull(reveal.consume(OTHER_ROOM_ID, now = BASE_TIME)) - assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)) + assertEquals(sent, reveal.consume(ROOM_ID, now = BASE_TIME)?.sent) } @Test @@ -44,7 +59,7 @@ class PendingConversationRevealTest { val result = reveal.consume(ROOM_ID, now = BASE_TIME + PENDING_REVEAL_EXPIRY_MILLIS) - assertEquals(ROOM_ID, result?.message?.conversationId) + assertEquals(ROOM_ID, result?.sent?.message?.conversationId) } @Test diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/SendMessageUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/SendMessageUseCaseTest.kt index e4d9812c..fc942745 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/SendMessageUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/SendMessageUseCaseTest.kt @@ -60,8 +60,13 @@ class SendMessageUseCaseTest { override suspend fun getOngoingConversations(): AppResult> = AppResult.Success(emptyList()) - override suspend fun getMessages(conversationId: Long): AppResult> = - AppResult.Success(emptyList()) + override suspend fun getConversation(conversationId: Long): AppResult = + AppResult.Success( + ConversationDetail( + conversation = Conversation(id = conversationId, title = null), + messages = emptyList(), + ), + ) override suspend fun updateTitle(conversationId: Long, title: String): AppResult = AppResult.Success(Unit) diff --git a/domain/src/test/kotlin/com/gamss/android/domain/conversation/UpdateConversationTitleUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/conversation/UpdateConversationTitleUseCaseTest.kt index 28cddc76..b696f888 100644 --- a/domain/src/test/kotlin/com/gamss/android/domain/conversation/UpdateConversationTitleUseCaseTest.kt +++ b/domain/src/test/kotlin/com/gamss/android/domain/conversation/UpdateConversationTitleUseCaseTest.kt @@ -89,7 +89,7 @@ class UpdateConversationTitleUseCaseTest { override suspend fun getOngoingConversations(): AppResult> = throw UnsupportedOperationException() - override suspend fun getMessages(conversationId: Long): AppResult> = + override suspend fun getConversation(conversationId: Long): AppResult = throw UnsupportedOperationException() override suspend fun updateTitle(conversationId: Long, title: String): AppResult { diff --git a/domain/src/test/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCaseTest.kt new file mode 100644 index 00000000..96c5bc76 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/usecase/GetDailyTokenUsageUseCaseTest.kt @@ -0,0 +1,62 @@ +package com.gamss.android.domain.usecase + +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.model.DailyTokenUsage +import com.gamss.android.domain.user.UserProfile +import com.gamss.android.domain.user.UserRepository +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class GetDailyTokenUsageUseCaseTest { + + @Test + fun `저장소 값으로 사용률을 계산해 채운다`() = runBlocking { + val repository = FakeUserRepository( + usage = DailyTokenUsage(usedTokens = 60, dailyLimit = 100, exceeded = false), + ) + + val result = GetDailyTokenUsageUseCase(repository)() + + assertEquals(60, (result as AppResult.Success).data.usagePercent) + } + + @Test + fun `한도가 없으면 사용률은 null이다`() = runBlocking { + val repository = FakeUserRepository( + usage = DailyTokenUsage(usedTokens = 60, dailyLimit = null, exceeded = false), + ) + + val result = GetDailyTokenUsageUseCase(repository)() + + assertNull((result as AppResult.Success).data.usagePercent) + } + + @Test + fun `저장소 실패를 그대로 반환한다`() = runBlocking { + val failure = IllegalStateException("network error") + val repository = FakeUserRepository(result = AppResult.Failure(failure)) + + val result = GetDailyTokenUsageUseCase(repository)() + + assertSame(failure, (result as AppResult.Failure).throwable) + } + + private class FakeUserRepository( + usage: DailyTokenUsage = DailyTokenUsage(usedTokens = 0, dailyLimit = 100, exceeded = false), + private val result: AppResult = AppResult.Success(usage), + ) : UserRepository { + override suspend fun updateNickname(nickname: String): AppResult = + error("Not needed for this test") + + override suspend fun deleteUserAccount(): AppResult = + error("Not needed for this test") + + override suspend fun getUserInfo(): AppResult = + error("Not needed for this test") + + override suspend fun getDailyTokenUsage(): AppResult = result + } +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt index 96d22772..a321298d 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomScreen.kt @@ -1,66 +1,87 @@ package com.gamss.android.feature.chat -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.net.Uri +import android.content.res.Configuration import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.Close -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import androidx.hilt.navigation.compose.hiltViewModel -import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote -import com.gamss.android.core.designsystem.component.chat.ChatSender -import com.gamss.android.core.designsystem.component.chat.GamssReceivedChatBubble -import com.gamss.android.core.designsystem.component.chat.GamssSentChatBubble +import com.gamss.android.core.common.util.formatConversationDate +import com.gamss.android.core.designsystem.component.GamssIcons +import com.gamss.android.core.designsystem.component.GamssTokenUsageTooltip +import com.gamss.android.core.designsystem.modifier.gamssShadow import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationHeight +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationHorizontalPadding +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationIcon +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationIconAction +import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationTitleAlignment import com.gamss.android.domain.card.Card -import com.gamss.android.domain.conversation.MAX_MESSAGE_LENGTH import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.domain.emotion.EmotionCharacter +import com.gamss.android.feature.chat.component.EndConversationDialog +import com.gamss.android.feature.chat.component.LoadingMessageBubble +import com.gamss.android.feature.chat.component.MessageBubble +import com.gamss.android.feature.chat.component.MessageInputBar +import com.gamss.android.feature.chat.component.NewMessageToast import com.gamss.android.feature.chat.component.SupportAgencyDialog +import com.gamss.android.feature.chat.component.toReplyQuote +import com.gamss.android.feature.chat.util.AnimatedChatMessage +import com.gamss.android.feature.chat.util.ChatMessageAnimation +import com.gamss.android.feature.chat.util.ChatScrollState +import com.gamss.android.feature.chat.util.dialOrNotify +import com.gamss.android.feature.chat.util.rememberChatMessageAnimationState +import com.gamss.android.feature.chat.util.rememberChatScrollState +import kotlinx.coroutines.delay import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @@ -72,6 +93,7 @@ import org.orbitmvi.orbit.compose.collectSideEffect fun ChatRoomScreen( conversationId: Long, onCardClose: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, viewModel: ChatRoomViewModel = hiltViewModel(), ) { @@ -94,18 +116,23 @@ fun ChatRoomScreen( onCharacterMessageClick = viewModel::onReplyTargetSelect, onReplyTargetClear = viewModel::onReplyTargetClear, onEndClick = viewModel::onEndRequest, + onTokenUsageToggle = viewModel::onTokenUsageToggle, + onTokenUsageRetry = viewModel::onTokenUsageRetry ) } - ChatRoomContent(state = state, actions = actions, modifier = modifier) + ChatRoomContent( + state = state, + actions = actions, + modifier = modifier, + onBackClick = onBackClick, + ) state.riskDetection?.let { detection -> SupportAgencyDialog( agencies = detection.agencies, onCallClick = { agency -> context.dialOrNotify(agency.phoneNumber) }, onEmergencyCallClick = { context.dialOrNotify(EMERGENCY_PHONE_NUMBER) }, - // 감정 결과 화면으로 이동하는 별도 계약이 생기기 전까지는 안내만 닫는다. - onConfirm = viewModel::onRiskDialogDismiss, onDismiss = viewModel::onRiskDialogDismiss, ) } @@ -116,10 +143,8 @@ fun ChatRoomScreen( onConfirm = viewModel::onEndConfirm, onDismiss = viewModel::onEndCancel, ) - is EndFlow.CardReady -> CardBottomSheet( - card = endFlow.card, - onDismiss = onCardClose, - ) + + is EndFlow.CardReady -> CardBottomSheet(card = endFlow.card, onDismiss = onCardClose) EndFlow.NotStarted, EndFlow.Ending, EndFlow.CreatingCard, @@ -129,29 +154,6 @@ fun ChatRoomScreen( } } -// 디자인 컴포넌트로 대체하기 -@Composable -private fun EndConversationDialog( - onConfirm: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.chat_room_end_dialog_title)) }, - text = { Text(stringResource(R.string.chat_room_end_dialog_subTitle)) }, - confirmButton = { - TextButton(onClick = onConfirm) { - Text(stringResource(R.string.chat_room_end_dialog_confirm_button_label)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.chat_room_end_dialog_dismiss_button_label)) - } - }, - ) -} - // 카드생성 bottomsheet @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -189,6 +191,8 @@ private data class ChatRoomActions( val onCharacterMessageClick: (Message) -> Unit, val onReplyTargetClear: () -> Unit, val onEndClick: () -> Unit, + val onTokenUsageToggle: () -> Unit, + val onTokenUsageRetry: () -> Unit, ) @Composable @@ -196,81 +200,68 @@ private fun ChatRoomContent( state: ChatRoomState, actions: ChatRoomActions, modifier: Modifier = Modifier, + onBackClick: () -> Unit, ) { val listState = rememberLazyListState() + val messageAnimationState = rememberChatMessageAnimationState( + conversationId = state.conversationId, + isLoading = state.isLoading, + messageIds = state.messages.map(Message::id), + ) + val chatScrollState = rememberChatScrollState(state = state, listState = listState) - LaunchedEffect(state.messages.size, state.isAwaitingComments) { - val itemCount = state.messages.size + if (state.isAwaitingComments) 1 else 0 - if (itemCount > 0) { - listState.animateScrollToItem(itemCount - 1) + val 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()) } } + // 키보드가 오르내리는 동안엔 위 보정 스크롤이 인셋 변화를 프레임 단위로 정확히 따라잡지 + // 못해 listState.canScrollForward 가 순간적으로 흔들리고, 그 값을 그대로 따르는 스크롤 + // 버튼이 깜빡인다. imeBottomPx 가 바뀔 때마다 이 이펙트가 재시작되므로(진행 중이던 delay는 + // 취소됨), 인셋이 한동안(마지막 변화 후 IME_SETTLE_GRACE_PERIOD_MILLIS) 안 바뀌어 안정됐을 + // 때만 버튼을 노출해 깜빡임을 없앤다. + var isImeInTransition by remember { mutableStateOf(false) } + LaunchedEffect(imeBottomPx) { + isImeInTransition = true + delay(IME_SETTLE_GRACE_PERIOD_MILLIS) + isImeInTransition = false + } + Scaffold( modifier = modifier, - topBar = { - // 디자인 컴포넌트 적용 예정 - ChatRoomTopBar( - endFlow = state.endFlow, - canEnd = state.canEnd, - showEndButton = state.useChatEndFeature, - onEndClick = actions.onEndClick, - ) - }, + topBar = { ChatRoomTopBar(state = state, actions = actions, onBackClick = onBackClick) }, // 상위 Scaffold 가 인셋을 이미 적용해, imePadding 을 그대로 쓰면 이중 적용된다. contentWindowInsets = WindowInsets(0), ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() + .background(GamssTheme.colors.background) .padding(innerPadding) - .windowInsetsPadding(WindowInsets.ime.exclude(WindowInsets.navigationBars)), + .windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)), ) { - LazyColumn( - state = listState, + ChatMessageList( + state = state, + actions = actions, + listState = listState, + animationState = messageAnimationState, + scrollState = chatScrollState, + showScrollToBottomButton = chatScrollState.showScrollToBottomButton && !isImeInTransition, modifier = Modifier .weight(1f) .fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - if (state.messages.isEmpty()) { - item { - Box( - modifier = Modifier.fillParentMaxSize(), - contentAlignment = Alignment.Center, - ) { - if (state.isLoading) { - CircularProgressIndicator() - } else { - Text( - text = "오늘 어떤 일이 있었나요?", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - items(state.messages, key = { it.id }) { message -> - MessageBubble( - message = message, - messages = state.messages, - isReplyTarget = state.replyTarget?.messageId == message.id, - onCharacterMessageClick = actions.onCharacterMessageClick, - ) - } - if (state.isAwaitingComments) { - item { GeneratingIndicator() } - } - } - - HorizontalDivider() + ) ChatRoomInputSection( endFlow = state.endFlow, input = state.input, - canSend = state.canSend, + isInputEnabled = !state.isLoading && state.endFlow == EndFlow.NotStarted, + isSending = state.isSending, replyTarget = state.replyTarget, actions = actions, ) @@ -280,45 +271,205 @@ private fun ChatRoomContent( @Composable private fun ChatRoomTopBar( - endFlow: EndFlow, - canEnd: Boolean, - showEndButton: Boolean, - onEndClick: () -> Unit, + state: ChatRoomState, + actions: ChatRoomActions, + onBackClick: () -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 4.dp, top = 12.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "대화", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f), + Box { + GamssTopNavigation( + title = state.conversationCreatedAt?.let(::formatConversationDate).orEmpty(), + titleAlignment = GamssTopNavigationTitleAlignment.Center, + showLeftIcon = true, + onLeftIconClick = onBackClick, + rightActions = listOfNotNull( + when { + !state.useChatEndFeature -> null + state.endFlow.isBusy -> GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.CreateCard, + onClick = {}, + isLoading = true, + ) + state.canEnd -> GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.CreateCard, + onClick = actions.onEndClick, + ) + else -> null + }, + GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.CheckToken, + onClick = actions.onTokenUsageToggle, + ), + ), + ) + + if (state.isTokenUsagePopupExpanded) { + TokenUsagePopup( + usagePercent = state.tokenUsagePercent, + isLoading = state.isTokenUsageLoading, + onDismissRequest = actions.onTokenUsageToggle, + onRetryClick = actions.onTokenUsageRetry, + ) + } + } +} + +/** + * 상단 CheckToken 아이콘 아래에 뜬다. 오프셋은 [GamssTopNavigation]이 공개한 크기 상수로 + * 계산한다 — [GamssCharacterPicker] 를 입력바 아래에 띄울 때 쓰는 것과 같은 방식이다. + */ +@Composable +private fun TokenUsagePopup( + usagePercent: Int?, + isLoading: Boolean, + onDismissRequest: () -> Unit, + onRetryClick: () -> Unit, +) { + val popupOffset = with(LocalDensity.current) { + IntOffset( + x = -GamssTopNavigationHorizontalPadding.roundToPx(), + y = GamssTopNavigationHeight.roundToPx(), ) - if (showEndButton) { - if (endFlow.isBusy) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + Popup( + alignment = Alignment.TopEnd, + offset = popupOffset, + onDismissRequest = onDismissRequest, + properties = PopupProperties(focusable = true), + ) { + GamssTokenUsageTooltip( + title = stringResource(R.string.chat_room_token_usage_title), + usagePercent = usagePercent, + usagePercentLabel = if (usagePercent != null) { + stringResource( + R.string.chat_room_token_usage_percent, + usagePercent, + ) } else { - TextButton(onClick = onEndClick, enabled = canEnd) { - Text( - when { - endFlow == EndFlow.CardFailedRetryable -> "카드 다시 만들기" - endFlow is EndFlow.Ended -> "끝난 대화" - else -> "대화 끝내기" - }, + "" + }, + resetTimeLabel = stringResource(R.string.chat_room_token_usage_reset_time), + failMessage = stringResource(R.string.chat_room_check_token_usage_fail), + retryLabel = stringResource(R.string.chat_room_check_token_usage_button_label), + onRetryClick = onRetryClick, + isLoading = isLoading, + ) + } +} + +@Composable +private fun ChatMessageList( + state: ChatRoomState, + actions: ChatRoomActions, + listState: LazyListState, + animationState: ChatMessageAnimation, + scrollState: ChatScrollState, + showScrollToBottomButton: Boolean, + modifier: Modifier = Modifier, +) { + // 리스트 전체(state.messages)를 각 아이템에 그대로 넘기면, 메시지가 하나 추가될 때마다 리스트 + // 참조가 바뀌어 이미 떠 있던 다른 모든 말풍선까지 재구성 대상이 된다. 답장 대상 조회를 여기서 + // 한 번에 끝내고 아이템별로는 결과값(replyQuote)만 넘기면, 안 바뀐 아이템은 재구성을 건너뛸 수 + // 있다. LazyListScope 빌더 본문은 @Composable이 아니라 여기(바깥)서 remember해야 한다. + val messagesById = remember(state.messages) { state.messages.associateBy(Message::id) } + + Box(modifier = modifier) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (state.messages.isEmpty()) { + item { + Box( + modifier = Modifier.fillParentMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (state.isLoading) { + CircularProgressIndicator() + } else { + Text( + text = "오늘 어떤 일이 있었나요?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + items(state.messages, key = { it.id }) { message -> + val replyQuote = message.repliesToMessageId + ?.let { targetId -> messagesById[targetId] } + ?.toReplyQuote() + AnimatedChatMessage( + messageId = message.id, + shouldAnimate = animationState.shouldAnimate(message.id), + listState = listState, + ) { + MessageBubble( + message = message, + replyQuote = replyQuote, + onCharacterMessageClick = actions.onCharacterMessageClick, ) } } + if (state.isAwaitingComments) { + val nextCharacter = (state.pendingComments.firstOrNull()?.sender as? MessageSender.Character) + ?.character + item { LoadingMessageBubble(character = nextCharacter) } + } + } + + scrollState.newMessageToast?.let { toastMessage -> + NewMessageToast( + message = toastMessage, + onClick = { scrollState.dismissToastAndScrollToBottom(state) }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 12.dp), + ) + } + + if (showScrollToBottomButton) { + ScrollToBottomButton( + onClick = { scrollState.scrollToBottom(state) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 18.dp, bottom = 12.dp), + ) } } } +@Composable +private fun ScrollToBottomButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(36.dp) + .gamssShadow(shape = CircleShape) + .clip(CircleShape) + .background(GamssTheme.colors.gray700, CircleShape) + .clickable(role = Role.Button, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(GamssIcons.ScrollDown), + contentDescription = stringResource(R.string.chat_room_scroll_to_bottom), + modifier = Modifier.size(24.dp), + tint = GamssTheme.colors.gray025, + ) + } +} + @Composable private fun ChatRoomInputSection( endFlow: EndFlow, input: String, - canSend: Boolean, + isInputEnabled: Boolean, + isSending: Boolean, replyTarget: ReplyTarget?, actions: ChatRoomActions, ) { @@ -335,165 +486,88 @@ private fun ChatRoomInputSection( return } - replyTarget?.let { - ReplyTargetBanner(replyTarget = it, onClear = actions.onReplyTargetClear) - } - MessageInputBar( input = input, - canSend = canSend, + enabled = isInputEnabled, + isSending = isSending, + replyTarget = replyTarget, onInputChange = actions.onInputChange, onSendClick = actions.onSendClick, + onReplyClear = actions.onReplyTargetClear, ) } -/** - * @param messages 답장 대상 메시지의 내용을 찾기 위한 전체 목록. [Message.repliesToMessageId] 가 - * 가리키는 메시지가 이 목록에 없으면(예: 아직 로드되지 않은 과거 메시지) 답장 인용 없이 표시한다. - */ -@Composable -private fun MessageBubble( - message: Message, - messages: List, - isReplyTarget: Boolean, - onCharacterMessageClick: (Message) -> Unit, - modifier: Modifier = Modifier, -) { - val replyQuote = message.repliesToMessageId - ?.let { targetId -> messages.find { it.id == targetId } } - ?.toReplyQuote() - - Box( - modifier = modifier - .fillMaxWidth() - .then( - if (isReplyTarget) { - Modifier.background(GamssTheme.colors.blue.copy(alpha = 0.12f)) - } else { - Modifier - }, - ), - ) { - when (val sender = message.sender) { - MessageSender.User -> GamssSentChatBubble( - message = message.content, - time = message.createdTime, - replyQuote = replyQuote, - modifier = Modifier.align(Alignment.CenterEnd), - ) - is MessageSender.Character -> GamssReceivedChatBubble( - sender = ChatSender(name = sender.character.displayName), - message = message.content, - time = message.createdTime, - replyQuote = replyQuote, - modifier = Modifier - .align(Alignment.CenterStart) - .clickable { onCharacterMessageClick(message) }, - ) - MessageSender.Unknown -> GamssReceivedChatBubble( - sender = ChatSender(name = ""), - message = message.content, - time = message.createdTime, - replyQuote = replyQuote, - modifier = Modifier.align(Alignment.CenterStart), - ) - } - } -} - -private fun Message.toReplyQuote(): ChatReplyQuote { - val label = when (val target = sender) { - is MessageSender.Character -> "${target.character.displayName}에게 답장" - MessageSender.User -> "나에게 답장" - MessageSender.Unknown -> "답장" - } - return ChatReplyQuote(senderLabel = label, message = content) -} +private const val EMERGENCY_PHONE_NUMBER = "119" +private const val IME_SETTLE_GRACE_PERIOD_MILLIS = 120L +@Preview(name = "Light", showBackground = true) +@Suppress("UnusedPrivateMember") @Composable -private fun GeneratingIndicator(modifier: Modifier = Modifier) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp) - Text( - text = "답장을 쓰고 있어요", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) +private fun ChatRoomLightPreview() { + GamssTheme(darkTheme = false) { + ChatRoomPreviewContent() } } +@Preview( + name = "Dark", + showBackground = true, + backgroundColor = 0xFF000000, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Suppress("UnusedPrivateMember") @Composable -private fun ReplyTargetBanner( - replyTarget: ReplyTarget, - onClear: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(start = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "↩ ${replyTarget.characterName}에게 답장", - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = onClear) { - Icon(imageVector = Icons.Filled.Close, contentDescription = "답장 취소") - } +private fun ChatRoomDarkPreview() { + GamssTheme(darkTheme = true) { + ChatRoomPreviewContent() } } @Composable -private fun MessageInputBar( - input: String, - canSend: Boolean, - onInputChange: (String) -> Unit, - onSendClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = input, - onValueChange = onInputChange, - modifier = Modifier.weight(1f), - placeholder = { Text("지금 기분을 적어보세요") }, - supportingText = { Text("${input.length}/$MAX_MESSAGE_LENGTH") }, - maxLines = 4, - ) - IconButton(onClick = onSendClick, enabled = canSend) { - Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "보내기") - } - } -} - -private fun Context.dialOrNotify(phoneNumber: String?) { - if (phoneNumber == null) return - if (!dial(phoneNumber)) { - Toast.makeText( - this, - getString(R.string.safety_call_unavailable, phoneNumber), - Toast.LENGTH_LONG, - ).show() - } +private fun ChatRoomPreviewContent() { + val messages = listOf( + Message( + id = 1, + conversationId = 1, + sender = MessageSender.Character(EmotionCharacter.JOY), + content = "안녕! 오늘도 행복한 하루~!", + createdTime = "오후 1:38", + ), + Message( + id = 2, + conversationId = 1, + sender = MessageSender.User, + content = "안녕하세요 반가워요", + createdTime = "오후 1:39", + ), + Message( + id = 3, + conversationId = 1, + sender = MessageSender.Character(EmotionCharacter.SADNESS), + content = "오늘은 좀 힘든 하루였어요", + repliesToMessageId = 2, + createdTime = "오후 1:40", + ), + ) + val state = ChatRoomState( + conversationId = 1, + messages = messages, + input = "", + useChatEndFeature = true, + replyTarget = ReplyTarget( + messageId = 1, + characterName = "기쁨", + content = "안녕! 오늘도 행복한 하루~!", + ), + ) + val actions = ChatRoomActions( + onInputChange = {}, + onSendClick = {}, + onCharacterMessageClick = {}, + onReplyTargetClear = {}, + onEndClick = {}, + onTokenUsageToggle = {}, + onTokenUsageRetry = {} + ) + ChatRoomContent(state = state, actions = actions, onBackClick = {}) } - -@Suppress("SwallowedException") -private fun Context.dial(phoneNumber: String): Boolean = - try { - startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:${Uri.encode(phoneNumber)}"))) - true - } catch (_: ActivityNotFoundException) { - false - } - -private const val EMERGENCY_PHONE_NUMBER = "119" diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt index 40df4eb2..09696bf0 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomState.kt @@ -3,9 +3,11 @@ package com.gamss.android.feature.chat import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.safety.RiskDetection +import java.time.LocalDateTime data class ChatRoomState( val conversationId: Long? = null, + val conversationCreatedAt: LocalDateTime? = null, val messages: List = emptyList(), val pendingComments: List = emptyList(), val input: String = "", @@ -16,12 +18,18 @@ data class ChatRoomState( val riskDetection: RiskDetection? = null, /** 원격 설정 `use_chat_end_feature`. 꺼져 있으면 대화 끝내기 버튼을 숨긴다. */ val useChatEndFeature: Boolean = false, + val tokenUsagePercent: Int? = null, + val isTokenUsagePopupExpanded: Boolean = false, + val isTokenUsageLoading: Boolean = false, ) { /** 종료 흐름이 시작된 뒤로는 막는다. 종료 API 가 도는 중에 보내면 저장 여부가 갈린다. */ val canSend: Boolean get() = input.isNotBlank() && !isSending && !isLoading && endFlow == EndFlow.NotStarted - val isAwaitingComments: Boolean get() = isSending || pendingComments.isNotEmpty() + // isSending(내 전송이 서버 왕복 중)은 여기 포함하지 않는다 — "입력중" 버블은 상대가 답장을 + // 준비 중일 때만 보여야 하고, 내 메시지 전송 중이라는 것과는 다른 신호다. 전송 중 UI 피드백은 + // 입력창의 전송 버튼 비활성화(MessageInputBar의 isSending)로 이미 충분하다. + val isAwaitingComments: Boolean get() = pendingComments.isNotEmpty() /** 보낸 메시지가 있어야 카드를 만들 감정과 요약이 나온다. 종료 단계와 무관한 조건이다. */ val endPreconditionsMet: Boolean @@ -34,4 +42,5 @@ data class ChatRoomState( data class ReplyTarget( val messageId: Long, val characterName: String, + val content: String, ) diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt index 34170837..93e38fb7 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/ChatRoomViewModel.kt @@ -15,6 +15,7 @@ import com.gamss.android.domain.conversation.takeWithinMessageLimit import com.gamss.android.domain.repository.TokenUsageRefreshNotifier import com.gamss.android.domain.safety.DetectRiskInTextUseCase import com.gamss.android.domain.safety.RiskLevel +import com.gamss.android.domain.usecase.GetDailyTokenUsageUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -34,6 +35,7 @@ class ChatRoomViewModel @Inject constructor( private val tokenUsageRefreshNotifier: TokenUsageRefreshNotifier, private val detectRiskInText: DetectRiskInTextUseCase, private val getRemoteConfigFlag: GetRemoteConfigFlagUseCase, + private val getDailyTokenUsageUseCase: GetDailyTokenUsageUseCase, ) : ViewModel(), ContainerHost { @@ -68,8 +70,9 @@ class ChatRoomViewModel @Inject constructor( reduce { state.copy( isLoading = false, - messages = listOf(pending.message), - pendingComments = pending.comments, + conversationCreatedAt = pending.createdAt, + messages = listOf(pending.sent.message), + pendingComments = pending.sent.comments, ) } launchCommentReveal() @@ -78,7 +81,14 @@ class ChatRoomViewModel @Inject constructor( when (val result = session.restore(conversationId)) { is AppResult.Success -> - reduce { state.copy(isLoading = false, messages = result.data, pendingComments = emptyList()) } + reduce { + state.copy( + isLoading = false, + conversationCreatedAt = result.data.conversation.createdAt, + messages = result.data.messages, + pendingComments = emptyList(), + ) + } is AppResult.Failure -> { reduce { state.copy(isLoading = false) } postSideEffect(ChatRoomSideEffect.ShowToast(LOAD_FAILED)) @@ -94,7 +104,11 @@ class ChatRoomViewModel @Inject constructor( val character = (message.sender as? MessageSender.Character)?.character ?: return@intent reduce { state.copy( - replyTarget = ReplyTarget(messageId = message.id, characterName = character.displayName), + replyTarget = ReplyTarget( + messageId = message.id, + characterName = character.displayName, + content = message.content, + ), ) } } @@ -103,6 +117,57 @@ class ChatRoomViewModel @Inject constructor( reduce { state.copy(replyTarget = null) } } + fun onTokenUsageToggle() = intent { + var opened = false + var needsFetch = false + reduce { + opened = !state.isTokenUsagePopupExpanded + needsFetch = opened && state.tokenUsagePercent == null + state.copy(isTokenUsagePopupExpanded = opened) + } + if (needsFetch) refreshTokenUsage() + } + + fun onTokenUsageRetry() = intent { + refreshTokenUsage() + } + + /** + * 사용자가 직접 요청한 조회(팝업 열기·재시도)는 실패를 그대로 반영해야 재시도 UI가 뜬다. + * [onSend]의 백그라운드 갱신처럼 조용히 이전 값을 유지하는 fallback을 여기선 쓰지 않는다. + * 팝업이 조회 중임을 알 수 있도록 요청 전후로 isTokenUsageLoading을 함께 반영한다. + */ + private suspend fun ChatRoomSyntax.refreshTokenUsage() { + reduce { state.copy(isTokenUsageLoading = true) } + val percent = fetchTokenUsagePercent() + reduce { state.copy(tokenUsagePercent = percent, isTokenUsageLoading = false) } + } + + /** + * [onSend] 성공 직후의 백그라운드 갱신. 메시지를 반영하는 reduce와 분리된 별도 인텐트로 + * 띄워, 사용량 조회가 느려도 이미 도착한 메시지 표시가 지연되지 않게 한다. 실패 시엔 + * 이전 값을 조용히 유지한다. + */ + private fun refreshTokenUsageInBackground() { + intent { + val usagePercent = fetchTokenUsagePercent() + reduce { state.copy(tokenUsagePercent = usagePercent ?: state.tokenUsagePercent) } + } + } + + /** + * 조회 실패는 채팅 자체를 막을 이유가 없어 화면에는 조용히 null 로만 반영한다. + */ + private suspend fun fetchTokenUsagePercent(): Int? = + when (val result = getDailyTokenUsageUseCase()) { + is AppResult.Success -> { + result.data.usagePercent + } + is AppResult.Failure -> { + null + } + } + fun onSend() = intent { flushPendingComments() @@ -146,6 +211,7 @@ class ChatRoomViewModel @Inject constructor( when (result) { is AppResult.Success -> { val sent = result.data + tokenUsageRefreshNotifier.requestRefresh() reduce { state.copy( isSending = false, @@ -158,8 +224,8 @@ class ChatRoomViewModel @Inject constructor( } launchCommentReveal() sent.commentStatus.toUserMessage()?.let { postSideEffect(ChatRoomSideEffect.ShowToast(it)) } - tokenUsageRefreshNotifier.requestRefresh() session.finishSend() + refreshTokenUsageInBackground() } is AppResult.Failure -> { reduce { state.copy(isSending = false) } 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 6f04a21f..318585b8 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingListTopBar.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingListTopBar.kt @@ -1,18 +1,9 @@ package com.gamss.android.feature.chat.component import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigation import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationContent import com.gamss.android.core.designsystem.topnavigation.GamssTopNavigationIcon @@ -42,9 +33,18 @@ internal fun ChattingListTopBar( GamssTopNavigationContent.Logo }, showLeftIcon = isSelectionMode, - leftIconContentDescription = stringResource(R.string.chatting_list_selection_cancel), onLeftIconClick = onSelectionCancel, - rightActions = listOf( + leftIconContentDescription = stringResource(R.string.chatting_list_selection_cancel), + rightActions = listOfNotNull( + // 선택 모드에서는 검색으로 들어갈 수 없다 + if (!isSelectionMode) { + GamssTopNavigationIconAction( + icon = GamssTopNavigationIcon.Search, + onClick = onSearchClick, + ) + } else { + null + }, GamssTopNavigationIconAction( icon = GamssTopNavigationIcon.Menu, onClick = onMenuClick, @@ -52,24 +52,5 @@ internal fun ChattingListTopBar( ), ), ) - - if (!isSelectionMode) { - // GamssTopNavigation의 오른쪽에 복수의 아이콘 받는 컴포넌트로 변경되었을때 이 컴포넌트 삭제 예정 (검색 모드 진입을 위한 임시조치) - IconButton( - onClick = onSearchClick, - modifier = Modifier - .align(Alignment.CenterEnd) - .offset(x = (-42).dp, y = 3.dp) - .size(48.dp), - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = stringResource( - R.string.chatting_list_search_content_description, - ), - tint = GamssTheme.colors.gray900, - ) - } - } } } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingSearchContent.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingSearchContent.kt index 1101b922..db751be3 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingSearchContent.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/ChattingSearchContent.kt @@ -155,6 +155,7 @@ private fun SearchInput( unfocusedContainerColor = GamssTheme.colors.gray075, focusedIndicatorColor = GamssTheme.colors.gray075, unfocusedIndicatorColor = GamssTheme.colors.gray075, + cursorColor = GamssTheme.colors.gray900 ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { onSearch() }), diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EmotionAvatarIcon.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EmotionAvatarIcon.kt new file mode 100644 index 00000000..6f363fc1 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EmotionAvatarIcon.kt @@ -0,0 +1,16 @@ +package com.gamss.android.feature.chat.component + +import androidx.annotation.DrawableRes +import com.gamss.android.domain.emotion.EmotionCharacter +import com.gamss.android.core.designsystem.R as DesignSystemR + +/** [EmotionCharacter] 6종 각각에 대응하는 core:designsystem 아바타 아이콘. */ +internal val EmotionCharacter.avatarIconRes: Int + @DrawableRes get() = when (this) { + EmotionCharacter.JOY -> DesignSystemR.drawable.ic_avatar_joy + EmotionCharacter.ANGER -> DesignSystemR.drawable.ic_avatar_anger + EmotionCharacter.ANXIETY -> DesignSystemR.drawable.ic_avatar_anxiety + EmotionCharacter.SADNESS -> DesignSystemR.drawable.ic_avatar_sadness + EmotionCharacter.QUIRKY -> DesignSystemR.drawable.ic_avatar_quirky + EmotionCharacter.PRICKLY -> DesignSystemR.drawable.ic_avatar_prickly + } diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EndConversationDialog.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EndConversationDialog.kt new file mode 100644 index 00000000..2b77d604 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/EndConversationDialog.kt @@ -0,0 +1,29 @@ +package com.gamss.android.feature.chat.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.gamss.android.core.designsystem.button.GamssButtonVariant +import com.gamss.android.core.designsystem.dialog.GamssDialog +import com.gamss.android.core.designsystem.dialog.GamssDialogAction +import com.gamss.android.feature.chat.R + +@Composable +fun EndConversationDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + GamssDialog( + title = stringResource(R.string.chat_room_end_dialog_title), + subtitle = stringResource(R.string.chat_room_end_dialog_subTitle), + secondaryAction = GamssDialogAction( + label = stringResource(R.string.chat_room_end_dialog_dismiss_button_label), + onClick = onDismiss, + variant = GamssButtonVariant.Secondary, + ), + primaryAction = GamssDialogAction( + label = stringResource(R.string.chat_room_end_dialog_confirm_button_label), + onClick = onConfirm, + ), + onDismissRequest = onDismiss, + ) +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt new file mode 100644 index 00000000..d333b30d --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageBubble.kt @@ -0,0 +1,114 @@ +package com.gamss.android.feature.chat.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote +import com.gamss.android.core.designsystem.component.chat.ChatSender +import com.gamss.android.core.designsystem.component.chat.GamssLoadingMessageBubble +import com.gamss.android.core.designsystem.component.chat.GamssReceivedChatBubble +import com.gamss.android.core.designsystem.component.chat.GamssSentChatBubble +import com.gamss.android.core.designsystem.modifier.noRippleCombinedClickable +import com.gamss.android.domain.conversation.Message +import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.domain.emotion.EmotionCharacter +import com.gamss.android.feature.chat.R + +/** + * @param replyQuote [message]가 답장이면 그 대상의 인용 정보. 호출부(리스트)가 한 번에 미리 + * 찾아서 넘긴다 — 이 버블은 전체 메시지 목록을 몰라도 되고, 목록이 커져도(다른 메시지가 + * 추가돼도) 이 값이 그대로면 재구성을 건너뛸 수 있다. + */ +@Composable +fun MessageBubble( + message: Message, + replyQuote: ChatReplyQuote?, + onCharacterMessageClick: (Message) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxWidth(), + ) { + when (val sender = message.sender) { + MessageSender.User -> GamssSentChatBubble( + message = message.content, + time = message.createdTime, + replyQuote = replyQuote, + modifier = Modifier.align(Alignment.CenterEnd), + ) + + is MessageSender.Character -> GamssReceivedChatBubble( + sender = ChatSender( + name = sender.character.displayName, + avatar = { CharacterAvatarImage(sender.character) }, + ), + message = message.content, + time = message.createdTime, + replyQuote = replyQuote, + modifier = Modifier + .align(Alignment.CenterStart) + .noRippleCombinedClickable( + onClick = { onCharacterMessageClick(message) }, + onLongClick = null + ), + ) + + MessageSender.Unknown -> GamssReceivedChatBubble( + sender = ChatSender(name = ""), + message = message.content, + time = message.createdTime, + replyQuote = replyQuote, + modifier = Modifier.align(Alignment.CenterStart), + ) + } + } +} + +/** + * @param character 다음에 도착할 답장의 발신자. pendingComments의 첫 메시지 등으로 미리 알 때 + * 넘기면 빈 프로필 대신 그 캐릭터 아바타와 이름을 보여준다. + */ +@Composable +fun LoadingMessageBubble(character: EmotionCharacter? = null) { + val senderStatus = if (character != null) { + character.displayName + } else { + stringResource(R.string.chat_room_message_writing_label) + } + GamssLoadingMessageBubble( + senderStatus = senderStatus, + avatar = character?.let { { CharacterAvatarImage(it) } }, + ) +} + +@Composable +private fun CharacterAvatarImage(character: EmotionCharacter) { + Image( + painter = painterResource(character.avatarIconRes), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) +} + +/** [ChatRoomScreen]의 리스트가 아이템별 [MessageBubble.replyQuote]를 미리 계산할 때도 쓴다. */ +@Composable +internal fun Message.toReplyQuote(): ChatReplyQuote { + val label = when (val target = sender) { + is MessageSender.Character -> stringResource( + R.string.chat_room_reply_to_character, + target.character.displayName, + ) + + MessageSender.User -> stringResource(R.string.chat_room_reply_to_me) + MessageSender.Unknown -> stringResource(R.string.chat_room_reply) + } + return ChatReplyQuote(senderLabel = label, message = content) +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt new file mode 100644 index 00000000..23c41c70 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/MessageInputBar.kt @@ -0,0 +1,52 @@ +package com.gamss.android.feature.chat.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.component.GamssInputBar +import com.gamss.android.core.designsystem.component.chat.ChatReplyQuote +import com.gamss.android.feature.chat.R +import com.gamss.android.feature.chat.ReplyTarget + +@Composable +fun MessageInputBar( + input: String, + enabled: Boolean, + isSending: Boolean, + replyTarget: ReplyTarget?, + onInputChange: (String) -> Unit, + onSendClick: () -> Unit, + onReplyClear: () -> Unit, +) { + GamssInputBar( + value = input, + onValueChange = onInputChange, + onTrailingClick = onSendClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 12.dp), + placeholder = stringResource(R.string.chat_room_input_placeholder), + trailingContentDescription = "보내기", + enabled = enabled, + // 전송 중에도 입력칸 포커스·키보드는 유지하되(enabled), 전송 버튼만 잠가 중복 전송을 + // 시각적으로도 막는다. + sendEnabled = enabled && !isSending, + maxLines = MESSAGE_INPUT_MAX_LINES, + replyQuote = replyTarget?.let { + ChatReplyQuote( + senderLabel = stringResource( + R.string.chat_room_reply_to_character, + it.characterName + ), + message = it.content, + ) + }, + onReplyClear = onReplyClear, + replyClearContentDescription = "답장 취소", + ) +} + +private const val MESSAGE_INPUT_MAX_LINES = 5 diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/NewMessageToast.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/NewMessageToast.kt new file mode 100644 index 00000000..8712f21a --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/NewMessageToast.kt @@ -0,0 +1,148 @@ +package com.gamss.android.feature.chat.component + +import android.content.res.Configuration +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.gamss.android.core.designsystem.component.GamssText +import com.gamss.android.core.designsystem.modifier.gamssShadow +import com.gamss.android.core.designsystem.modifier.noRippleCombinedClickable +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.domain.conversation.Message +import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.domain.emotion.EmotionCharacter + +// 디자인 상 각지게 그려진다(둥근 pill 아님). +private val ToastShape = RectangleShape +private val AvatarSize = 16.dp +private val ToastStrokeWidth = 0.5.dp +private val ToastHorizontalMargin = 40.dp +private val ToastHorizontalPadding = 16.dp +private val ToastVerticalPadding = 10.dp +private val ToastContentGap = 8.dp + +/** + * 스크롤을 올려 최신 메시지가 화면 밖으로 벗어났을 때, 새로 도착한 메시지를 알리는 토스트. + * + * 좌우 [ToastHorizontalMargin]까지만 늘어나고 그 안에서 내용에 맞춰 너비가 줄어든다. 넘치는 + * 내용은 말줄임된다. 탭하기 전까지 자동으로 사라지지 않으므로 dismiss 콜백은 따로 두지 않고, + * 탭하면 [onClick] 하나로 "확인 + 최신 메시지로 이동"을 함께 처리한다. + */ +@Composable +fun NewMessageToast( + message: Message, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + // 발신자는 항상 캐릭터다 — 본인이 보낸 메시지는 호출부에서 애초에 이 토스트 대상으로 넘기지 + // 않는다. Unknown 은 MessageBubble 과 동일하게 빈 이름/빈 아바타로 둔다. + val character = (message.sender as? MessageSender.Character)?.character + val senderName = character?.displayName.orEmpty() + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = ToastHorizontalMargin) + .gamssShadow(shape = ToastShape) + .clip(ToastShape) + .background(GamssTheme.colors.gray800, ToastShape) + .border(ToastStrokeWidth, GamssTheme.colors.gray950, ToastShape) + .noRippleCombinedClickable(onClick = onClick, onLongClick = null) + .padding(horizontal = ToastHorizontalPadding, vertical = ToastVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + ToastAvatar(avatarIconRes = character?.avatarIconRes) + GamssText( + text = senderName, + modifier = Modifier.padding(start = ToastContentGap), + style = GamssTheme.typography.body5Medium, + color = GamssTheme.colors.gray300, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + GamssText( + text = message.content, + modifier = Modifier + .padding(start = ToastContentGap) + .weight(weight = 1f, fill = false), + style = GamssTheme.typography.body5Medium, + color = GamssTheme.colors.gray025, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ToastAvatar(@DrawableRes avatarIconRes: Int?, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(AvatarSize) + .clip(CircleShape) + .background(GamssTheme.colors.gray025), + ) { + if (avatarIconRes != null) { + Image( + painter = painterResource(avatarIconRes), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Preview(name = "Light", showBackground = true) +@Suppress("UnusedPrivateMember") +@Composable +private fun NewMessageToastLightPreview() { + GamssTheme(darkTheme = false) { + NewMessageToastPreviewContent() + } +} + +@Preview( + name = "Dark", + showBackground = true, + backgroundColor = 0xFF000000, + uiMode = Configuration.UI_MODE_NIGHT_YES, +) +@Suppress("UnusedPrivateMember") +@Composable +private fun NewMessageToastDarkPreview() { + GamssTheme(darkTheme = true) { + NewMessageToastPreviewContent() + } +} + +@Composable +private fun NewMessageToastPreviewContent() { + NewMessageToast( + message = Message( + id = 1, + conversationId = 1, + sender = MessageSender.Character(EmotionCharacter.PRICKLY), + content = "안녕하세요", + createdTime = "오후 1:38", + ), + onClick = {}, + ) +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/SupportAgencyDialog.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/SupportAgencyDialog.kt index 7641dd92..97a77a14 100644 --- a/feature/chat/src/main/java/com/gamss/android/feature/chat/component/SupportAgencyDialog.kt +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/component/SupportAgencyDialog.kt @@ -37,11 +37,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import com.gamss.android.core.designsystem.button.GamssButton import com.gamss.android.core.designsystem.theme.GamssTheme import com.gamss.android.domain.safety.SupportAgency import com.gamss.android.feature.chat.R -private val DialogShape = RoundedCornerShape(28.dp) +private val DialogShape = RoundedCornerShape(16.dp) private val SupportPanelShape = RoundedCornerShape(20.dp) private val ActionShape = RoundedCornerShape(12.dp) @@ -50,7 +51,6 @@ internal fun SupportAgencyDialog( agencies: List, onCallClick: (SupportAgency) -> Unit, onEmergencyCallClick: () -> Unit, - onConfirm: () -> Unit, onDismiss: () -> Unit, ) { Dialog( @@ -60,7 +60,7 @@ internal fun SupportAgencyDialog( Box( modifier = Modifier .fillMaxSize() - .padding(horizontal = GamssTheme.spacing.spacing300), + .padding(horizontal = GamssTheme.spacing.spacing550), contentAlignment = Alignment.Center, ) { Surface( @@ -68,12 +68,12 @@ internal fun SupportAgencyDialog( .fillMaxWidth() .widthIn(max = 520.dp), shape = DialogShape, - color = GamssTheme.colors.gray025, + color = GamssTheme.colors.white, ) { Column( modifier = Modifier .verticalScroll(rememberScrollState()) - .padding(GamssTheme.spacing.spacing500), + .padding(GamssTheme.spacing.spacing400), verticalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing500), ) { DialogHeader() @@ -82,7 +82,11 @@ internal fun SupportAgencyDialog( onCallClick = onCallClick, onEmergencyCallClick = onEmergencyCallClick, ) - DialogActions(onDismiss = onDismiss, onConfirm = onConfirm) + GamssButton( + modifier = Modifier.fillMaxWidth(), + label = stringResource(R.string.safety_agency_close_button_label), + onClick = onDismiss, + ) } } } @@ -216,63 +220,16 @@ private fun AdditionalInfo() { painter = painterResource(R.drawable.ic_info), contentDescription = null, tint = GamssTheme.colors.gray300, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(18.dp), ) Text( text = stringResource(R.string.chat_room_risk_dialog_additional_info), - style = GamssTheme.typography.body5Regular, + style = GamssTheme.typography.body6Medium, color = GamssTheme.colors.gray500, ) } } -@Composable -private fun DialogActions( - onDismiss: () -> Unit, - onConfirm: () -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(GamssTheme.spacing.spacing100), - ) { - DialogActionButton( - label = stringResource(R.string.chat_room_end_dialog_dismiss_button_label), - containerColor = GamssTheme.colors.gray100, - contentColor = GamssTheme.colors.gray600, - onClick = onDismiss, - modifier = Modifier.weight(1f), - ) - DialogActionButton( - label = stringResource(R.string.chat_room_risk_dialog_confirm_button_label), - containerColor = GamssTheme.colors.gray950, - contentColor = GamssTheme.colors.gray025, - onClick = onConfirm, - modifier = Modifier.weight(1f), - ) - } -} - -@Composable -private fun DialogActionButton( - label: String, - containerColor: Color, - contentColor: Color, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .height(56.dp) - .clip(ActionShape) - .background(containerColor) - .clickable(onClick = onClick) - .semantics { role = Role.Button }, - contentAlignment = Alignment.Center, - ) { - Text(text = label, style = GamssTheme.typography.subtitle3, color = contentColor) - } -} - @Composable private fun CallIcon(tint: Color) { Icon( @@ -296,7 +253,6 @@ private fun SupportAgencyDialogPreview() { ), onCallClick = {}, onEmergencyCallClick = {}, - onConfirm = {}, onDismiss = {}, ) } 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 new file mode 100644 index 00000000..99b6915f --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatMessageAnimation.kt @@ -0,0 +1,93 @@ +package com.gamss.android.feature.chat.util + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember + +/** + * 진입시 표시되는 메시지는 그대로 표시하고, 그 이후 목록에 추가된 메시지만 진입 애니메이션 대상으로 관리한다. + */ +internal class ChatMessageAnimation { + private val displayedMessageIds = mutableSetOf() + private var hasCapturedInitialMessages = false + + fun shouldAnimate(messageId: Long): Boolean = + hasCapturedInitialMessages && messageId !in displayedMessageIds + + fun markDisplayed(messageIds: List) { + displayedMessageIds += messageIds + hasCapturedInitialMessages = true + } +} + +@Composable +internal fun rememberChatMessageAnimationState( + conversationId: Long?, + isLoading: Boolean, + messageIds: List, +): ChatMessageAnimation { + val animationState = remember(conversationId) { ChatMessageAnimation() } + + SideEffect { + if (conversationId != null && !isLoading) { + animationState.markDisplayed(messageIds) + } + } + + return animationState +} + +/** 메시지 목록 하단(입력창과 맞닿는 위치)에서 최종 말풍선 위치까지 메시지를 올려 보낸다. */ +@Composable +internal fun AnimatedChatMessage( + messageId: Long, + shouldAnimate: Boolean, + listState: LazyListState, + content: @Composable () -> Unit, +) { + if (!shouldAnimate) { + content() + return + } + + val visibilityState = remember(messageId) { + MutableTransitionState(false).apply { targetState = true } + } + AnimatedVisibility( + visibleState = visibilityState, + // slide 와 fade 를 동일한 스프링 스펙으로 묶어, 두 효과가 서로 다른 타이밍으로 끝나며 + // 끊겨 보이던 문제를 없애고 하나의 유려한 움직임으로 이어지게 한다. + enter = slideInVertically( + animationSpec = spring( + dampingRatio = MESSAGE_ENTER_DAMPING_RATIO, + stiffness = MESSAGE_ENTER_STIFFNESS, + ), + initialOffsetY = { messageHeight -> + listState.offsetFromInput(messageId, messageHeight) + }, + ) + fadeIn( + animationSpec = spring( + dampingRatio = MESSAGE_ENTER_DAMPING_RATIO, + stiffness = MESSAGE_ENTER_STIFFNESS, + ), + ), + ) { + content() + } +} + +private fun LazyListState.offsetFromInput(messageId: Long, messageHeight: Int): Int { + val messageItem = layoutInfo.visibleItemsInfo.firstOrNull { it.key == messageId } + ?: return messageHeight + return (layoutInfo.viewportEndOffset - messageItem.offset).coerceAtLeast(messageHeight) +} + +private const val MESSAGE_ENTER_DAMPING_RATIO = Spring.DampingRatioLowBouncy +private const val MESSAGE_ENTER_STIFFNESS = Spring.StiffnessMediumLow diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt new file mode 100644 index 00000000..81e05b90 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/ChatScrollState.kt @@ -0,0 +1,178 @@ +package com.gamss.android.feature.chat.util + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import com.gamss.android.domain.conversation.Message +import com.gamss.android.domain.conversation.MessageSender +import com.gamss.android.feature.chat.ChatRoomState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * 채팅 목록의 "바닥 유지" 동작을 관리한다. + * + * - 최초 진입(과거 메시지 로딩 완료) 시 한 번 바닥으로 점프한다. + * - 내가 메시지를 보내면(전송 완료 시점) 항상 바닥까지 스크롤한다. 사용자의 명시적 행동이라 + * 상대 메시지 도착 시와 달리 자동 스크롤을 유지한다. + * - 상대 메시지가 도착했을 때, 도착 직전에 이미 바닥을 보고 있었다면(=직전 마지막 메시지가 + * 화면에 보이고 있었다면) 그대로 따라가며 바닥까지 자동 스크롤한다. 이미 위로 스크롤해 + * 과거를 보고 있었다면 자동 스크롤하지 않고 [newMessageToast]를 채워 안내하며, 그 메시지가 + * 화면에 들어오면(사용자가 스크롤해서 직접 봤으면) 지운다. + */ +internal class ChatScrollState( + private val listState: LazyListState, + private val coroutineScope: CoroutineScope, +) { + var newMessageToast by mutableStateOf(null) + private set + + var hasScrolledToInitialBottom by mutableStateOf(false) + private set + + private var lastSeenMessageId: Long? = null + + // 내 전송 완료로 인한 자동 스크롤이 방금 반영한 메시지 id. handleNewMessage 가 같은 메시지를 + // 또 새 메시지로 처리해 토스트를 잠깐 띄웠다 지우는 걸 막는다. + private var lastAutoScrolledMessageId: Long? = null + + // handleSendingChanged 가 true→false 전환만 골라내는 데 쓰는 직전 값. + private var wasSending = false + + // 전송이 시작된 시점(isSending false→true)의 마지막 메시지 id. 전송이 끝났을 때(true→false) + // 이 값과 비교해 성공 여부를 판단한다 — 실패(네트워크 오류, 위험 감지 차단 등)하면 메시지가 추가되지 + // 않아 id가 그대로다. 이 비교 없이 isSending 전환만 보면, 위로 스크롤해 과거를 보는 중에 전송이 + // 실패해도 newMessageToast가 지워지고 바닥으로 점프해버린다. + private var lastMessageIdBeforeSend: Long? = null + + val showScrollToBottomButton: Boolean by derivedStateOf { + listState.canScrollForward && newMessageToast == null + } + + fun dismissToastAndScrollToBottom(state: ChatRoomState) { + newMessageToast = null + scrollToBottom(state) + } + + fun scrollToBottom(state: ChatRoomState) { + val index = state.lastItemIndex + if (index < 0) return + coroutineScope.launch { listState.scrollToItem(index) } + } + + suspend fun handleInitialLoad(state: ChatRoomState) { + if (hasScrolledToInitialBottom || state.conversationId == null || state.isLoading) return + if (state.messages.isNotEmpty()) { + listState.scrollToItem(state.lastItemIndex) + } + lastSeenMessageId = state.messages.lastOrNull()?.id + hasScrolledToInitialBottom = true + } + + /** [state]의 isSending 값이 바뀔 때마다 호출한다. true→false 전환일 때만 전송 완료 처리를 한다. */ + suspend fun handleSendingChanged(state: ChatRoomState) { + val isSending = state.isSending + if (!wasSending && isSending) { + lastMessageIdBeforeSend = state.messages.lastOrNull()?.id + } else if (wasSending && !isSending) { + val succeeded = state.messages.lastOrNull()?.id != lastMessageIdBeforeSend + handleSendConcluded(state, succeeded) + } + wasSending = isSending + } + + // 스크롤(애니메이션이라 시간이 걸림)보다 커서 갱신이 먼저 반영돼야, 그 사이 handleNewMessage + // 가 같은 메시지를 놓치고 토스트를 잠깐 띄우는 경합을 막을 수 있다. + private suspend fun handleSendConcluded(state: ChatRoomState, succeeded: Boolean) { + if (!succeeded || !hasScrolledToInitialBottom) return + lastAutoScrolledMessageId = state.messages.lastOrNull()?.id + newMessageToast = null + val index = state.lastItemIndex + if (index >= 0) { + listState.scrollToItem(index) + } + } + + /** 상대 메시지가 하나씩 도착할 때마다(코멘트 순차 공개 포함) 호출된다. */ + fun handleNewMessage(state: ChatRoomState) { + if (!hasScrolledToInitialBottom) return + val latest = state.messages.lastOrNull() + if (latest == null || latest.id == lastSeenMessageId) return + + // 새 메시지가 추가돼도 그 이전 메시지들의 화면상 위치는 바뀌지 않는다 — 그래서 이 메시지가 + // 새 메시지를 반영한 레이아웃 이후에 확인해도, "직전 마지막 메시지가 보이고 있었는지"는 + // 곧 "도착 직전에 바닥을 보고 있었는지"와 같은 뜻이다. + val wasAtBottom = lastSeenMessageId?.let(::isMessageVisible) ?: true + lastSeenMessageId = latest.id + + // 내 전송으로 이미 자동 스크롤된 메시지거나 내가 보낸 메시지면 토스트/자동 스크롤 대상이 + // 아니다 — 기존 토스트가 있다면 건드리지 않고 그대로 둔다. + val isToastCandidate = latest.id != lastAutoScrolledMessageId && latest.sender != MessageSender.User + if (isToastCandidate && wasAtBottom) { + // 이미 바닥을 보고 있었다면 새 메시지를 놓치지 않도록 그대로 따라 내려간다. + newMessageToast = null + scrollToBottom(state) + } else if (isToastCandidate) { + // 도착한 시점에 이미 화면에 보이는 메시지라면(뷰포트에 여유가 있어 스크롤 없이도 + // 보이는 경우) 안내할 필요가 없다. + newMessageToast = if (isMessageVisible(latest.id)) null else latest + } + } + + /** + * 화면에 보이는 아이템 목록이 바뀔 때마다(=스크롤할 때마다) 호출된다. 지금 토스트가 + * 가리키는 메시지가 화면에 들어왔으면 지운다. 리스트 맨 끝까지 스크롤하지 않아도, 그 + * 메시지 하나만 보이면 충분하다. + */ + fun clearToastIfMessageVisible() { + val toastMessageId = newMessageToast?.id ?: return + if (isMessageVisible(toastMessageId)) newMessageToast = null + } + + private fun isMessageVisible(messageId: Long): Boolean = + listState.layoutInfo.visibleItemsInfo.any { it.key == messageId } +} + +/** 가장 최근에 보이던 마지막 아이템의 인덱스. 코멘트 생성 표시(로딩)까지 바닥에 포함시킨다. */ +private val ChatRoomState.lastItemIndex: Int + get() = messages.size - 1 + if (isAwaitingComments) 1 else 0 + +@Composable +internal fun rememberChatScrollState( + state: ChatRoomState, + listState: LazyListState, +): ChatScrollState { + val coroutineScope = rememberCoroutineScope() + val scrollState = remember(state.conversationId) { ChatScrollState(listState, coroutineScope) } + + LaunchedEffect(state.conversationId, state.isLoading) { + scrollState.handleInitialLoad(state) + } + + LaunchedEffect(state.isSending) { + scrollState.handleSendingChanged(state) + } + + LaunchedEffect(state.messages.lastOrNull()?.id, scrollState.hasScrolledToInitialBottom) { + scrollState.handleNewMessage(state) + } + + LaunchedEffect(scrollState, listState) { + // 보이는 아이템 "목록"이 아니라 보이는 범위의 양 끝 인덱스만 본다 — 스크롤 중이면 매 + // 프레임 바뀌는 값이라, visibleItemsInfo 전체를 새 List로 매핑하는 비용을 피한다. 목록은 + // 항상 연속된 범위라 양 끝이 그대로면 그 안의 가시성도 그대로다. + snapshotFlow { + val visible = listState.layoutInfo.visibleItemsInfo + visible.firstOrNull()?.index to visible.lastOrNull()?.index + }.collect { scrollState.clearToastIfMessageVisible() } + } + + return scrollState +} diff --git a/feature/chat/src/main/java/com/gamss/android/feature/chat/util/Utils.kt b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/Utils.kt new file mode 100644 index 00000000..2b40cba3 --- /dev/null +++ b/feature/chat/src/main/java/com/gamss/android/feature/chat/util/Utils.kt @@ -0,0 +1,28 @@ +package com.gamss.android.feature.chat.util + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import com.gamss.android.feature.chat.R + +fun Context.dialOrNotify(phoneNumber: String?) { + if (phoneNumber == null) return + if (!dial(phoneNumber)) { + Toast.makeText( + this, + getString(R.string.safety_call_unavailable, phoneNumber), + Toast.LENGTH_LONG, + ).show() + } +} + +@Suppress("SwallowedException") +private fun Context.dial(phoneNumber: String): Boolean = + try { + startActivity(Intent(Intent.ACTION_DIAL, Uri.parse("tel:${Uri.encode(phoneNumber)}"))) + true + } catch (_: ActivityNotFoundException) { + false + } diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml index b172d0e1..c8bde7a3 100644 --- a/feature/chat/src/main/res/values/strings.xml +++ b/feature/chat/src/main/res/values/strings.xml @@ -4,6 +4,17 @@ 감정 기록 생성 시 대화는 종료되며,\n더 이상 대화를 이어갈 수 없어요. 뒤로가기 감정 확인하기 + 메시지 입력 + %1$s에게 답장 + 나에게 답장 + 답장 + 최신 메시지로 이동 + 토큰 사용량 + %1$d%% 사용됨 + 오전 5:00에 초기화됩니다 + 사용량을 불러오지 못했어요. + 재시도 + 입력 중 혼자 감당하지 않아도 괜찮아요. 대화에서 도움이 필요하다는 신호가 확인됐어요.\n전문가와 이야기해 보는 걸 추천드려요. 24시간 상담이 가능해요. 누르면 바로 연결돼요. @@ -12,6 +23,7 @@ %1$s %2$s에 전화하기 119에 전화하기 전화 앱을 열 수 없어요. %1$s로 직접 전화해 주세요. + 닫기 삭제하기 대화를 정말 삭제할까요? diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt index e671cd9f..b509128b 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomLoadTest.kt @@ -5,7 +5,7 @@ import com.gamss.android.domain.card.CreateConversationCardUseCase import com.gamss.android.domain.conversation.ConversationSession import com.gamss.android.domain.conversation.ConversationSummaryStore import com.gamss.android.domain.conversation.EndConversationUseCase -import com.gamss.android.domain.conversation.GetMessagesUseCase +import com.gamss.android.domain.conversation.GetConversationUseCase import com.gamss.android.domain.conversation.PendingConversationReveal import com.gamss.android.domain.conversation.SendMessageUseCase import com.gamss.android.domain.conversation.UpdateConversationTitleUseCase @@ -56,6 +56,8 @@ class ChatRoomLoadTest { listOf(COMMENT_ID_BASE + 0, COMMENT_ID_BASE + 1, COMMENT_ID_BASE + 2), afterLoad.pendingComments.map { it.id }, ) + // reveal 캐시 경로도 서버 재조회 없이 top bar 제목(생성 일시)을 즉시 채운다. + assertTrue(afterLoad.conversationCreatedAt != null) val firstReveal = awaitState() assertEquals(COMMENT_ID_BASE + 0, firstReveal.messages.last().id) @@ -93,7 +95,7 @@ class ChatRoomLoadTest { private fun homeSession(repository: FakeConversationRepository, pendingReveal: PendingConversationReveal) = ConversationSession( sendMessage = SendMessageUseCase(repository), - getMessages = GetMessagesUseCase(repository), + getConversation = GetConversationUseCase(repository), updateConversationTitle = UpdateConversationTitleUseCase(repository), endConversation = EndConversationUseCase(repository), createConversationCard = CreateConversationCardUseCase( diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt index 985f0c9c..4b31ab6f 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChatRoomRevealTest.kt @@ -46,6 +46,7 @@ class ChatRoomRevealTest { afterSend.pendingComments.map { it.id }, ) assertTrue(afterSend.isAwaitingComments) + skipItems(1) // 전송 성공 뒤 백그라운드로 갱신되는 토큰 사용량 반영 val firstReveal = awaitState() assertEquals(COMMENT_ID_BASE + 0, firstReveal.messages.last().id) @@ -76,6 +77,7 @@ class ChatRoomRevealTest { val afterSend = awaitState() assertEquals(4, afterSend.pendingComments.size) + skipItems(1) // 전송 성공 뒤 백그라운드로 갱신되는 토큰 사용량 반영 containerHost.onInputChange(INPUT) skipItems(1) // input 반영 @@ -102,6 +104,7 @@ class ChatRoomRevealTest { val afterSend = awaitState() assertEquals(1, afterSend.pendingComments.size) assertEquals(1, afterSend.messages.size) + skipItems(1) // 전송 성공 뒤 백그라운드로 갱신되는 토큰 사용량 반영 val afterReveal = awaitState() assertTrue(afterReveal.pendingComments.isEmpty()) 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 64f803e4..1807f0ec 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 @@ -12,11 +12,12 @@ import com.gamss.android.domain.config.RemoteConfigKey import com.gamss.android.domain.config.RemoteConfigRepository import com.gamss.android.domain.conversation.CommentGenerationStatus import com.gamss.android.domain.conversation.Conversation +import com.gamss.android.domain.conversation.ConversationDetail import com.gamss.android.domain.conversation.ConversationRepository import com.gamss.android.domain.conversation.ConversationSession import com.gamss.android.domain.conversation.ConversationSummaryStore import com.gamss.android.domain.conversation.EndConversationUseCase -import com.gamss.android.domain.conversation.GetMessagesUseCase +import com.gamss.android.domain.conversation.GetConversationUseCase import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.conversation.PendingConversationReveal @@ -29,6 +30,7 @@ import com.gamss.android.domain.emotion.ConversationEmotionAccumulator import com.gamss.android.domain.emotion.EmotionCharacter import com.gamss.android.domain.emotion.EmotionClassifier import com.gamss.android.domain.emotion.EmotionLabel +import com.gamss.android.domain.model.DailyTokenUsage import com.gamss.android.domain.repository.TokenUsageRefreshNotifier import com.gamss.android.domain.safety.DetectRiskInTextUseCase import com.gamss.android.domain.safety.RiskLexicon @@ -37,6 +39,9 @@ import com.gamss.android.domain.safety.RiskTermMatcher import com.gamss.android.domain.summary.DiarySummarizer import com.gamss.android.domain.summary.SummarizeDiaryUseCase import com.gamss.android.domain.summary.UtteranceTokenCounter +import com.gamss.android.domain.usecase.GetDailyTokenUsageUseCase +import com.gamss.android.domain.user.UserProfile +import com.gamss.android.domain.user.UserRepository import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -58,9 +63,18 @@ internal fun chatRoomViewModel( tokenUsageRefreshNotifier: TokenUsageRefreshNotifier = RecordingTokenUsageRefreshNotifier(), remoteConfigRepository: RemoteConfigRepository = FakeRemoteConfigRepository(), pendingReveal: PendingConversationReveal = PendingConversationReveal(), - session: ConversationSession = ConversationSession( + userRepository: UserRepository = FakeUserRepository(), +): ChatRoomViewModel = ChatRoomViewModel( + tokenUsageRefreshNotifier = tokenUsageRefreshNotifier, + detectRiskInText = DetectRiskInTextUseCase( + repository = NoRiskLexiconRepository, + matcher = RiskTermMatcher(), + ), + getRemoteConfigFlag = GetRemoteConfigFlagUseCase(remoteConfigRepository), + getDailyTokenUsageUseCase = GetDailyTokenUsageUseCase(userRepository), + session = ConversationSession( sendMessage = SendMessageUseCase(conversationRepository), - getMessages = GetMessagesUseCase(conversationRepository), + getConversation = GetConversationUseCase(conversationRepository), updateConversationTitle = UpdateConversationTitleUseCase(conversationRepository), endConversation = EndConversationUseCase(conversationRepository), createConversationCard = CreateConversationCardUseCase( @@ -74,14 +88,6 @@ internal fun chatRoomViewModel( emotionAccumulator = ConversationEmotionAccumulator(classifier), pendingReveal = pendingReveal, ), -): ChatRoomViewModel = ChatRoomViewModel( - tokenUsageRefreshNotifier = tokenUsageRefreshNotifier, - detectRiskInText = DetectRiskInTextUseCase( - repository = NoRiskLexiconRepository, - matcher = RiskTermMatcher(), - ), - getRemoteConfigFlag = GetRemoteConfigFlagUseCase(remoteConfigRepository), - session = session, ) /** 원격 설정 조회 없이 항상 켜진 값을 돌려준다. 값 자체를 검증하는 테스트는 별도로 stub 한다. */ @@ -108,6 +114,22 @@ private object NoRiskLexiconRepository : RiskLexiconRepository { override suspend fun refresh() = Unit } +/** 토큰 사용량 조회만 있으면 되는 테스트용 스텁. 채팅 흐름은 닉네임/계정 API 를 쓰지 않는다. */ +internal class FakeUserRepository( + private val usage: DailyTokenUsage = DailyTokenUsage(usedTokens = 0, dailyLimit = 100, exceeded = false), +) : UserRepository { + override suspend fun updateNickname(nickname: String): AppResult = + error("Not needed for this test") + + override suspend fun deleteUserAccount(): AppResult = + error("Not needed for this test") + + override suspend fun getUserInfo(): AppResult = + error("Not needed for this test") + + override suspend fun getDailyTokenUsage(): AppResult = AppResult.Success(usage) +} + /** 갱신 요청 횟수만 센다. 홈 쪽 수신은 feature:home 테스트가 본다. */ internal class RecordingTokenUsageRefreshNotifier : TokenUsageRefreshNotifier { var refreshCount = 0 @@ -143,6 +165,7 @@ internal class FakeConversationRepository( private val commentCount: Int = 0, private val failing: Boolean = false, private val endFailing: Boolean = false, + private val restoredConversation: Conversation = Conversation(id = ROOM_ID, title = null), ) : ConversationRepository { private var sentCount = 0 @@ -183,8 +206,13 @@ internal class FakeConversationRepository( ) } - override suspend fun getMessages(conversationId: Long): AppResult> = - AppResult.Success(emptyList()) + override suspend fun getConversation(conversationId: Long): AppResult = + AppResult.Success( + ConversationDetail( + conversation = restoredConversation, + messages = emptyList(), + ), + ) override suspend fun getOngoingConversations(): AppResult> = AppResult.Success(emptyList()) diff --git a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChattingListTestFakes.kt b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChattingListTestFakes.kt index a60e9534..b236e618 100644 --- a/feature/chat/src/test/java/com/gamss/android/feature/chat/ChattingListTestFakes.kt +++ b/feature/chat/src/test/java/com/gamss/android/feature/chat/ChattingListTestFakes.kt @@ -3,10 +3,10 @@ package com.gamss.android.feature.chat import androidx.paging.PagingData import com.gamss.android.core.common.AppResult import com.gamss.android.domain.conversation.Conversation +import com.gamss.android.domain.conversation.ConversationDetail import com.gamss.android.domain.conversation.ConversationRepository import com.gamss.android.domain.conversation.DeleteConversationsUseCase import com.gamss.android.domain.conversation.GetOngoingConversationsUseCase -import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.SentMessage import com.gamss.android.domain.conversation.chattingsearch.ChattingRoomSummary import com.gamss.android.domain.conversation.chattingsearch.SearchChattingRoomsUseCase @@ -75,7 +75,7 @@ internal class FakeChattingListRepository( excludeCharacters: Set, ): AppResult = error("목록 테스트에서 쓰지 않는다") - override suspend fun getMessages(conversationId: Long): AppResult> = + override suspend fun getConversation(conversationId: Long): AppResult = error("목록 테스트에서 쓰지 않는다") override suspend fun updateTitle(conversationId: Long, title: String): AppResult = diff --git a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt index c88e7534..f9b25bc5 100644 --- a/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt +++ b/feature/home/src/test/java/com/gamss/android/feature/home/HomeTestFakes.kt @@ -9,11 +9,12 @@ import com.gamss.android.domain.card.CreateCardUseCase import com.gamss.android.domain.card.CreateConversationCardUseCase import com.gamss.android.domain.conversation.CommentGenerationStatus import com.gamss.android.domain.conversation.Conversation +import com.gamss.android.domain.conversation.ConversationDetail import com.gamss.android.domain.conversation.ConversationRepository import com.gamss.android.domain.conversation.ConversationSession import com.gamss.android.domain.conversation.ConversationSummaryStore import com.gamss.android.domain.conversation.EndConversationUseCase -import com.gamss.android.domain.conversation.GetMessagesUseCase +import com.gamss.android.domain.conversation.GetConversationUseCase import com.gamss.android.domain.conversation.Message import com.gamss.android.domain.conversation.MessageSender import com.gamss.android.domain.conversation.PendingConversationReveal @@ -38,7 +39,7 @@ internal const val NEW_ROOM_ID = 42L internal fun conversationSession(repository: ConversationRepository) = ConversationSession( sendMessage = SendMessageUseCase(repository), - getMessages = GetMessagesUseCase(repository), + getConversation = GetConversationUseCase(repository), updateConversationTitle = UpdateConversationTitleUseCase(repository), endConversation = EndConversationUseCase(repository), createConversationCard = CreateConversationCardUseCase( @@ -105,8 +106,13 @@ internal class RecordingConversationRepository( override suspend fun getOngoingConversations(): AppResult> = AppResult.Success(emptyList()) - override suspend fun getMessages(conversationId: Long): AppResult> = - AppResult.Success(emptyList()) + override suspend fun getConversation(conversationId: Long): AppResult = + AppResult.Success( + ConversationDetail( + conversation = Conversation(id = conversationId, title = null), + messages = emptyList(), + ), + ) override suspend fun updateTitle(conversationId: Long, title: String): AppResult = AppResult.Success(Unit)