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 189feaf79..13edb1543 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 @@ -7,15 +7,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable 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 @@ -23,32 +19,47 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay +import team.aliens.dms.android.app.MainActivityViewModel import team.aliens.dms.android.app.navigation.AdjustProfileScreenNav import team.aliens.dms.android.app.navigation.ApplicationScreenNav import team.aliens.dms.android.app.navigation.BottomNavigationBar +import team.aliens.dms.android.app.navigation.ChatBotScreenNav import team.aliens.dms.android.app.navigation.CheckPasswordScreenNav +import team.aliens.dms.android.app.navigation.EditPasswordScreenNav +import team.aliens.dms.android.app.navigation.FindIdScreenNav import team.aliens.dms.android.app.navigation.HomeScreenNav +import team.aliens.dms.android.app.navigation.LateStudyNav import team.aliens.dms.android.app.navigation.MealScreenNav import team.aliens.dms.android.app.navigation.MyPageScreenNav -import team.aliens.dms.android.app.navigation.FindIdScreenNav -import team.aliens.dms.android.app.navigation.ResetPasswordScreenNav import team.aliens.dms.android.app.navigation.NoticeDetailScreenNav import team.aliens.dms.android.app.navigation.NotificationScreenNav import team.aliens.dms.android.app.navigation.OnboardingScreenNav import team.aliens.dms.android.app.navigation.PointHistoryScreenNav import team.aliens.dms.android.app.navigation.RemainScreenNav -import team.aliens.dms.android.app.navigation.EditPasswordScreenNav +import team.aliens.dms.android.app.navigation.ResetPasswordScreenNav import team.aliens.dms.android.app.navigation.SelectProfileScreenNav import team.aliens.dms.android.app.navigation.SettingScreenNav import team.aliens.dms.android.app.navigation.SignInScreenNav +import team.aliens.dms.android.app.navigation.SignUpCompleteNav +import team.aliens.dms.android.app.navigation.SignUpEnterEmailNav +import team.aliens.dms.android.app.navigation.SignUpEnterEmailVerificationCodeNav +import team.aliens.dms.android.app.navigation.SignUpEnterSchoolVerificationCodeNav +import team.aliens.dms.android.app.navigation.SignUpEnterSchoolVerificationQuestionNav +import team.aliens.dms.android.app.navigation.SignUpEnterStudentNumberNav +import team.aliens.dms.android.app.navigation.SignUpSetIdNav +import team.aliens.dms.android.app.navigation.SignUpSetPasswordNav +import team.aliens.dms.android.app.navigation.SignUpTermsNav import team.aliens.dms.android.app.navigation.VoteScreenNav -import team.aliens.dms.android.app.MainActivityViewModel -import team.aliens.dms.android.app.navigation.LateStudyNav import team.aliens.dms.android.core.designsystem.snackbar.DmsSnackBar import team.aliens.dms.android.core.designsystem.snackbar.DmsSnackBarVisuals import team.aliens.dms.android.core.ui.navigation.LocalResultStore import team.aliens.dms.android.core.ui.navigation.rememberResultStore import team.aliens.dms.android.data.voting.model.AllVoteSearch +import team.aliens.dms.android.feature.chatbot.ui.ChatBotRoute +import team.aliens.dms.android.feature.editpassword.navigation.CheckPasswordRoute +import team.aliens.dms.android.feature.editpassword.navigation.EditPasswordRoute +import team.aliens.dms.android.feature.findid.navigation.FindIdRoute +import team.aliens.dms.android.feature.latestudy.navigation.LateStudyRoute import team.aliens.dms.android.feature.main.application.navigation.ApplicationRoute import team.aliens.dms.android.feature.main.home.navigation.HomeRoute import team.aliens.dms.android.feature.main.mypage.navigation.MyPageRoute @@ -60,9 +71,6 @@ import team.aliens.dms.android.feature.point.navigation.PointHistoryRoute import team.aliens.dms.android.feature.profile.route.AdjustProfileRoute import team.aliens.dms.android.feature.profile.route.SelectProfileRoute import team.aliens.dms.android.feature.remain.navigation.RemainApplicationRoute -import team.aliens.dms.android.feature.editpassword.navigation.CheckPasswordRoute -import team.aliens.dms.android.feature.editpassword.navigation.EditPasswordRoute -import team.aliens.dms.android.feature.findid.navigation.FindIdRoute import team.aliens.dms.android.feature.resetpassword.navigation.ResetPasswordRoute import team.aliens.dms.android.feature.setting.navigation.SettingRoute import team.aliens.dms.android.feature.signin.navigation.SignInRoute @@ -76,20 +84,7 @@ import team.aliens.dms.android.feature.signup.navigation.SignUpSetIdRoute import team.aliens.dms.android.feature.signup.navigation.SignUpSetPasswordRoute import team.aliens.dms.android.feature.signup.navigation.SignUpTermsRoute import team.aliens.dms.android.feature.vote.navigation.VoteRoute -import team.aliens.dms.android.app.navigation.SignUpCompleteNav -import team.aliens.dms.android.app.navigation.SignUpEnterEmailNav -import team.aliens.dms.android.app.navigation.SignUpEnterEmailVerificationCodeNav -import team.aliens.dms.android.app.navigation.SignUpEnterSchoolVerificationCodeNav -import team.aliens.dms.android.app.navigation.SignUpEnterSchoolVerificationQuestionNav -import team.aliens.dms.android.app.navigation.SignUpEnterStudentNumberNav -import team.aliens.dms.android.app.navigation.SignUpSetIdNav -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 fun DmsApp( @@ -102,14 +97,13 @@ 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 @@ -127,8 +121,7 @@ fun DmsApp( } Box( - modifier = Modifier - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), ) { Scaffold( bottomBar = { @@ -145,7 +138,7 @@ fun DmsApp( } backStack.add(destination) } - } + }, ) } }, @@ -153,8 +146,7 @@ fun DmsApp( ) { paddingValues -> CompositionLocalProvider(LocalResultStore provides resultStore) { NavDisplay( - modifier = Modifier - .padding(paddingValues), + modifier = Modifier.padding(paddingValues), backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryProvider = entryProvider { @@ -214,22 +206,18 @@ fun DmsApp( }, onNavigateLateStudyApplication = { backStack.add(LateStudyNav) - } + }, ) } entry { - ChatBotRoute( - onInputFocusChange = { focused -> - isChatInputFocused = focused - }, - ) + ChatBotRoute() } entry { VoteRoute( onNavigateBack = { backStack.remove(VoteScreenNav) }, onShowSnackBar = { snackBarType, message -> appState.showSnackBar(snackBarType, message) - } + }, ) } entry { @@ -240,7 +228,7 @@ fun DmsApp( }, onShowSnackBar = { snackBarType, message -> appState.showSnackBar(snackBarType, message) - } + }, ) } entry { @@ -274,7 +262,7 @@ fun DmsApp( } entry { MealRoute( - onNavigateBack = { backStack.remove(MealScreenNav) } + onNavigateBack = { backStack.remove(MealScreenNav) }, ) } entry { @@ -288,13 +276,13 @@ fun DmsApp( }, onShowSnackBar = { snackBarType, message -> appState.showSnackBar(snackBarType, message) - } + }, ) } entry { PointHistoryRoute( pointType = it.pointType, - onBackClick = { backStack.remove(PointHistoryScreenNav(it.pointType)) } + onBackClick = { backStack.remove(PointHistoryScreenNav(it.pointType)) }, ) } entry { @@ -316,7 +304,7 @@ fun DmsApp( onNavigateEditPassword = { backStack.add(EditPasswordScreenNav(it)) }, onShowSnackBar = { snackBar, message -> appState.showSnackBar(snackBar, message) - } + }, ) } entry { @@ -325,7 +313,7 @@ fun DmsApp( onNavigateAdjustProfile = { backStack.add(AdjustProfileScreenNav(model = it)) }, onShowSnackBar = { snackBar, message -> appState.showSnackBar(snackBar, message) - } + }, ) } entry { @@ -334,7 +322,7 @@ fun DmsApp( model = it.model, onShowSnackBar = { snackBar, message -> appState.showSnackBar(snackBar, message) - } + }, ) } entry { @@ -349,7 +337,7 @@ fun DmsApp( }, onShowSnackBar = { snackBarType, message -> appState.showSnackBar(snackBarType, message) - } + }, ) } entry { 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 index 0d388226f..02040af76 100644 --- 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 @@ -1,13 +1,17 @@ package team.aliens.dms.android.feature.chatbot.ui +import androidx.compose.animation.core.animateDpAsState 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn @@ -35,6 +39,13 @@ 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 +private val ChatBotInputBarHeight = 64.dp +private val ChatBotKeyboardInputBottomPadding = 12.dp +private val ChatBotBottomNavigationInputPadding = 16.dp +private val ChatBotScaffoldBottomBarPadding = 104.dp +private val ChatBotInputBarHorizontalPadding = 20.dp +private val ChatBotMessageBottomSpacing = 24.dp + @Composable fun ChatBotRoute( onInputFocusChange: (Boolean) -> Unit = {}, @@ -68,6 +79,28 @@ private fun ChatBotScreen( ) { var isInputFocused by remember { mutableStateOf(false) } val listState = rememberLazyListState() + val keyboardBottomPadding = WindowInsets.ime + .asPaddingValues() + .calculateBottomPadding() + val isKeyboardVisible = keyboardBottomPadding > 0.dp + + val targetInputBottomPadding = if (isKeyboardVisible) { + ( + keyboardBottomPadding - + ChatBotScaffoldBottomBarPadding + + ChatBotKeyboardInputBottomPadding + ).coerceAtLeast(ChatBotKeyboardInputBottomPadding) + } else { + ChatBotBottomNavigationInputPadding + } + + val inputBottomPadding by animateDpAsState( + targetValue = targetInputBottomPadding, + label = "ChatBotInputBottomPadding", + ) + + val inputBackgroundHeight = inputBottomPadding + ChatBotInputBarHeight + val messageBottomPadding = inputBackgroundHeight + ChatBotMessageBottomSpacing LaunchedEffect(state.messages.size, state.isLoading) { if (state.messages.isNotEmpty() || state.isLoading) { @@ -92,6 +125,7 @@ private fun ChatBotScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { ChatBotHeader() + ChatBotSuggestionQuestions( onSuggestionClick = onSuggestionClick, ) @@ -104,19 +138,11 @@ private fun ChatBotScreen( .padding(horizontal = 20.dp), contentPadding = PaddingValues( top = 72.dp, - bottom = if (isInputFocused) { - 112.dp - } else { - 132.dp - }, + bottom = messageBottomPadding, ), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - item { - ChatBotCompactHeader() - } - state.messages.forEach { message -> item { ChatBotMessageItem(message = message) @@ -131,26 +157,29 @@ private fun ChatBotScreen( } } + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(inputBackgroundHeight) + .background(DmsTheme.colorScheme.background), + ) + ChatBotInputBar( value = state.input, onValueChange = onInputChange, onSendClick = onSendClick, - enabled = !state.isLoading, + enabled = true, 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 - }, + start = ChatBotInputBarHorizontalPadding, + end = ChatBotInputBarHorizontalPadding, + bottom = inputBottomPadding, ), ) } @@ -173,7 +202,7 @@ private fun ChatBotHeader() { modifier = Modifier .fillMaxWidth() .padding(top = 22.dp), - text = "외출, 점호, 벌점, 세탁실 이용 등 기숙사 규정을 AI\n가 빠르고 정확하게 안내해 드립니다.", + text = "외출, 점호, 벌점, 세탁실 이용 등 기숙사 규정을 \nAI가 빠르고 정확하게 안내해 드립니다.", color = DmsTheme.colorScheme.inverseSurface, style = DmsTheme.typography.body3, textAlign = TextAlign.Center, @@ -181,19 +210,6 @@ private fun ChatBotHeader() { } } -@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, 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 index ab36b2530..d8ab9c391 100644 --- 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 @@ -8,6 +8,9 @@ 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.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import team.aliens.dms.android.core.designsystem.DmsTheme @@ -29,7 +32,11 @@ internal fun ChatBotMessageBubble( ) { Text( modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), - text = text, + text = if (isUser) { + AnnotatedString(text) + } else { + text.toChatBotMarkdownText() + }, color = DmsTheme.colorScheme.surfaceBright, style = DmsTheme.typography.body3, fontWeight = FontWeight.Normal, @@ -53,3 +60,27 @@ internal fun ChatBotTypingBubble( } } } + +private fun String.toChatBotMarkdownText(): AnnotatedString { + val convertedText = replace( + regex = Regex("""(?m)^\s*\*\s+"""), + replacement = "• ", + ) + val boldRegex = Regex("""\*\*(.*?)\*\*""") + + return buildAnnotatedString { + var currentIndex = 0 + + boldRegex.findAll(convertedText).forEach { matchResult -> + append(convertedText.substring(currentIndex, matchResult.range.first)) + + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append(matchResult.groupValues[1]) + pop() + + currentIndex = matchResult.range.last + 1 + } + + append(convertedText.substring(currentIndex)) + } +} 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 index dd67905ab..0d6007f7b 100644 --- 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 @@ -1,35 +1,93 @@ package team.aliens.dms.android.feature.chatbot.ui.component +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition 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.offset 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.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import team.aliens.dms.android.core.designsystem.DmsTheme +import kotlin.math.roundToInt @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), + modifier = Modifier.padding(start = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - repeat(3) { - Box( - modifier = Modifier - .size(6.dp) - .clip(CircleShape) - .background(DmsTheme.colorScheme.inverseSurface), + repeat(3) { index -> + ChatBotTypingDot( + delayMillis = index * 120, ) } } } + +@Composable +private fun ChatBotTypingDot( + delayMillis: Int, +) { + val infiniteTransition = rememberInfiniteTransition( + label = "ChatBotTypingDotTransition", + ) + + val offsetY by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -4f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 720 + 0f at delayMillis + -4f at delayMillis + 180 + 0f at delayMillis + 360 + 0f at 720 + }, + repeatMode = RepeatMode.Restart, + ), + label = "ChatBotTypingDotOffset", + ) + + val alpha by infiniteTransition.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 720 + 0.35f at delayMillis + 1f at delayMillis + 180 + 0.35f at delayMillis + 360 + 0.35f at 720 + }, + repeatMode = RepeatMode.Restart, + ), + label = "ChatBotTypingDotAlpha", + ) + + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = offsetY.roundToInt(), + ) + } + .size(6.dp) + .alpha(alpha) + .clip(CircleShape) + .background(DmsTheme.colorScheme.inverseSurface), + ) +}