From 25824ddd0dabe60566a5f2d0e95f4a3d6350cc75 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Fri, 7 Aug 2026 03:03:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=ED=9E=88?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EB=A6=AC=20=ED=99=94=EB=A9=B4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Orbit MVI 프레임워크를 사용하여 대화 내역 목록 조회 및 삭제 기능을 구현한다. `HistoryViewModel`에서 UseCase를 호출하여 상태를 관리하며, `HistoryRoute`를 통해 UI와 연결한다. - `HistoryScreen`: 대화 목록, 빈 화면 상태, '새 채팅' 플로팅 버튼을 포함하는 UI 구성 - `HistoryViewModel`: Orbit의 `intent`를 사용해 로딩 상태 처리 및 대화 삭제 로직 구현 - `HistoryConversationUiModel`: 도메인 모델을 UI 전용 모델로 변환하며, `DateUtils`를 사용해 마지막 메시지 시간을 상대 시간(예: 30분 전)으로 표시 - `MainActivity`: 앱의 진입점을 기존 샘플 코드에서 `HistoryRoute`로 교체 --- .../java/com/kikidan/chat/HistoryRoute.kt | 45 ++++ .../java/com/kikidan/chat/HistoryViewModel.kt | 44 ++++ .../chat/model/HistoryConversationUiModel.kt | 25 +++ .../kikidan/chat/model/HistorySideEffect.kt | 7 + .../com/kikidan/chat/model/HistoryState.kt | 8 + .../com/kikidan/chat/screen/HistoryScreen.kt | 195 ++++++++++++++++++ 6 files changed, 324 insertions(+) create mode 100644 feature/chat/src/main/java/com/kikidan/chat/HistoryRoute.kt create mode 100644 feature/chat/src/main/java/com/kikidan/chat/HistoryViewModel.kt create mode 100644 feature/chat/src/main/java/com/kikidan/chat/model/HistoryConversationUiModel.kt create mode 100644 feature/chat/src/main/java/com/kikidan/chat/model/HistorySideEffect.kt create mode 100644 feature/chat/src/main/java/com/kikidan/chat/model/HistoryState.kt create mode 100644 feature/chat/src/main/java/com/kikidan/chat/screen/HistoryScreen.kt diff --git a/feature/chat/src/main/java/com/kikidan/chat/HistoryRoute.kt b/feature/chat/src/main/java/com/kikidan/chat/HistoryRoute.kt new file mode 100644 index 0000000..a2d303e --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/HistoryRoute.kt @@ -0,0 +1,45 @@ +package com.kikidan.chat + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.kikidan.chat.model.HistorySideEffect +import com.kikidan.chat.model.toUiModel +import com.kikidan.chat.screen.HistoryScreen +import com.kikidan.designsystem.R +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun HistoryRoute( + onBackClick: () -> Unit, + onNavigateToChat: (conversationId: String) -> Unit, + onNewChatClick: () -> Unit, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, + viewModel: HistoryViewModel = hiltViewModel(), +) { + val state by viewModel.collectAsState() + val defaultErrorMessage = stringResource(R.string.chat_default_error) + + LaunchedEffect(Unit) { viewModel.load() } + + viewModel.collectSideEffect { effect -> + when (effect) { + is HistorySideEffect.Error -> snackbarHostState.showSnackbar(defaultErrorMessage) + } + } + + HistoryScreen( + conversations = state.conversations.map { it.toUiModel() }, + onBackClick = onBackClick, + onConversationClick = onNavigateToChat, + onDeleteClick = viewModel::onDeleteClick, + onNewChatClick = onNewChatClick, + modifier = modifier, + ) +} diff --git a/feature/chat/src/main/java/com/kikidan/chat/HistoryViewModel.kt b/feature/chat/src/main/java/com/kikidan/chat/HistoryViewModel.kt new file mode 100644 index 0000000..5f19eab --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/HistoryViewModel.kt @@ -0,0 +1,44 @@ +package com.kikidan.chat + +import androidx.lifecycle.ViewModel +import com.kikidan.chat.model.HistorySideEffect +import com.kikidan.chat.model.HistoryState +import com.kikidan.domain.usecase.DeleteConversationUseCase +import com.kikidan.domain.usecase.GetConversationsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +class HistoryViewModel + @Inject + constructor( + private val getConversations: GetConversationsUseCase, + private val deleteConversation: DeleteConversationUseCase, + ) : ViewModel(), + ContainerHost { + override val container = container(HistoryState()) + + fun load() = + intent { + reduce { state.copy(isLoading = true) } + getConversations() + .onSuccess { conversations -> + reduce { state.copy(conversations = conversations, isLoading = false) } + }.onFailure { + reduce { state.copy(isLoading = false) } + postSideEffect(HistorySideEffect.Error(it)) + } + } + + fun onDeleteClick(conversationId: String) = + intent { + deleteConversation(conversationId) + .onSuccess { + reduce { + state.copy(conversations = state.conversations.filterNot { it.id == conversationId }) + } + }.onFailure { postSideEffect(HistorySideEffect.Error(it)) } + } + } diff --git a/feature/chat/src/main/java/com/kikidan/chat/model/HistoryConversationUiModel.kt b/feature/chat/src/main/java/com/kikidan/chat/model/HistoryConversationUiModel.kt new file mode 100644 index 0000000..8290aab --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/model/HistoryConversationUiModel.kt @@ -0,0 +1,25 @@ +package com.kikidan.chat.model + +import android.text.format.DateUtils +import com.kikidan.domain.model.chat.ConversationSummary + +data class HistoryConversationUiModel( + val id: String, + val title: String, + val relativeTime: String, + val isUnread: Boolean, +) + +fun ConversationSummary.toUiModel(): HistoryConversationUiModel = + HistoryConversationUiModel( + id = id, + title = title, + relativeTime = + DateUtils + .getRelativeTimeSpanString( + lastMessageAt.toEpochMilli(), + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ).toString(), + isUnread = unread, + ) diff --git a/feature/chat/src/main/java/com/kikidan/chat/model/HistorySideEffect.kt b/feature/chat/src/main/java/com/kikidan/chat/model/HistorySideEffect.kt new file mode 100644 index 0000000..0fb124d --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/model/HistorySideEffect.kt @@ -0,0 +1,7 @@ +package com.kikidan.chat.model + +sealed interface HistorySideEffect { + data class Error( + val e: Throwable, + ) : HistorySideEffect +} diff --git a/feature/chat/src/main/java/com/kikidan/chat/model/HistoryState.kt b/feature/chat/src/main/java/com/kikidan/chat/model/HistoryState.kt new file mode 100644 index 0000000..0a4fb3b --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/model/HistoryState.kt @@ -0,0 +1,8 @@ +package com.kikidan.chat.model + +import com.kikidan.domain.model.chat.ConversationSummary + +data class HistoryState( + val isLoading: Boolean = true, + val conversations: List = emptyList(), +) diff --git a/feature/chat/src/main/java/com/kikidan/chat/screen/HistoryScreen.kt b/feature/chat/src/main/java/com/kikidan/chat/screen/HistoryScreen.kt new file mode 100644 index 0000000..c879903 --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/screen/HistoryScreen.kt @@ -0,0 +1,195 @@ +package com.kikidan.chat.screen + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +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.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.kikidan.chat.model.HistoryConversationUiModel +import com.kikidan.designsystem.R +import com.kikidan.designsystem.component.chat.TodakunChatHistoryItem +import com.kikidan.designsystem.component.header.TodakunSubHeader +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTheme +import com.kikidan.designsystem.theme.TodakunTypography + +@Composable +internal fun HistoryScreen( + conversations: List, + onBackClick: () -> Unit, + onConversationClick: (String) -> Unit, + onDeleteClick: (String) -> Unit, + onNewChatClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxSize() + .background(TodakunColor.white) + .systemBarsPadding() + ) { + Column( + modifier = + Modifier + .fillMaxSize() + ) { + TodakunSubHeader( + title = stringResource(R.string.chat_history_title), + onBackClick = onBackClick, + ) + + if (conversations.isEmpty()) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = stringResource(R.string.chat_history_empty), + style = TodakunTypography.body2Regular, + color = TodakunColor.gray500, + ) + } + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(conversations, key = { it.id }) { conversation -> + TodakunChatHistoryItem( + title = conversation.title, + relativeTime = conversation.relativeTime, + isUnread = conversation.isUnread, + onClick = { onConversationClick(conversation.id) }, + onDeleteClick = { onDeleteClick(conversation.id) }, + ) + } + } + } + } + + NewChatButton( + text = stringResource(R.string.chat_history_new_chat), + painter = painterResource(id = R.drawable.ic_chat_add), + onClick = onNewChatClick, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 60.dp), + ) + } +} + +@Composable +private fun NewChatButton( + text: String, + painter: Painter, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + val pressedOverlay = TodakunColor.gray975.copy(alpha = 0.16f) + val containerColor = + if (isPressed) pressedOverlay.compositeOver(TodakunColor.primary700) else TodakunColor.primary700 + val shape = RoundedCornerShape(percent = 50) + + CompositionLocalProvider(LocalRippleConfiguration provides null) { + Button( + onClick = onClick, + modifier = + modifier.dropShadow( + shape = shape, + shadow = + Shadow( + radius = 20.dp, + offset = DpOffset(x = 0.dp, y = 4.dp), + color = TodakunColor.black.copy(alpha = 0.06f), + ), + ), + shape = shape, + colors = + ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = TodakunColor.white, + ), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 12.dp), + interactionSource = interactionSource, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painter, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + Text( + text = text, + style = TodakunTypography.body2Medium, + ) + } + } + } +} + +private val previewConversations = + listOf( + HistoryConversationUiModel(id = "1", title = "오늘 나의 행운의 숫자는?", relativeTime = "30분 전", isUnread = true), + HistoryConversationUiModel(id = "2", title = "이직할까 말까?", relativeTime = "3시간 전", isUnread = false), + HistoryConversationUiModel(id = "3", title = "이번 달 큰 지출 해도 괜찮을까", relativeTime = "1일 전", isUnread = false), + ) + +@Preview(showBackground = true, name = "목록") +@Composable +private fun HistoryScreenListPreview() { + TodakunTheme { + HistoryScreen( + conversations = previewConversations, + onBackClick = {}, + onConversationClick = {}, + onDeleteClick = {}, + onNewChatClick = {}, + ) + } +} + +@Preview(showBackground = true, name = "빈 목록") +@Composable +private fun HistoryScreenEmptyPreview() { + TodakunTheme { + HistoryScreen( + conversations = emptyList(), + onBackClick = {}, + onConversationClick = {}, + onDeleteClick = {}, + onNewChatClick = {}, + ) + } +} From 45c7c36f7021d6c7c0507e207307f8aa426ad780 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sat, 8 Aug 2026 13:55:31 +0900 Subject: [PATCH 2/3] =?UTF-8?q?style:=20HistoryScreen=20Modifier=20trailin?= =?UTF-8?q?g=20comma=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코드 스타일 일관성을 위해 Box와 Column의 Modifier 체인 끝에 trailing comma를 추가한다. --- .../src/main/java/com/kikidan/chat/screen/HistoryScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feature/chat/src/main/java/com/kikidan/chat/screen/HistoryScreen.kt b/feature/chat/src/main/java/com/kikidan/chat/screen/HistoryScreen.kt index c879903..3f5e834 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/screen/HistoryScreen.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/screen/HistoryScreen.kt @@ -57,12 +57,12 @@ internal fun HistoryScreen( modifier .fillMaxSize() .background(TodakunColor.white) - .systemBarsPadding() + .systemBarsPadding(), ) { Column( modifier = Modifier - .fillMaxSize() + .fillMaxSize(), ) { TodakunSubHeader( title = stringResource(R.string.chat_history_title), From 87bdeda990fce3c874b80bf351cd7fe0cfcd20e7 Mon Sep 17 00:00:00 2001 From: oungsi2000 Date: Sat, 8 Aug 2026 15:40:19 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20HistoryState=EC=9D=98=20=EB=8C=80?= =?UTF-8?q?=ED=99=94=20=EB=AA=A9=EB=A1=9D=20=ED=83=80=EC=9E=85=EC=9D=84=20?= =?UTF-8?q?PersistentList=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jetpack Compose의 리컴포지션 최적화를 위해 `conversations` 타입을 `List`에서 `PersistentList`로 변경한다. `HistoryViewModel`에서 목록을 조회하거나 삭제할 때 `toPersistentList()`를 호출하여 불변 컬렉션을 유지하도록 수정한다. --- .../src/main/java/com/kikidan/chat/HistoryViewModel.kt | 10 ++++++++-- .../main/java/com/kikidan/chat/model/HistoryState.kt | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/feature/chat/src/main/java/com/kikidan/chat/HistoryViewModel.kt b/feature/chat/src/main/java/com/kikidan/chat/HistoryViewModel.kt index 5f19eab..70b0b80 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/HistoryViewModel.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/HistoryViewModel.kt @@ -6,6 +6,7 @@ import com.kikidan.chat.model.HistoryState import com.kikidan.domain.usecase.DeleteConversationUseCase import com.kikidan.domain.usecase.GetConversationsUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList import org.orbitmvi.orbit.ContainerHost import org.orbitmvi.orbit.viewmodel.container import javax.inject.Inject @@ -25,7 +26,7 @@ class HistoryViewModel reduce { state.copy(isLoading = true) } getConversations() .onSuccess { conversations -> - reduce { state.copy(conversations = conversations, isLoading = false) } + reduce { state.copy(conversations = conversations.toPersistentList(), isLoading = false) } }.onFailure { reduce { state.copy(isLoading = false) } postSideEffect(HistorySideEffect.Error(it)) @@ -37,7 +38,12 @@ class HistoryViewModel deleteConversation(conversationId) .onSuccess { reduce { - state.copy(conversations = state.conversations.filterNot { it.id == conversationId }) + state.copy( + conversations = + state.conversations + .filterNot { it.id == conversationId } + .toPersistentList(), + ) } }.onFailure { postSideEffect(HistorySideEffect.Error(it)) } } diff --git a/feature/chat/src/main/java/com/kikidan/chat/model/HistoryState.kt b/feature/chat/src/main/java/com/kikidan/chat/model/HistoryState.kt index 0a4fb3b..61352b0 100644 --- a/feature/chat/src/main/java/com/kikidan/chat/model/HistoryState.kt +++ b/feature/chat/src/main/java/com/kikidan/chat/model/HistoryState.kt @@ -1,8 +1,10 @@ package com.kikidan.chat.model import com.kikidan.domain.model.chat.ConversationSummary +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf data class HistoryState( val isLoading: Boolean = true, - val conversations: List = emptyList(), + val conversations: PersistentList = persistentListOf(), )