diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 540cef7..87cae44 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ android:theme="@style/Theme.Todakun"> diff --git a/core/designsystem/src/main/java/com/kikidan/designsystem/component/chat/TodakunChatActionCard.kt b/core/designsystem/src/main/java/com/kikidan/designsystem/component/chat/TodakunChatActionCard.kt new file mode 100644 index 0000000..808fe6b --- /dev/null +++ b/core/designsystem/src/main/java/com/kikidan/designsystem/component/chat/TodakunChatActionCard.kt @@ -0,0 +1,142 @@ +package com.kikidan.designsystem.component.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.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.clip +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kikidan.designsystem.R +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTheme +import com.kikidan.designsystem.theme.TodakunTypography + +@Composable +fun TodakunChatCalenderActionCard( + category: String, + dateText: String, + buttonLabel: String, + onButtonClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(TodakunColor.white) + .border(1.dp, TodakunColor.coolGray300, RoundedCornerShape(12.dp)) + .padding(20.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "[$category]", + style = TodakunTypography.body2SemiBold, + color = TodakunColor.gray975, + ) + Text( + text = dateText, + style = TodakunTypography.body3Regular, + color = TodakunColor.gray800, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + CalendarActionButton( + text = buttonLabel, + painter = painterResource(id = R.drawable.ic_calendar_add), + onClick = onButtonClick, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun CalendarActionButton( + 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.primary600) else TodakunColor.primary600 + + CompositionLocalProvider(LocalRippleConfiguration provides null) { + Button( + onClick = onClick, + modifier = modifier, + shape = RoundedCornerShape(12.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = TodakunColor.white, + ), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp), + interactionSource = interactionSource, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painter, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = text, + style = TodakunTypography.body3SemiBold, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun TodakunChatCalenderActionCardPreview() { + TodakunTheme { + TodakunChatCalenderActionCard( + category = "계약 · 이사", + dateText = "2026 . 7 . 25 (토)", + buttonLabel = "내 캘린더에 추가하기", + onButtonClick = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/kikidan/designsystem/component/chat/TodakunChatHeader.kt b/core/designsystem/src/main/java/com/kikidan/designsystem/component/chat/TodakunChatHeader.kt index de30b4a..99294ab 100644 --- a/core/designsystem/src/main/java/com/kikidan/designsystem/component/chat/TodakunChatHeader.kt +++ b/core/designsystem/src/main/java/com/kikidan/designsystem/component/chat/TodakunChatHeader.kt @@ -2,6 +2,7 @@ package com.kikidan.designsystem.component.chat import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -14,6 +15,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -53,7 +55,11 @@ fun TodakunChatHeader( Modifier .align(Alignment.CenterStart) .size(20.dp) - .clickable(onClick = onCloseClick), + .clickable( + onClick = onCloseClick, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), ) Row( @@ -90,7 +96,11 @@ fun TodakunChatHeader( modifier = Modifier .size(20.dp) - .clickable(onClick = onChatIconClick), + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onChatIconClick, + ), ) Icon( painter = painterResource(id = R.drawable.ic_notes), @@ -100,7 +110,11 @@ fun TodakunChatHeader( Modifier .padding(start = 16.dp) .size(20.dp) - .clickable(onClick = onNotesIconClick), + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onNotesIconClick, + ), ) } } diff --git a/core/designsystem/src/main/java/com/kikidan/designsystem/component/chat/TodakunChatInputField.kt b/core/designsystem/src/main/java/com/kikidan/designsystem/component/chat/TodakunChatInputField.kt index ab4a7b7..65da98b 100644 --- a/core/designsystem/src/main/java/com/kikidan/designsystem/component/chat/TodakunChatInputField.kt +++ b/core/designsystem/src/main/java/com/kikidan/designsystem/component/chat/TodakunChatInputField.kt @@ -3,6 +3,7 @@ package com.kikidan.designsystem.component.chat import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -20,6 +21,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -142,7 +144,12 @@ private fun SendMessageButton( .background( color = if (isFilled) TodakunColor.primary600 else TodakunColor.gray50, shape = CircleShape, - ).clickable(enabled = isFilled, onClick = onSendClick), + ).clickable( + enabled = isFilled, + onClick = onSendClick, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ), contentAlignment = Alignment.Center, ) { Icon( diff --git a/core/designsystem/src/main/res/drawable/ic_calendar_add.xml b/core/designsystem/src/main/res/drawable/ic_calendar_add.xml new file mode 100644 index 0000000..cf0c18c --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_calendar_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/img_todak_achievement_luck.png b/core/designsystem/src/main/res/drawable/img_todak_achievement_luck.png new file mode 100644 index 0000000..1f7d6e8 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_todak_achievement_luck.png differ diff --git a/core/designsystem/src/main/res/drawable/img_todak_chat_thumbnail.png b/core/designsystem/src/main/res/drawable/img_todak_chat_thumbnail.png new file mode 100644 index 0000000..4322ee0 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_todak_chat_thumbnail.png differ diff --git a/core/designsystem/src/main/res/drawable/img_todak_default_pose.png b/core/designsystem/src/main/res/drawable/img_todak_default_pose.png new file mode 100644 index 0000000..bdde846 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_todak_default_pose.png differ diff --git a/core/designsystem/src/main/res/drawable/img_todak_health_luck.png b/core/designsystem/src/main/res/drawable/img_todak_health_luck.png new file mode 100644 index 0000000..c91e7da Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_todak_health_luck.png differ diff --git a/core/designsystem/src/main/res/drawable/img_todak_love_luck.png b/core/designsystem/src/main/res/drawable/img_todak_love_luck.png new file mode 100644 index 0000000..d2b45ec Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_todak_love_luck.png differ diff --git a/core/designsystem/src/main/res/drawable/img_todak_money_luck.png b/core/designsystem/src/main/res/drawable/img_todak_money_luck.png new file mode 100644 index 0000000..588df0a Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_todak_money_luck.png differ diff --git a/core/designsystem/src/main/res/drawable/img_todak_relationship_luck.png b/core/designsystem/src/main/res/drawable/img_todak_relationship_luck.png new file mode 100644 index 0000000..cd19cdb Binary files /dev/null and b/core/designsystem/src/main/res/drawable/img_todak_relationship_luck.png differ diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml index c9eade1..123aa90 100644 --- a/core/designsystem/src/main/res/values/strings.xml +++ b/core/designsystem/src/main/res/values/strings.xml @@ -47,4 +47,36 @@ 토닥이 행운 액션 마이 + + + 토닥이 + 오늘은 어떤게 궁금해? + 토닥이가 생각하고 있어요 + 생각 중 + 그리팅 닫기 + 알 수 없는 에러가 발생했어요. 잠시 후 다시 시도해주세요 + 토닥이 사진 + + + 관계운을 알려줄게! + 친구, 가족, 동료 등\n 고민되는 게 있으면 전부 나한테 물어봐. + 연애운을 알려줄게! + 짝사랑, 썸, 연애 등\n 남한테 말하지 못하는 비밀을 나한테 털어놔. + 성취운을 알려줄게! + 커리어, 학업, 목표 등\n 궁금한 점이나 고민은 전부 나한테 물어봐. + 금전운을 알려줄게! + 수입, 지출, 재테크 등\n 돈의 흐름에 대해서 나한테 물어봐. + 건강운을 알려줄게! + 컨디션, 습관, 마음가짐 등\n 요즘 신경 쓰이는 걸 나한테 말해봐. + + + 토닥운 + AI 토닥이 + 토닥이에게\n궁금한 운세를 다 물어봐! + + + 대화 히스토리 + 아직 토닥이와 나눈 대화가 없어요. + 새 채팅 + diff --git a/feature/chat/src/main/java/com/kikidan/chat/ChatRoute.kt b/feature/chat/src/main/java/com/kikidan/chat/ChatRoute.kt new file mode 100644 index 0000000..adacc1b --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/ChatRoute.kt @@ -0,0 +1,53 @@ +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.ChatSideEffect +import com.kikidan.chat.screen.ChatScreen +import com.kikidan.chat.screen.ChatSplashScreen +import com.kikidan.designsystem.R +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun ChatRoute( + conversationId: String?, + onCloseClick: () -> Unit, + onNavigateToHistory: () -> Unit, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, + viewModel: ChatViewModel = hiltViewModel(), +) { + val state by viewModel.collectAsState() + val defaultErrorMessage = stringResource(R.string.chat_default_error) + val showSplash = state.quota == null + + LaunchedEffect(Unit) { viewModel.load(conversationId) } + + viewModel.collectSideEffect { effect -> + when (effect) { + is ChatSideEffect.ShowStreamingErrorMessage -> snackbarHostState.showSnackbar(effect.message) + is ChatSideEffect.Error -> snackbarHostState.showSnackbar(defaultErrorMessage) + } + } + + if (showSplash) { + ChatSplashScreen() + } else { + ChatScreen( + state = state, + onInputChange = viewModel::onInputChange, + onSendClick = viewModel::onSendClick, + onSuggestionClick = viewModel::onSuggestionClick, + onNewConversationClick = viewModel::startNewConversation, + onCloseClick = onCloseClick, + onHistoryClick = onNavigateToHistory, + modifier = modifier, + ) + } +} diff --git a/feature/chat/src/main/java/com/kikidan/chat/component/ChatGreetingOverlay.kt b/feature/chat/src/main/java/com/kikidan/chat/component/ChatGreetingOverlay.kt new file mode 100644 index 0000000..a8b6f0b --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/component/ChatGreetingOverlay.kt @@ -0,0 +1,204 @@ +package com.kikidan.chat.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.kikidan.chat.util.toCharacterResourceId +import com.kikidan.designsystem.R +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTheme +import com.kikidan.designsystem.theme.TodakunTypography +import com.kikidan.domain.model.chat.ChatCategory + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ChatGreetingOverlay( + category: ChatCategory, + onCloseClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val title = category.toTitle() + val body = category.toBody() + Box( + modifier = + modifier + .fillMaxSize() + .background(Color(0xFF000000).copy(alpha = 0.4f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) {}, + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + OverlayCharacterImage( + category = category, + size = ChatGreetingDefaults.CharacterSize, + ) + Spacer(Modifier.height(36.dp)) + SpeechBubble( + modifier = Modifier.padding(horizontal = 20.dp), + title = title, + body = body, + onCloseClick = onCloseClick, + ) + } + } +} + +@Composable +private fun SpeechBubble( + title: String, + body: String, + onCloseClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier + .size(width = 20.dp, height = 12.dp) + .background(TodakunColor.white, BubbleTail), + ) + + Box( + modifier = + Modifier + .clip(ChatGreetingDefaults.CardShape) + .background(TodakunColor.white) + .padding(horizontal = 20.dp, vertical = 24.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + style = TodakunTypography.heading4Bold, + color = TodakunColor.coolGray900, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = body, + style = TodakunTypography.body2Regular, + color = TodakunColor.coolGray600, + textAlign = TextAlign.Center, + ) + } + + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = stringResource(R.string.chat_greeting_close_description), + tint = TodakunColor.coolGray600, + modifier = + Modifier + .align(Alignment.TopEnd) + .size(ChatGreetingDefaults.CloseIconSize) + .clickable(onClick = onCloseClick), + ) + } + } +} + +@Composable +private fun ChatCategory.toTitle() = + when (this) { + ChatCategory.LOVE -> stringResource(R.string.chat_greeting_love_title) + ChatCategory.MONEY -> stringResource(R.string.chat_greeting_money_title) + ChatCategory.ACHIEVEMENT -> stringResource(R.string.chat_greeting_achievement_title) + ChatCategory.HEALTH -> stringResource(R.string.chat_greeting_health_title) + ChatCategory.RELATIONSHIP -> stringResource(R.string.chat_greeting_relationship_title) + } + +@Composable +private fun ChatCategory.toBody() = + when (this) { + ChatCategory.LOVE -> stringResource(R.string.chat_greeting_love_body) + ChatCategory.MONEY -> stringResource(R.string.chat_greeting_money_body) + ChatCategory.ACHIEVEMENT -> stringResource(R.string.chat_greeting_achievement_body) + ChatCategory.HEALTH -> stringResource(R.string.chat_greeting_health_body) + ChatCategory.RELATIONSHIP -> stringResource(R.string.chat_greeting_relationship_body) + } + +@Composable +private fun OverlayCharacterImage( + category: ChatCategory, + size: Dp, + modifier: Modifier = Modifier, +) { + val resourceId = category.toCharacterResourceId() + Image( + modifier = modifier.size(size), + painter = painterResource(resourceId), + contentDescription = stringResource(R.string.chat_charactor_content_description), + ) +} + +private object BubbleTail : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline = + Outline.Generic( + Path().apply { + moveTo(0f, size.height) + lineTo(size.width, size.height) + lineTo(size.width / 2f, 0f) + close() + }, + ) +} + +private object ChatGreetingDefaults { + val CharacterSize = 220.dp + val CardShape = RoundedCornerShape(12.dp) + val CloseIconSize = 20.dp +} + +@Preview +@Composable +private fun ChatGreetingOverlayWithTitlePreview() { + TodakunTheme { + ChatGreetingOverlay( + category = ChatCategory.ACHIEVEMENT, + onCloseClick = {}, + ) + } +} diff --git a/feature/chat/src/main/java/com/kikidan/chat/component/ThinkingIndicator.kt b/feature/chat/src/main/java/com/kikidan/chat/component/ThinkingIndicator.kt new file mode 100644 index 0000000..5599077 --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/component/ThinkingIndicator.kt @@ -0,0 +1,103 @@ +package com.kikidan.chat.component + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kikidan.designsystem.R +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTheme +import com.kikidan.designsystem.theme.TodakunTypography + +@Composable +internal fun ThinkingIndicator(modifier: Modifier = Modifier) { + val description = stringResource(R.string.chat_thinking_description) + val transition = rememberInfiniteTransition(label = "thinking") + + Row( + modifier = modifier.semantics { contentDescription = description }, + horizontalArrangement = Arrangement.spacedBy(ThinkingIndicatorDefaults.LabelGap), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(ThinkingIndicatorDefaults.DotGap), + verticalAlignment = Alignment.Bottom, + ) { + repeat(ThinkingIndicatorDefaults.DOT_COUNT) { index -> + val bounceProgress by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = ThinkingIndicatorDefaults.BOUNCE_MILLIS, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Reverse, + initialStartOffset = + StartOffset(index * ThinkingIndicatorDefaults.STAGGER_MILLIS), + ), + label = "dot$index", + ) + Box( + modifier = + Modifier + .size(ThinkingIndicatorDefaults.DotSize) + .graphicsLayer { + translationY = -ThinkingIndicatorDefaults.BounceHeight.toPx() * bounceProgress + }.clip(CircleShape) + .background( + lerp(TodakunColor.primary300, TodakunColor.primary700, bounceProgress), + ), + ) + } + } + + Text( + text = stringResource(R.string.chat_thinking_label), + style = TodakunTypography.body2Regular, + color = TodakunColor.primary700, + ) + } +} + +private object ThinkingIndicatorDefaults { + const val DOT_COUNT = 3 + const val BOUNCE_MILLIS = 450 + const val STAGGER_MILLIS = 150 + val DotSize = 4.dp + val DotGap = 3.dp + val BounceHeight = 3.dp + val LabelGap = 8.dp +} + +@Preview(showBackground = true) +@Composable +private fun ThinkingIndicatorPreview() { + TodakunTheme { + ThinkingIndicator() + } +} diff --git a/feature/chat/src/main/java/com/kikidan/chat/screen/ChatScreen.kt b/feature/chat/src/main/java/com/kikidan/chat/screen/ChatScreen.kt new file mode 100644 index 0000000..eba28ca --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/screen/ChatScreen.kt @@ -0,0 +1,509 @@ +package com.kikidan.chat.screen + +import android.content.Intent +import android.provider.CalendarContract +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +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.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.graphics.graphicsLayer +import androidx.compose.ui.layout.onPlaced +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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.kikidan.chat.component.ChatGreetingOverlay +import com.kikidan.chat.component.ThinkingIndicator +import com.kikidan.chat.model.ChatState +import com.kikidan.chat.model.StreamingChatState +import com.kikidan.chat.util.toCharacterResourceId +import com.kikidan.designsystem.R +import com.kikidan.designsystem.component.chat.TodakunChatCalenderActionCard +import com.kikidan.designsystem.component.chat.TodakunChatExampleChip +import com.kikidan.designsystem.component.chat.TodakunChatHeader +import com.kikidan.designsystem.component.chat.TodakunChatInputField +import com.kikidan.designsystem.component.chat.TodakunChatUserInputBubble +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTheme +import com.kikidan.designsystem.theme.TodakunTypography +import com.kikidan.domain.model.chat.ChatAction +import com.kikidan.domain.model.chat.ChatActionType +import com.kikidan.domain.model.chat.ChatCategory +import com.kikidan.domain.model.chat.ChatMessage +import com.kikidan.domain.model.chat.ChatSuggestion +import com.kikidan.domain.model.chat.MessageRole +import com.kikidan.domain.model.chat.MessageStatus +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.delay +import java.time.Instant +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun ChatScreen( + state: ChatState, + onInputChange: (String) -> Unit, + onSendClick: () -> Unit, + onSuggestionClick: (String) -> Unit, + onNewConversationClick: () -> Unit, + onCloseClick: () -> Unit, + onHistoryClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val context = LocalContext.current + var inputFieldHeight by remember { mutableStateOf(0.dp) } + var selectedCategory: ChatCategory? by remember { mutableStateOf(null) } + + LaunchedEffect(selectedCategory) { + if (selectedCategory != null) { + delay(ChatScreenDefaults.GREETING_DURATION_MILLIS) + selectedCategory = null + } + } + + Box( + modifier = + modifier + .fillMaxSize() + .background(TodakunColor.white) + .systemBarsPadding() + .imePadding(), + ) { + Column { + TodakunChatHeader( + title = stringResource(R.string.chat_header_title), + freeChatUsed = state.quota?.remaining ?: 0, + freeChatTotal = state.quota?.limit ?: 0, + onCloseClick = onCloseClick, + onChatIconClick = onNewConversationClick, + onNotesIconClick = onHistoryClick, + ) + + ChatMessageList( + state = state, + modifier = Modifier.fillMaxSize(), + suggestions = state.suggestions, + onSuggestionClick = { suggestion -> + selectedCategory = suggestion.category + onSuggestionClick(suggestion.seedPrompt) + }, + inputFieldHeight = inputFieldHeight, + selectedCategory = selectedCategory, + onActionClick = { action -> + val intent = + Intent(Intent.ACTION_INSERT).apply { + data = CalendarContract.Events.CONTENT_URI + putExtra(CalendarContract.Events.TITLE, action.category) + putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, action.date) + putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, true) + } + context.startActivity(intent) + }, + ) + } + TodakunChatInputField( + value = state.input, + onValueChange = onInputChange, + onSendClick = onSendClick, + modifier = + Modifier + .align(Alignment.BottomCenter) + .onPlaced { placeable -> + inputFieldHeight = + with(density) { + placeable.size.height.toDp() + } + }.padding(20.dp), + ) + + AnimatedVisibility( + visible = selectedCategory != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + selectedCategory?.let { + ChatGreetingOverlay( + category = it, + onCloseClick = { selectedCategory = null }, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ChatMessageList( + inputFieldHeight: Dp, + selectedCategory: ChatCategory?, + state: ChatState, + suggestions: List, + onSuggestionClick: (ChatSuggestion) -> Unit, + onActionClick: (ChatAction) -> Unit, + modifier: Modifier = Modifier, +) { + // 상위에서 selectedCatgeory가 null이 되도 이전 캐릭터 유지 + var prevCategory by remember { mutableStateOf(selectedCategory) } + if (selectedCategory != null) prevCategory = selectedCategory + + val listState = rememberLazyListState() + val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current) + + // 키보드가 열리고 닫히는 애니메이션 프레임마다 마지막 아이템으로 재스크롤해 입력창에 가려지지 않게 한다. + LaunchedEffect(state.messages.size, state.streamingChatState, imeBottom) { + if (listState.layoutInfo.totalItemsCount > 0) { + listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1) + } + } + + LazyColumn( + state = listState, + modifier = modifier, + contentPadding = + PaddingValues( + start = 20.dp, + end = 20.dp, + top = 12.dp, + bottom = inputFieldHeight, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + ProfileCharacterImage(size = 60.dp, category = prevCategory) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(R.string.chat_entry_question), + style = TodakunTypography.body1Medium, + color = TodakunColor.coolGray900, + ) + } + + if (state.messages.isEmpty() && state.streamingChatState is StreamingChatState.Idle) { + item { + suggestions.forEach { suggestion -> + TodakunChatExampleChip( + modifier = Modifier.padding(vertical = 8.dp), + text = "${suggestion.emoji} ${suggestion.label}", + onClick = { + onSuggestionClick(suggestion) + }, + ) + } + } + } + + items(state.messages, key = { it.id }) { message -> + when (message.role) { + MessageRole.USER -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TodakunChatUserInputBubble(text = message.content) + } + } + + MessageRole.ASSISTANT -> { + Column { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = message.content, + style = TodakunTypography.body2Regular, + color = TodakunColor.coolGray900, + ) + message.action?.let { action -> + Spacer(modifier = Modifier.height(20.dp)) + TodakunChatCalenderActionCard( + category = action.category, + dateText = action.date?.toActionDateText().orEmpty(), + buttonLabel = action.label, + onButtonClick = { onActionClick(action) }, + ) + } + } + } + } + } + + // THINKING / TYPING 인디케이터를 마지막 슬롯에 표시 + if (state.streamingChatState !is StreamingChatState.Idle) { + item { + Spacer(modifier = Modifier.height(24.dp)) + when (state.streamingChatState) { + is StreamingChatState.Thinking -> { + ThinkingIndicator() + } + + is StreamingChatState.Typing -> { + Text( + text = state.streamingChatState.streamingText, + style = TodakunTypography.body2Regular, + color = TodakunColor.black, + ) + } + + is StreamingChatState.Idle -> { + Unit + } // 도달하지 않는다 + } + } + } + } +} + +@Composable +private fun ProfileCharacterImage( + category: ChatCategory?, + size: Dp, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .size(size) + .clip(CircleShape), + ) { + Image( + painter = painterResource(category?.toCharacterResourceId() ?: R.drawable.img_todak_default_pose), + contentDescription = stringResource(R.string.chat_charactor_content_description), + modifier = + Modifier + .fillMaxSize() + .then( + if (category != null) { + Modifier.graphicsLayer { + scaleX = 1.4f + scaleY = 1.4f + } + } else { + Modifier + }, + ), + ) + } +} + +private object ChatScreenDefaults { + const val GREETING_DURATION_MILLIS = 3_000L +} + +private fun LocalDate.toActionDateText(): String = ActionDateFormatter.format(this) + +private val ActionDateFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy . M . d (E)", Locale.KOREA) + +private val previewSuggestions = + persistentListOf( + ChatSuggestion( + emoji = "📅", + label = "중요한 일정 잡기 좋은 날인지 궁금해", + seedPrompt = "오늘 중요한 일정 잡기 좋은 날인지 알려줘", + category = ChatCategory.LOVE, + ), + ChatSuggestion( + emoji = "💼", + label = "커리어 운세가 궁금해", + seedPrompt = "오늘 커리어 운세를 알려줘", + category = ChatCategory.ACHIEVEMENT, + ), + ChatSuggestion(emoji = "💕", label = "오늘 연애운이 궁금해", seedPrompt = "오늘 연애운을 알려줘", category = ChatCategory.LOVE), + ChatSuggestion( + emoji = "💰", + label = "재물운이 어떤지 알고 싶어", + seedPrompt = "오늘 재물운을 알려줘", + category = ChatCategory.MONEY, + ), + ChatSuggestion( + emoji = "🏥", + label = "건강 관리에 좋은 날인지 궁금해", + seedPrompt = "오늘 건강운을 알려줘", + category = ChatCategory.HEALTH, + ), + ) + +private val previewMessages = + persistentListOf( + ChatMessage( + id = "1", + role = MessageRole.USER, + content = "오늘 운세가 궁금해", + status = MessageStatus.COMPLETED, + action = null, + createdAt = Instant.EPOCH, + ), + ChatMessage( + id = "2", + role = MessageRole.ASSISTANT, + content = "오늘의 운세를 알아볼게요! 대체로 긍정적인 에너지가 흐르는 날입니다.", + status = MessageStatus.COMPLETED, + action = + ChatAction( + type = ChatActionType.CALENDAR_ADD, + label = "내 캘린더에 추가하기", + category = "계약 · 이사", + date = LocalDate.of(2026, 7, 25), + ), + createdAt = Instant.EPOCH, + ), + ChatMessage( + id = "3", + role = MessageRole.USER, + content = "더 자세히 알려줄 수 있어?", + status = MessageStatus.COMPLETED, + action = null, + createdAt = Instant.EPOCH, + ), + ) + +@Preview(showBackground = true, name = "진입 상태") +@Composable +private fun ChatScreenEntryPreview() { + TodakunTheme { + ChatScreen( + state = ChatState(suggestions = previewSuggestions), + onInputChange = {}, + onSendClick = {}, + onSuggestionClick = {}, + onNewConversationClick = {}, + onCloseClick = {}, + onHistoryClick = {}, + ) + } +} + +@Preview(showBackground = true, name = "그리팅 오버레이 (개행 있음)") +@Composable +private fun ChatScreenGreetingWithTitlePreview() { + TodakunTheme { + ChatScreen( + state = + ChatState( + suggestions = previewSuggestions, + greeting = "성취운을 알려줄게!\n커리어, 학업, 목표 등 궁금한 점이나 고민은 전부 물어봐줘.", + ), + onInputChange = {}, + onSendClick = {}, + onSuggestionClick = {}, + onNewConversationClick = {}, + onCloseClick = {}, + onHistoryClick = {}, + ) + } +} + +@Preview(showBackground = true, name = "그리팅 오버레이 (개행 없음)") +@Composable +private fun ChatScreenGreetingNoTitlePreview() { + TodakunTheme { + ChatScreen( + state = + ChatState( + suggestions = previewSuggestions, + greeting = "오늘도 좋은 하루 되세요! 궁금한 것들을 물어봐줘.", + ), + onInputChange = {}, + onSendClick = {}, + onSuggestionClick = {}, + onNewConversationClick = {}, + onCloseClick = {}, + onHistoryClick = {}, + ) + } +} + +@Preview(showBackground = true, name = "생각 중 (THINKING)") +@Composable +private fun ChatScreenThinkingPreview() { + TodakunTheme { + ChatScreen( + state = + ChatState( + messages = previewMessages.take(1).toPersistentList(), + ), + onInputChange = {}, + onSendClick = {}, + onSuggestionClick = {}, + onNewConversationClick = {}, + onCloseClick = {}, + onHistoryClick = {}, + ) + } +} + +@Preview(showBackground = true, name = "타이핑 중 (TYPING)") +@Composable +private fun ChatScreenTypingPreview() { + TodakunTheme { + ChatScreen( + state = + ChatState( + messages = previewMessages.take(1).toPersistentList(), + streamingChatState = StreamingChatState.Typing("오늘의 운세를 알아볼게요! 대체로 긍정"), + ), + onInputChange = {}, + onSendClick = {}, + onSuggestionClick = {}, + onNewConversationClick = {}, + onCloseClick = {}, + onHistoryClick = {}, + ) + } +} + +@Preview(showBackground = true, name = "대화 이어보기") +@Composable +private fun ChatScreenConversationPreview() { + TodakunTheme { + ChatScreen( + state = + ChatState( + messages = previewMessages, + streamingChatState = StreamingChatState.Idle, + ), + onInputChange = {}, + onSendClick = {}, + onSuggestionClick = {}, + onNewConversationClick = {}, + onCloseClick = {}, + onHistoryClick = {}, + ) + } +} diff --git a/feature/chat/src/main/java/com/kikidan/chat/screen/SplashScreen.kt b/feature/chat/src/main/java/com/kikidan/chat/screen/SplashScreen.kt new file mode 100644 index 0000000..5e86f5d --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/screen/SplashScreen.kt @@ -0,0 +1,89 @@ +package com.kikidan.chat.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.kikidan.designsystem.R +import com.kikidan.designsystem.theme.TodakunColor +import com.kikidan.designsystem.theme.TodakunTheme +import com.kikidan.designsystem.theme.TodakunTypography + +@Composable +fun ChatSplashScreen(modifier: Modifier = Modifier) { + Column( + modifier = + modifier + .fillMaxSize() + .background(Color(0xFFF2F0FB)) + .padding(vertical = 178.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.chat_splash_brand_primary), + style = TodakunTypography.heading4Bold, + color = TodakunColor.gray975, + ) + Spacer( + modifier = + Modifier + .width(2.dp) + .height(20.dp) + .background(TodakunColor.coolGray300), + ) + Text( + text = stringResource(R.string.chat_splash_brand_secondary), + style = TodakunTypography.heading4Bold, + color = TodakunColor.gray975, + ) + } + + Image( + painter = painterResource(id = R.drawable.img_todak_chat_thumbnail), + contentDescription = stringResource(R.string.chat_charactor_content_description), + contentScale = ContentScale.Fit, + modifier = + Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 40.dp), + ) + + Text( + text = stringResource(R.string.chat_splash_tagline), + style = TodakunTypography.heading4Bold, + color = TodakunColor.primary900, + textAlign = TextAlign.Center, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ChatSplashScreenPreview() { + TodakunTheme { + ChatSplashScreen() + } +} diff --git a/feature/chat/src/main/java/com/kikidan/chat/util/ToCharacterResourceId.kt b/feature/chat/src/main/java/com/kikidan/chat/util/ToCharacterResourceId.kt new file mode 100644 index 0000000..3c4b3ee --- /dev/null +++ b/feature/chat/src/main/java/com/kikidan/chat/util/ToCharacterResourceId.kt @@ -0,0 +1,13 @@ +package com.kikidan.chat.util + +import com.kikidan.designsystem.R +import com.kikidan.domain.model.chat.ChatCategory + +internal fun ChatCategory.toCharacterResourceId() = + when (this) { + ChatCategory.LOVE -> R.drawable.img_todak_love_luck + ChatCategory.MONEY -> R.drawable.img_todak_money_luck + ChatCategory.ACHIEVEMENT -> R.drawable.img_todak_achievement_luck + ChatCategory.HEALTH -> R.drawable.img_todak_health_luck + ChatCategory.RELATIONSHIP -> R.drawable.img_todak_relationship_luck + } diff --git a/feature/chat/src/main/res/values/strings.xml b/feature/chat/src/main/res/values/strings.xml new file mode 100644 index 0000000..d781ec5 --- /dev/null +++ b/feature/chat/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f4fda7..2f61342 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ kakao = "2.24.0" androidx-credentials = "1.6.0" googleid = "1.2.0" kotlinx-collections-immutable = "0.5.1" +material3 = "1.4.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -85,6 +86,7 @@ googleid = { group = "com.google.android.libraries.identity.googleid", name = "g ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } [plugins]