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..70b0b80 --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/HistoryViewModel.kt @@ -0,0 +1,50 @@ +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 kotlinx.collections.immutable.toPersistentList +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.toPersistentList(), 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 } + .toPersistentList(), + ) + } + }.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..61352b0 --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/model/HistoryState.kt @@ -0,0 +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: PersistentList = persistentListOf(), +) 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..3f5e834 --- /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 = {}, + ) + } +}