From 9cde4b275ed45bf6dbe13d29692d96497928ad09 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 21 May 2025 13:36:15 +0300 Subject: [PATCH] Add AI chat feature --- .../composeResources/values/strings.xml | 4 + .../chat/presentation/ChatViewModel.kt | 39 ++++++ .../chat/presentation/models/ChatAction.kt | 3 + .../chat/presentation/models/ChatEvent.kt | 7 ++ .../chat/presentation/models/ChatMessage.kt | 6 + .../chat/presentation/models/ChatViewState.kt | 7 ++ .../kotlin/feature/chat/ui/ChatScreen.kt | 114 ++++++++++++++++++ .../kotlin/navigation/AppScreens.kt | 3 + .../kotlin/navigation/MainScreen.kt | 11 +- 9 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonMain/kotlin/feature/chat/presentation/ChatViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatAction.kt create mode 100644 composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatMessage.kt create mode 100644 composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatViewState.kt create mode 100644 composeApp/src/commonMain/kotlin/feature/chat/ui/ChatScreen.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 07d3707..b4423ad 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -17,6 +17,10 @@ Settings Profile Statistics + AI Chat + Enter message + OpenAI API Key + Send Start Date End Date diff --git a/composeApp/src/commonMain/kotlin/feature/chat/presentation/ChatViewModel.kt b/composeApp/src/commonMain/kotlin/feature/chat/presentation/ChatViewModel.kt new file mode 100644 index 0000000..f399536 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/feature/chat/presentation/ChatViewModel.kt @@ -0,0 +1,39 @@ +package feature.chat.presentation + +import base.BaseViewModel +import feature.chat.presentation.models.ChatAction +import feature.chat.presentation.models.ChatEvent +import feature.chat.presentation.models.ChatMessage +import feature.chat.presentation.models.ChatViewState + +class ChatViewModel : BaseViewModel( + initialState = ChatViewState() +) { + override fun obtainEvent(viewEvent: ChatEvent) { + when (viewEvent) { + is ChatEvent.MessageChanged -> viewState = viewState.copy(currentMessage = viewEvent.text) + is ChatEvent.ApiKeyChanged -> viewState = viewState.copy(apiKey = viewEvent.key) + ChatEvent.SendClicked -> sendMessage() + } + } + + private fun sendMessage() { + val text = viewState.currentMessage.trim() + if (text.isEmpty()) return + val updatedMessages = viewState.messages + ChatMessage(text, true) + val reply = generateReply(text) + viewState = viewState.copy( + messages = updatedMessages + ChatMessage(reply, false), + currentMessage = "" + ) + } + + private fun generateReply(message: String): String { + val calories = message.filter { it.isDigit() }.toIntOrNull() + return if (calories != null) { + "You logged $calories kcal. Consider adjusting your meal plan if this exceeds your goal." + } else { + "Thanks for the message!" + } + } +} diff --git a/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatAction.kt b/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatAction.kt new file mode 100644 index 0000000..fb9113b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatAction.kt @@ -0,0 +1,3 @@ +package feature.chat.presentation.models + +sealed class ChatAction diff --git a/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatEvent.kt b/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatEvent.kt new file mode 100644 index 0000000..8a0fcbe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatEvent.kt @@ -0,0 +1,7 @@ +package feature.chat.presentation.models + +sealed class ChatEvent { + data class MessageChanged(val text: String) : ChatEvent() + data class ApiKeyChanged(val key: String) : ChatEvent() + data object SendClicked : ChatEvent() +} diff --git a/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatMessage.kt b/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatMessage.kt new file mode 100644 index 0000000..10af364 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatMessage.kt @@ -0,0 +1,6 @@ +package feature.chat.presentation.models + +data class ChatMessage( + val text: String, + val isUser: Boolean +) diff --git a/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatViewState.kt b/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatViewState.kt new file mode 100644 index 0000000..99e6afb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/feature/chat/presentation/models/ChatViewState.kt @@ -0,0 +1,7 @@ +package feature.chat.presentation.models + +data class ChatViewState( + val messages: List = emptyList(), + val currentMessage: String = "", + val apiKey: String = "" +) diff --git a/composeApp/src/commonMain/kotlin/feature/chat/ui/ChatScreen.kt b/composeApp/src/commonMain/kotlin/feature/chat/ui/ChatScreen.kt new file mode 100644 index 0000000..fc46cf7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/feature/chat/ui/ChatScreen.kt @@ -0,0 +1,114 @@ +package feature.chat.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import feature.chat.presentation.ChatViewModel +import feature.chat.presentation.models.ChatEvent +import feature.chat.presentation.models.ChatMessage +import org.jetbrains.compose.resources.stringResource +import tech.mobiledeveloper.jethabit.resources.Res +import tech.mobiledeveloper.jethabit.resources.chat_api_key_hint +import tech.mobiledeveloper.jethabit.resources.chat_message_hint +import tech.mobiledeveloper.jethabit.resources.chat_send +import tech.mobiledeveloper.jethabit.resources.chat_title +import ui.themes.JetHabitTheme +import ui.themes.components.JetHabitButton + +@Composable +fun ChatScreen( + navController: NavController, + viewModel: ChatViewModel = viewModel { ChatViewModel() } +) { + val viewState by viewModel.viewStates().collectAsState() + + ChatView(viewState = viewState) { viewModel.obtainEvent(it) } +} + +@Composable +private fun ChatView(viewState: feature.chat.presentation.models.ChatViewState, eventHandler: (ChatEvent) -> Unit) { + Surface( + modifier = Modifier.fillMaxSize(), + color = JetHabitTheme.colors.primaryBackground + ) { + Column(modifier = Modifier.fillMaxSize().padding(JetHabitTheme.shapes.padding)) { + OutlinedTextField( + value = viewState.apiKey, + onValueChange = { eventHandler(ChatEvent.ApiKeyChanged(it)) }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(Res.string.chat_api_key_hint)) }, + colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = JetHabitTheme.colors.primaryText, + focusedBorderColor = JetHabitTheme.colors.tintColor, + unfocusedBorderColor = JetHabitTheme.colors.secondaryText, + focusedLabelColor = JetHabitTheme.colors.tintColor, + unfocusedLabelColor = JetHabitTheme.colors.secondaryText + ) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth() + ) { + items(viewState.messages) { message -> + ChatRow(message) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = viewState.currentMessage, + onValueChange = { eventHandler(ChatEvent.MessageChanged(it)) }, + modifier = Modifier.weight(1f), + label = { Text(stringResource(Res.string.chat_message_hint)) }, + colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = JetHabitTheme.colors.primaryText, + focusedBorderColor = JetHabitTheme.colors.tintColor, + unfocusedBorderColor = JetHabitTheme.colors.secondaryText, + focusedLabelColor = JetHabitTheme.colors.tintColor, + unfocusedLabelColor = JetHabitTheme.colors.secondaryText + ) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + JetHabitButton( + onClick = { eventHandler(ChatEvent.SendClicked) }, + text = stringResource(Res.string.chat_send) + ) + } + } + } +} + +@Composable +private fun ChatRow(message: ChatMessage) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = if (message.isUser) Arrangement.End else Arrangement.Start + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = if (message.isUser) JetHabitTheme.colors.tintColor else JetHabitTheme.colors.secondaryBackground + ) { + Text( + text = message.text, + modifier = Modifier.padding(8.dp), + color = if (message.isUser) MaterialTheme.colors.onPrimary else JetHabitTheme.colors.primaryText + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/navigation/AppScreens.kt b/composeApp/src/commonMain/kotlin/navigation/AppScreens.kt index 4a9da47..81aea1f 100644 --- a/composeApp/src/commonMain/kotlin/navigation/AppScreens.kt +++ b/composeApp/src/commonMain/kotlin/navigation/AppScreens.kt @@ -4,6 +4,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Chat import androidx.compose.material.icons.outlined.Check import androidx.compose.ui.graphics.vector.ImageVector import org.jetbrains.compose.resources.StringResource @@ -12,6 +13,7 @@ import tech.mobiledeveloper.jethabit.resources.health_tab import tech.mobiledeveloper.jethabit.resources.title_daily import tech.mobiledeveloper.jethabit.resources.title_profile import tech.mobiledeveloper.jethabit.resources.title_statistics +import tech.mobiledeveloper.jethabit.resources.chat_title enum class AppScreens( val title: String, @@ -21,5 +23,6 @@ enum class AppScreens( Daily("daily", Icons.Default.Home, Res.string.title_daily), Health("health", Icons.Default.Favorite, Res.string.health_tab), Statistics("statistics", Icons.Outlined.Check, Res.string.title_statistics), + Chat("chat", Icons.Default.Chat, Res.string.chat_title), Profile("profile", Icons.Default.Person, Res.string.title_profile) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/navigation/MainScreen.kt b/composeApp/src/commonMain/kotlin/navigation/MainScreen.kt index 39b69fe..329cc0e 100644 --- a/composeApp/src/commonMain/kotlin/navigation/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/navigation/MainScreen.kt @@ -17,6 +17,7 @@ import feature.daily.ui.DailyScreen import feature.detail.ui.DetailScreen import feature.health.list.ui.HealthScreen import feature.health.track.ui.TrackHabitScreen +import feature.chat.ui.ChatScreen import screens.settings.SettingsScreen import feature.statistics.ui.StatisticsScreen import feature.create.ui.ComposeScreen @@ -33,6 +34,10 @@ enum class HealthScreens { Start, Track, Create } +enum class ChatScreens { + Start +} + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class ) @@ -43,6 +48,7 @@ fun MainScreen() { AppScreens.Daily, AppScreens.Health, AppScreens.Statistics, + AppScreens.Chat, AppScreens.Profile ) @@ -73,9 +79,12 @@ fun MainScreen() { ComposeScreen(type = type) } } - composable(AppScreens.Statistics.title) { + composable(AppScreens.Statistics.title) { StatisticsScreen() } + composable(AppScreens.Chat.title) { + ChatScreen(navController) + } navigation( startDestination = ProfileScreens.Start.name, route = AppScreens.Profile.title