diff --git a/app/src/main/kotlin/team/aliens/dms/android/app/navigation/BottomMenu.kt b/app/src/main/kotlin/team/aliens/dms/android/app/navigation/BottomMenu.kt index 592324707..02c6b6a5f 100644 --- a/app/src/main/kotlin/team/aliens/dms/android/app/navigation/BottomMenu.kt +++ b/app/src/main/kotlin/team/aliens/dms/android/app/navigation/BottomMenu.kt @@ -27,6 +27,13 @@ sealed class BottomMenu( title = "신청", ) + data object ChatBot : BottomMenu( + route = ChatBotScreenNav, + icon = DmsIcon.ChatBot, + selectedIcon = DmsIcon.ChatBotFill, + title = "챗봇", + ) + data object MyPage : BottomMenu( route = MyPageScreenNav, icon = DmsIcon.MyPage, diff --git a/app/src/main/kotlin/team/aliens/dms/android/app/navigation/BottomNavigationBar.kt b/app/src/main/kotlin/team/aliens/dms/android/app/navigation/BottomNavigationBar.kt index 78eee2cf2..580170834 100644 --- a/app/src/main/kotlin/team/aliens/dms/android/app/navigation/BottomNavigationBar.kt +++ b/app/src/main/kotlin/team/aliens/dms/android/app/navigation/BottomNavigationBar.kt @@ -25,6 +25,7 @@ import team.aliens.dms.android.core.designsystem.labelB private val bottomMenus = listOf( BottomMenu.Home, BottomMenu.Application, + BottomMenu.ChatBot, BottomMenu.MyPage, ) @@ -51,9 +52,11 @@ fun BottomNavigationBar( NavigationBarItem( selected = selected, - enabled = !selected, + enabled = true, onClick = { - onNavigate(destination.route) + if (!selected) { + onNavigate(destination.route) + } }, colors = NavigationBarItemDefaults.colors( indicatorColor = Color.Transparent, @@ -64,7 +67,13 @@ fun BottomNavigationBar( ) { Icon( modifier = Modifier.size(32.dp), - painter = painterResource(id = if (selected) destination.selectedIcon else destination.icon), + painter = painterResource( + id = if (selected) { + destination.selectedIcon + } else { + destination.icon + }, + ), contentDescription = destination.title, tint = color, ) diff --git a/app/src/main/kotlin/team/aliens/dms/android/app/navigation/DmsNavKeys.kt b/app/src/main/kotlin/team/aliens/dms/android/app/navigation/DmsNavKeys.kt index ad74e1790..579284444 100644 --- a/app/src/main/kotlin/team/aliens/dms/android/app/navigation/DmsNavKeys.kt +++ b/app/src/main/kotlin/team/aliens/dms/android/app/navigation/DmsNavKeys.kt @@ -14,6 +14,9 @@ data object SignInScreenNav : NavKey @Serializable data object HomeScreenNav : NavKey +@Serializable +data object ChatBotScreenNav : NavKey + @Serializable data object MealScreenNav : NavKey diff --git a/app/src/main/kotlin/team/aliens/dms/android/app/ui/DmsApp.kt b/app/src/main/kotlin/team/aliens/dms/android/app/ui/DmsApp.kt index 9136cd0ae..189feaf79 100644 --- a/app/src/main/kotlin/team/aliens/dms/android/app/ui/DmsApp.kt +++ b/app/src/main/kotlin/team/aliens/dms/android/app/ui/DmsApp.kt @@ -13,6 +13,9 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.unit.dp @@ -84,6 +87,8 @@ import team.aliens.dms.android.app.navigation.SignUpSetPasswordNav import team.aliens.dms.android.app.navigation.SignUpTermsNav import team.aliens.dms.android.feature.latestudy.navigation.LateStudyRoute import java.util.UUID +import team.aliens.dms.android.app.navigation.ChatBotScreenNav +import team.aliens.dms.android.feature.chatbot.ui.ChatBotRoute @Composable @@ -97,12 +102,14 @@ fun DmsApp( val backStack = rememberNavBackStack(OnboardingScreenNav) val resultStore = rememberResultStore() + var isChatInputFocused by remember { mutableStateOf(false) } val currentScreen = backStack.lastOrNull() val shouldShowBottomBar = currentScreen in listOf( HomeScreenNav, ApplicationScreenNav, + ChatBotScreenNav, MyPageScreenNav, - ) + ) && !(currentScreen == ChatBotScreenNav && isChatInputFocused) LaunchedEffect(isStartupResolved, isOnboardingCompleted, isJwtAvailable) { if (!isStartupResolved) return@LaunchedEffect @@ -130,13 +137,10 @@ fun DmsApp( currentScreen = currentScreen.toString(), onNavigate = { destination -> if (currentScreen != destination) { - when (destination) { - is HomeScreenNav -> { - backStack.remove(HomeScreenNav) - } - } backStack.removeAll { - it is ApplicationScreenNav || + it is HomeScreenNav || + it is ApplicationScreenNav || + it is ChatBotScreenNav || it is MyPageScreenNav } backStack.add(destination) @@ -213,6 +217,13 @@ fun DmsApp( } ) } + entry { + ChatBotRoute( + onInputFocusChange = { focused -> + isChatInputFocused = focused + }, + ) + } entry { VoteRoute( onNavigateBack = { backStack.remove(VoteScreenNav) }, diff --git a/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/foundation/DmsIcon.kt b/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/foundation/DmsIcon.kt index c01415586..24ed9eb7a 100644 --- a/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/foundation/DmsIcon.kt +++ b/core/design-system/src/main/java/team/aliens/dms/android/core/designsystem/foundation/DmsIcon.kt @@ -24,6 +24,8 @@ object DmsIcon { val HomeFill = R.drawable.ic_home_fill val CheckCircle = R.drawable.ic_application val CheckCircleFill = R.drawable.ic_application_fill + val ChatBot = R.drawable.ic_chatbot + val ChatBotFill = R.drawable.ic_chatbot_fill val MyPage = R.drawable.ic_my_page val MyPageFill = R.drawable.ic_my_page_fill val Information = R.drawable.ic_information diff --git a/core/design-system/src/main/res/drawable/ic_chatbot.xml b/core/design-system/src/main/res/drawable/ic_chatbot.xml new file mode 100644 index 000000000..59ff97f3f --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_chatbot.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/core/design-system/src/main/res/drawable/ic_chatbot_fill.xml b/core/design-system/src/main/res/drawable/ic_chatbot_fill.xml new file mode 100644 index 000000000..f81ed1258 --- /dev/null +++ b/core/design-system/src/main/res/drawable/ic_chatbot_fill.xml @@ -0,0 +1,14 @@ + + + + diff --git a/data/src/main/kotlin/team/aliens/dms/android/data/chatbot/di/ChatBotDataModule.kt b/data/src/main/kotlin/team/aliens/dms/android/data/chatbot/di/ChatBotDataModule.kt new file mode 100644 index 000000000..0a6b84702 --- /dev/null +++ b/data/src/main/kotlin/team/aliens/dms/android/data/chatbot/di/ChatBotDataModule.kt @@ -0,0 +1,19 @@ +package team.aliens.dms.android.data.chatbot.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import team.aliens.dms.android.data.chatbot.repository.ChatBotRepository +import team.aliens.dms.android.data.chatbot.repository.ChatBotRepositoryImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class ChatBotDataModule { + @Binds + @Singleton + abstract fun bindChatBotRepository( + chatBotRepositoryImpl: ChatBotRepositoryImpl, + ): ChatBotRepository +} diff --git a/data/src/main/kotlin/team/aliens/dms/android/data/chatbot/repository/ChatBotRepository.kt b/data/src/main/kotlin/team/aliens/dms/android/data/chatbot/repository/ChatBotRepository.kt new file mode 100644 index 000000000..d217d59a8 --- /dev/null +++ b/data/src/main/kotlin/team/aliens/dms/android/data/chatbot/repository/ChatBotRepository.kt @@ -0,0 +1,5 @@ +package team.aliens.dms.android.data.chatbot.repository + +interface ChatBotRepository { + suspend fun askQuestion(question: String): String +} diff --git a/data/src/main/kotlin/team/aliens/dms/android/data/chatbot/repository/ChatBotRepositoryImpl.kt b/data/src/main/kotlin/team/aliens/dms/android/data/chatbot/repository/ChatBotRepositoryImpl.kt new file mode 100644 index 000000000..37e283bf9 --- /dev/null +++ b/data/src/main/kotlin/team/aliens/dms/android/data/chatbot/repository/ChatBotRepositoryImpl.kt @@ -0,0 +1,15 @@ +package team.aliens.dms.android.data.chatbot.repository + +import team.aliens.dms.android.network.chatbot.datasource.NetworkChatBotDataSource +import team.aliens.dms.android.network.chatbot.model.ChatBotQuestionRequest +import javax.inject.Inject + +class ChatBotRepositoryImpl @Inject constructor( + private val networkChatBotDataSource: NetworkChatBotDataSource, +) : ChatBotRepository { + override suspend fun askQuestion(question: String): String { + return networkChatBotDataSource.askQuestion( + request = ChatBotQuestionRequest(question = question), + ).answer + } +} diff --git a/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/ChatBotScreen.kt b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/ChatBotScreen.kt new file mode 100644 index 000000000..0d388226f --- /dev/null +++ b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/ChatBotScreen.kt @@ -0,0 +1,252 @@ +package team.aliens.dms.android.feature.chatbot.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +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.platform.LocalFocusManager +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import team.aliens.dms.android.core.designsystem.DmsTheme +import team.aliens.dms.android.feature.chatbot.ui.component.ChatBotInputBar +import team.aliens.dms.android.feature.chatbot.ui.component.ChatBotMessageBubble +import team.aliens.dms.android.feature.chatbot.ui.component.ChatBotSuggestionChip +import team.aliens.dms.android.feature.chatbot.ui.component.ChatBotTypingIndicator +import team.aliens.dms.android.feature.chatbot.viewmodel.ChatBotMessage +import team.aliens.dms.android.feature.chatbot.viewmodel.ChatBotState +import team.aliens.dms.android.feature.chatbot.viewmodel.ChatBotViewModel + +@Composable +fun ChatBotRoute( + onInputFocusChange: (Boolean) -> Unit = {}, +) { + val viewModel: ChatBotViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val focusManager = LocalFocusManager.current + + ChatBotScreen( + state = state, + onInputChange = viewModel::onInputChange, + onSendClick = { + viewModel.sendQuestion() + }, + onSuggestionClick = { question -> + viewModel.onInputChange(question) + viewModel.sendQuestion() + focusManager.clearFocus() + }, + onInputFocusChange = onInputFocusChange, + ) +} + +@Composable +private fun ChatBotScreen( + state: ChatBotState, + onInputChange: (String) -> Unit, + onSendClick: () -> Unit, + onSuggestionClick: (String) -> Unit, + onInputFocusChange: (Boolean) -> Unit = {}, +) { + var isInputFocused by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + + LaunchedEffect(state.messages.size, state.isLoading) { + if (state.messages.isNotEmpty() || state.isLoading) { + listState.animateScrollToItem( + index = state.messages.size + if (state.isLoading) 1 else 0, + scrollOffset = 80, + ) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(DmsTheme.colorScheme.background) + .statusBarsPadding(), + ) { + if (state.messages.isEmpty() && !isInputFocused) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ChatBotHeader() + ChatBotSuggestionQuestions( + onSuggestionClick = onSuggestionClick, + ) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + contentPadding = PaddingValues( + top = 72.dp, + bottom = if (isInputFocused) { + 112.dp + } else { + 132.dp + }, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + ChatBotCompactHeader() + } + + state.messages.forEach { message -> + item { + ChatBotMessageItem(message = message) + } + } + + if (state.isLoading) { + item { + ChatBotTypingItem() + } + } + } + } + + ChatBotInputBar( + value = state.input, + onValueChange = onInputChange, + onSendClick = onSendClick, + enabled = !state.isLoading, + onFocusChange = { focused -> + isInputFocused = focused + onInputFocusChange(focused) + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .imePadding() + .padding( + start = 20.dp, + end = 20.dp, + bottom = if (isInputFocused) { + 12.dp + } else { + 16.dp + }, + ), + ) + } +} + +@Composable +private fun ChatBotHeader() { + Column( + modifier = Modifier.padding(top = 92.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "기숙사 생활,\n이제 바로 물어보세요", + color = DmsTheme.colorScheme.onBackground, + style = DmsTheme.typography.headline2, + textAlign = TextAlign.Center, + ) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 22.dp), + text = "외출, 점호, 벌점, 세탁실 이용 등 기숙사 규정을 AI\n가 빠르고 정확하게 안내해 드립니다.", + color = DmsTheme.colorScheme.inverseSurface, + style = DmsTheme.typography.body3, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun ChatBotCompactHeader() { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + text = "외출, 점호, 벌점, 세탁실 이용 등 기숙사 규정을 AI\n가 빠르고 정확하게 안내해 드립니다.", + color = DmsTheme.colorScheme.inverseSurface, + style = DmsTheme.typography.body3, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun ChatBotSuggestionQuestions( + onSuggestionClick: (String) -> Unit, +) { + Column( + modifier = Modifier.padding(top = 46.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ChatBotSuggestionChip( + text = "외출 신청은 언제까지 해야 해?", + onClick = { onSuggestionClick("외출 신청은 언제까지 해야 해?") }, + ) + ChatBotSuggestionChip( + text = "점호 시간 알려줘", + onClick = { onSuggestionClick("점호 시간 알려줘") }, + ) + ChatBotSuggestionChip( + text = "세탁실 이용 시간이 궁금해", + onClick = { onSuggestionClick("세탁실 이용 시간이 궁금해") }, + ) + ChatBotSuggestionChip( + text = "벌점 기준 알려줘", + onClick = { onSuggestionClick("벌점 기준 알려줘") }, + ) + } +} + +@Composable +private fun ChatBotMessageItem( + message: ChatBotMessage, +) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = if (message.isUser) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, + ) { + ChatBotMessageBubble( + text = message.text, + isUser = message.isUser, + ) + } +} + +@Composable +private fun ChatBotTypingItem() { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterStart, + ) { + ChatBotTypingIndicator() + } +} diff --git a/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotInputBar.kt b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotInputBar.kt new file mode 100644 index 000000000..a0922ecd9 --- /dev/null +++ b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotInputBar.kt @@ -0,0 +1,121 @@ +package team.aliens.dms.android.feature.chatbot.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import team.aliens.dms.android.core.designsystem.DmsTheme + +@Composable +internal fun ChatBotInputBar( + value: String, + onValueChange: (String) -> Unit, + onSendClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onFocusChange: (Boolean) -> Unit = {}, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .shadow( + elevation = 20.dp, + shape = RoundedCornerShape(28.dp), + ambientColor = Color.Black.copy(alpha = 0.20f), + spotColor = Color.Black.copy(alpha = 0.30f), + ), + shape = RoundedCornerShape(28.dp), + color = DmsTheme.colorScheme.surfaceTint, + ) { + Row( + modifier = Modifier.padding( + start = 24.dp, + top = 6.dp, + end = 6.dp, + bottom = 6.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, + ) { + if (value.isEmpty()) { + Text( + text = "DMS AI에게 질문해 보세요...", + color = DmsTheme.colorScheme.inverseOnSurface, + style = DmsTheme.typography.body3, + fontWeight = FontWeight.Normal, + ) + } + + BasicTextField( + value = value, + onValueChange = onValueChange, + enabled = enabled, + textStyle = DmsTheme.typography.body3.copy( + color = DmsTheme.colorScheme.surfaceBright, + fontWeight = FontWeight.Normal, + ), + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Send, + ), + keyboardActions = KeyboardActions( + onSend = { + if (enabled) { + onSendClick() + } + }, + ), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + onFocusChange(focusState.hasFocus) + }, + ) + } + + Spacer(modifier = Modifier.size(8.dp)) + + Surface( + modifier = Modifier + .size(40.dp) + .clickable( + enabled = enabled, + onClick = onSendClick, + ), + shape = CircleShape, + color = DmsTheme.colorScheme.onPrimaryContainer, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = "↑", + color = DmsTheme.colorScheme.surfaceTint, + style = DmsTheme.typography.title3, + fontWeight = FontWeight.Normal, + ) + } + } + } + } +} diff --git a/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotMessageBubble.kt b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotMessageBubble.kt new file mode 100644 index 000000000..ab36b2530 --- /dev/null +++ b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotMessageBubble.kt @@ -0,0 +1,55 @@ +package team.aliens.dms.android.feature.chatbot.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import team.aliens.dms.android.core.designsystem.DmsTheme + +@Composable +internal fun ChatBotMessageBubble( + text: String, + isUser: Boolean, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.widthIn(max = 280.dp), + shape = RoundedCornerShape(12.dp), + color = if (isUser) { + DmsTheme.colorScheme.surfaceVariant + } else { + DmsTheme.colorScheme.surfaceTint + }, + ) { + Text( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), + text = text, + color = DmsTheme.colorScheme.surfaceBright, + style = DmsTheme.typography.body3, + fontWeight = FontWeight.Normal, + ) + } +} + +@Composable +internal fun ChatBotTypingBubble( + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = DmsTheme.colorScheme.surfaceTint, + ) { + Box( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp), + ) { + ChatBotTypingIndicator() + } + } +} diff --git a/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotSuggestionChip.kt b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotSuggestionChip.kt new file mode 100644 index 000000000..0841e3f6b --- /dev/null +++ b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotSuggestionChip.kt @@ -0,0 +1,35 @@ +package team.aliens.dms.android.feature.chatbot.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import team.aliens.dms.android.core.designsystem.DmsTheme + +@Composable +internal fun ChatBotSuggestionChip( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = DmsTheme.colorScheme.surfaceTint, + ) { + Text( + modifier = Modifier.padding(horizontal = 22.dp, vertical = 14.dp), + text = text, + color = DmsTheme.colorScheme.surfaceBright, + style = DmsTheme.typography.body3, + fontWeight = FontWeight.Normal, + textAlign = TextAlign.Center, + ) + } +} diff --git a/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotTypingIndicator.kt b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotTypingIndicator.kt new file mode 100644 index 000000000..dd67905ab --- /dev/null +++ b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/ui/component/ChatBotTypingIndicator.kt @@ -0,0 +1,35 @@ +package team.aliens.dms.android.feature.chatbot.ui.component + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import team.aliens.dms.android.core.designsystem.DmsTheme + +@Composable +internal fun ChatBotTypingIndicator() { + Row( + modifier = Modifier + .clip(RoundedCornerShape(14.dp)) + .background(DmsTheme.colorScheme.surface) + .padding(horizontal = 18.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + repeat(3) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background(DmsTheme.colorScheme.inverseSurface), + ) + } + } +} diff --git a/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/viewmodel/ChatBotState.kt b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/viewmodel/ChatBotState.kt new file mode 100644 index 000000000..7f1d7f3a7 --- /dev/null +++ b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/viewmodel/ChatBotState.kt @@ -0,0 +1,12 @@ +package team.aliens.dms.android.feature.chatbot.viewmodel + +data class ChatBotState( + val input: String = "", + val messages: List = emptyList(), + val isLoading: Boolean = false, +) + +data class ChatBotMessage( + val text: String, + val isUser: Boolean, +) diff --git a/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/viewmodel/ChatBotViewModel.kt b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/viewmodel/ChatBotViewModel.kt new file mode 100644 index 000000000..d9727983b --- /dev/null +++ b/feature/src/main/kotlin/team/aliens/dms/android/feature/chatbot/viewmodel/ChatBotViewModel.kt @@ -0,0 +1,69 @@ +package team.aliens.dms.android.feature.chatbot.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import team.aliens.dms.android.data.chatbot.repository.ChatBotRepository +import javax.inject.Inject + +@HiltViewModel +internal class ChatBotViewModel @Inject constructor( + private val chatBotRepository: ChatBotRepository, +) : ViewModel() { + private val _state = MutableStateFlow(ChatBotState()) + val state: StateFlow = _state.asStateFlow() + + fun onInputChange(input: String) { + _state.update { + it.copy(input = input) + } + } + + fun sendQuestion() { + val question = state.value.input.trim() + + if (question.isEmpty() || state.value.isLoading) return + + viewModelScope.launch { + _state.update { + it.copy( + input = "", + isLoading = true, + messages = it.messages + ChatBotMessage( + text = question, + isUser = true, + ), + ) + } + + runCatching { + chatBotRepository.askQuestion(question) + }.onSuccess { answer -> + _state.update { + it.copy( + isLoading = false, + messages = it.messages + ChatBotMessage( + text = answer, + isUser = false, + ), + ) + } + }.onFailure { + _state.update { + it.copy( + isLoading = false, + messages = it.messages + ChatBotMessage( + text = "답변을 불러오지 못했어요. 잠시 후 다시 시도해 주세요.", + isUser = false, + ), + ) + } + } + } + } +} diff --git a/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/apiservice/ChatBotApiService.kt b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/apiservice/ChatBotApiService.kt new file mode 100644 index 000000000..8f90232eb --- /dev/null +++ b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/apiservice/ChatBotApiService.kt @@ -0,0 +1,13 @@ +package team.aliens.dms.android.network.chatbot.apiservice + +import retrofit2.http.Body +import retrofit2.http.POST +import team.aliens.dms.android.network.chatbot.model.ChatBotAnswerResponse +import team.aliens.dms.android.network.chatbot.model.ChatBotQuestionRequest + +interface ChatBotApiService { + @POST("/chatbots/questions") + suspend fun askQuestion( + @Body request: ChatBotQuestionRequest, + ): ChatBotAnswerResponse +} diff --git a/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/datasource/NetworkChatBotDataSource.kt b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/datasource/NetworkChatBotDataSource.kt new file mode 100644 index 000000000..f638161a8 --- /dev/null +++ b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/datasource/NetworkChatBotDataSource.kt @@ -0,0 +1,10 @@ +package team.aliens.dms.android.network.chatbot.datasource + +import team.aliens.dms.android.network.chatbot.model.ChatBotAnswerResponse +import team.aliens.dms.android.network.chatbot.model.ChatBotQuestionRequest + +interface NetworkChatBotDataSource { + suspend fun askQuestion( + request: ChatBotQuestionRequest, + ): ChatBotAnswerResponse +} diff --git a/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/datasource/NetworkChatBotDataSourceImpl.kt b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/datasource/NetworkChatBotDataSourceImpl.kt new file mode 100644 index 000000000..3ba3806e3 --- /dev/null +++ b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/datasource/NetworkChatBotDataSourceImpl.kt @@ -0,0 +1,16 @@ +package team.aliens.dms.android.network.chatbot.datasource + +import team.aliens.dms.android.network.chatbot.apiservice.ChatBotApiService +import team.aliens.dms.android.network.chatbot.model.ChatBotAnswerResponse +import team.aliens.dms.android.network.chatbot.model.ChatBotQuestionRequest +import javax.inject.Inject + +class NetworkChatBotDataSourceImpl @Inject constructor( + private val chatBotApiService: ChatBotApiService, +) : NetworkChatBotDataSource { + override suspend fun askQuestion( + request: ChatBotQuestionRequest, + ): ChatBotAnswerResponse { + return chatBotApiService.askQuestion(request) + } +} diff --git a/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/di/ChatBotNetworkModule.kt b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/di/ChatBotNetworkModule.kt new file mode 100644 index 000000000..bf641bb99 --- /dev/null +++ b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/di/ChatBotNetworkModule.kt @@ -0,0 +1,33 @@ +package team.aliens.dms.android.network.chatbot.di + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import team.aliens.dms.android.core.network.di.GlobalRetrofitClient +import team.aliens.dms.android.network.chatbot.apiservice.ChatBotApiService +import team.aliens.dms.android.network.chatbot.datasource.NetworkChatBotDataSource +import team.aliens.dms.android.network.chatbot.datasource.NetworkChatBotDataSourceImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class ChatBotNetworkModule { + @Binds + @Singleton + abstract fun bindNetworkChatBotDataSource( + networkChatBotDataSourceImpl: NetworkChatBotDataSourceImpl, + ): NetworkChatBotDataSource + + companion object { + @Provides + @Singleton + fun provideChatBotApiService( + @GlobalRetrofitClient retrofit: Retrofit, + ): ChatBotApiService { + return retrofit.create(ChatBotApiService::class.java) + } + } +} diff --git a/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/model/ChatBotAnswerResponse.kt b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/model/ChatBotAnswerResponse.kt new file mode 100644 index 000000000..aef8f0d2f --- /dev/null +++ b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/model/ChatBotAnswerResponse.kt @@ -0,0 +1,5 @@ +package team.aliens.dms.android.network.chatbot.model + +data class ChatBotAnswerResponse( + val answer: String, +) diff --git a/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/model/ChatBotQuestionRequest.kt b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/model/ChatBotQuestionRequest.kt new file mode 100644 index 000000000..4e7cd59d2 --- /dev/null +++ b/network/src/main/kotlin/team/aliens/dms/android/network/chatbot/model/ChatBotQuestionRequest.kt @@ -0,0 +1,5 @@ +package team.aliens.dms.android.network.chatbot.model + +data class ChatBotQuestionRequest( + val question: String, +)