diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1a2a535..1a10b24 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(libs.hilt.android) implementation(libs.retrofit) implementation(libs.retrofit.converter.gson) + implementation(libs.androidx.material3.window.size.class1) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f90e474..a9b1d99 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,12 +13,14 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.ChatApp" > + android:theme="@style/Theme.ChatApp" + android:enableOnBackInvokedCallback="true"> + android:theme="@style/Theme.ChatApp" + android:windowSoftInputMode="adjustNothing"> diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt index 81f1f81..6f029bd 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt @@ -8,10 +8,12 @@ import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -20,6 +22,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -31,132 +34,362 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass 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.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint -import eu.m724.chatapp.activity.chat.compose.AnimatedChangingText -import eu.m724.chatapp.activity.chat.compose.LanguageModelMistakeWarning +import eu.m724.chatapp.activity.chat.ChatState.Companion.rememberChatState +import eu.m724.chatapp.activity.chat.composable.AnimatedChangingText +import eu.m724.chatapp.activity.chat.composable.LanguageModelMistakeWarning +import eu.m724.chatapp.activity.chat.composable.NestedScrollKeyboardHider +import eu.m724.chatapp.activity.chat.composable.SimpleTextFieldWithPadding +import eu.m724.chatapp.activity.chat.composable.disableBringIntoViewOnFocus import eu.m724.chatapp.activity.ui.theme.ChatAppTheme +import eu.m724.chatapp.api.data.ChatMessage +import kotlinx.coroutines.launch @AndroidEntryPoint class ChatActivity : ComponentActivity() { private val viewModel: ChatActivityViewModel by viewModels() + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { - Content(viewModel) + val windowSizeClass = calculateWindowSizeClass(this) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val softwareKeyboardController = LocalSoftwareKeyboardController.current + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val chatState = rememberChatState( + requestInProgress = uiState.requestInProgress, + onSend = { message -> + viewModel.sendMessage(message) + } + ) + + val threadViewLazyListState = rememberLazyListState() + + ChatScreen( + windowSizeClass = windowSizeClass, + uiState = uiState, + chatState = chatState, + threadViewLazyListState = threadViewLazyListState, + onRequestFocus = { + coroutineScope.launch { + if (threadViewLazyListState.layoutInfo.visibleItemsInfo.find { it.key == "composer" } == null) { + if (uiState.messages.isNotEmpty()) { + threadViewLazyListState.animateScrollToItem(uiState.messages.size) + } + } + + chatState.requestFocus() + softwareKeyboardController?.show() + } + } + ) + + LaunchedEffect(Unit) { + viewModel.uiEvents.collect { event -> + when (event) { + is ChatActivityUiEvent.ProcessingRequest -> { + threadViewLazyListState.animateScrollToItem(uiState.messages.size) + } + is ChatActivityUiEvent.SuccessfulResponse -> { + chatState.composerValue = "" + + if (uiState.messages.isNotEmpty()) { + threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2) + } + + threadViewLazyListState.layoutInfo.visibleItemsInfo.firstOrNull { + it.key == "composer" + }?.let { + chatState.requestFocus() + softwareKeyboardController?.show() // TODO perhaps it's pointless to focus since we can click on the toolbar? + } + } + is ChatActivityUiEvent.Error -> { + Toast.makeText(context, event.error, Toast.LENGTH_SHORT) + .show() // TODO better way of showing this. snackbar? + } + } + } + } } } } @Composable -fun Content( - viewModel: ChatActivityViewModel // = viewModel() doesn't work +fun ChatScreen( + windowSizeClass: WindowSizeClass, + uiState: ChatActivityUiState, + chatState: ChatState, + threadViewLazyListState: LazyListState, + onRequestFocus: () -> Unit ) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current - val context = LocalContext.current - - var composerValue by remember { mutableStateOf("") } - val composerFocusRequester = remember { FocusRequester() } - - val lazyListState = rememberLazyListState() - - LaunchedEffect(uiState.requestInProgress) { // TODO probably not the best way - if (!uiState.requestInProgress) { - if (uiState.requestLastError == null) { - composerValue = "" - composerFocusRequester.requestFocus() // TODO maybe make toggleable? or smart? - } else { - Toast.makeText(context, uiState.requestLastError, Toast.LENGTH_SHORT).show() // TODO better way of showing this - } - } else { - if (!uiState.messageHistory.isEmpty()) { - lazyListState.animateScrollToItem(uiState.messageHistory.size) - } - } - } + val isTablet = windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact ChatAppTheme { Scaffold( - modifier = Modifier.fillMaxSize().imePadding(), + modifier = Modifier.fillMaxSize(), topBar = { ChatTopAppBar(uiState.chatTitle ?: "Start a new conversation") } ) { innerPadding -> - Column( + ChatScreenContent( modifier = Modifier.fillMaxSize().padding(innerPadding), + isTablet = isTablet, + messages = uiState.messages, + liveResponse = uiState.liveResponse, + chatState = chatState, + threadViewLazyListState = threadViewLazyListState, + onRequestFocus = onRequestFocus + ) + } + } +} + +@Composable +fun ChatScreenContent( + modifier: Modifier = Modifier, + isTablet: Boolean, + messages: List, + liveResponse: String, + chatState: ChatState, + threadViewLazyListState: LazyListState, + onRequestFocus: () -> Unit +) { + val layout: @Composable (@Composable () -> Unit, @Composable () -> Unit) -> Unit = + if (isTablet) { + { thread, composer -> + Row( + modifier = modifier, + verticalAlignment = Alignment.Bottom + ) { + Box(modifier = Modifier.weight(1f)) { thread() } + Box(modifier = Modifier.width(500.dp)) { composer() } + } + } + } else { + { thread, composer -> + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box(modifier = Modifier.weight(1f)) { thread() } + composer() + } + } + } + + layout( + { + ThreadView( + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), + lazyListState = threadViewLazyListState, + messages = messages, + liveResponse = liveResponse, + chatState = chatState + ) + }, + { + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .padding(horizontal = 10.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - LazyColumn( - modifier = Modifier.fillMaxSize().weight(1f), - state = lazyListState - ) { - items(uiState.messageHistory) { message -> - MessageExchange( - modifier = Modifier.padding(5.dp), - isComposing = false, - composerValue = message.prompt, - responseValue = message.response - ) - } + ChatToolBar( + chatState = chatState, + onEmptySpaceClick = onRequestFocus + ) - item { - MessageExchange( - modifier = Modifier.padding(5.dp).fillParentMaxHeight(), - isComposing = !uiState.requestInProgress, - composerValue = composerValue, - onComposerValueChange = { composerValue = it }, - composerFocusRequester = composerFocusRequester, - responseValue = uiState.currentMessageResponse // TODO animate this - ) - } - } + LanguageModelMistakeWarning( + modifier = Modifier + .padding(vertical = 10.dp) // TODO this is troublesome if there's navigation bar below or any kind of padding + ) + } + } + ) +} - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterEnd - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - ComposerToolBar( - modifier = Modifier.width(500.dp).padding(horizontal = 10.dp), - canSend = composerValue.isNotBlank() && !uiState.requestInProgress, - onSend = { - viewModel.sendMessage(composerValue) - }, - onEmptySpaceClick = { - composerFocusRequester.requestFocus() - localSoftwareKeyboardController?.show() - } - ) +@Composable +fun ThreadView( + lazyListState: LazyListState, + messages: List, + liveResponse: String, + chatState: ChatState, + modifier: Modifier = Modifier +) { + val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current - LanguageModelMistakeWarning( - modifier = Modifier.padding(vertical = 10.dp) - ) - } - } + val composerValue = if (chatState.requestInProgress) { + chatState.lastPrompt + } else { + chatState.composerValue + } + + LazyColumn( + modifier = modifier + .nestedScroll( // Hides the keyboard when scrolling + NestedScrollKeyboardHider(localSoftwareKeyboardController) + ), + state = lazyListState + ) { + items(messages) { message -> + if (message.role == ChatMessage.Role.User) { + ChatMessagePrompt( + content = message.content, + modifier = Modifier.padding(vertical = 10.dp) + ) + } else if (message.role == ChatMessage.Role.Assistant) { + ChatMessageResponse( + content = message.content + ) + } + } + + item(key = "composer") { + ChatMessageComposer( + modifier = Modifier + .fillParentMaxHeight() // so that you can click anywhere on the screen to focus the text field + .disableBringIntoViewOnFocus() + .focusRequester(chatState.focusRequester), + value = composerValue, + onValueChange = { + chatState.composerValue = it + }, + submitted = chatState.requestInProgress + ) + } + + if (chatState.requestInProgress) { + item { + ChatMessageResponse( + content = liveResponse + ) + } + } + } +} + +@Composable +fun ChatMessagePrompt( + content: String, + modifier: Modifier = Modifier +) { + Text( + text = content, + modifier = modifier + .padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) +} + +@Composable +fun ChatMessageResponse( + content: String, + modifier: Modifier = Modifier +) { + Text( + text = content, + modifier = modifier + ) +} + +@Composable +fun ChatMessageComposer( + value: String, + onValueChange: (String) -> Unit, + submitted: Boolean, + modifier: Modifier = Modifier +) { + val textPadding by animateDpAsState( + targetValue = if (submitted) 16.dp else 0.dp, + label = "composerTextPaddingAnimation" + ) + + val textOpacity by animateFloatAsState( + targetValue = if (submitted) 0.7f else 1.0f, + label = "composerTextOpacityAnimation" + ) + + SimpleTextFieldWithPadding( + modifier = modifier, + value = value, + onValueChange = onValueChange, + enabled = !submitted, + placeholder = { + Text("Type your message...") // TODO hide when just browsing history? + }, + padding = PaddingValues(vertical = 10.dp, horizontal = textPadding), + textStyle = LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = textOpacity) + ) + ) +} + +@Composable +fun ChatToolBar( + modifier: Modifier = Modifier, + chatState: ChatState, + onEmptySpaceClick: () -> Unit +) { + val sendButtonColor by animateColorAsState( + targetValue = if (chatState.canSend) { + IconButtonDefaults.iconButtonColors().contentColor + } else { + IconButtonDefaults.iconButtonColors().disabledContentColor + }, label = "sendButtonColor" + ) + + ElevatedCard( + modifier = modifier, + shape = RoundedCornerShape(24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onEmptySpaceClick), + horizontalArrangement = Arrangement.End + ) { + IconButton( + onClick = chatState::performSend, + modifier = Modifier + .height(48.dp) + .padding(horizontal = 8.dp), + enabled = chatState.canSend, + colors = IconButtonDefaults.iconButtonColors( + contentColor = sendButtonColor, + disabledContentColor = sendButtonColor + ) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Send" + ) } } } @@ -175,136 +408,8 @@ fun ChatTopAppBar( ) { AnimatedChangingText( text = title, - ) // TODO fade - } - } - ) -} - -@Composable -fun MessageExchange( - isComposing: Boolean, - composerValue: String, - modifier: Modifier = Modifier, - onComposerValueChange: (String) -> Unit = {}, - composerFocusRequester: FocusRequester = FocusRequester(), - responseValue: String = "", -) { - - Column( - modifier = modifier.padding(horizontal = 10.dp) - ) { - MessageComposer( - value = composerValue, - onValueChange = onComposerValueChange, - enabled = isComposing, - focusRequester = composerFocusRequester - ) - - if (!isComposing) { - Text( - text = responseValue, - modifier = Modifier - .padding(horizontal = 16.dp) - ) - } - - } -} - -@Composable -fun MessageComposer( - value: String, - onValueChange: (String) -> Unit, - enabled: Boolean, - focusRequester: FocusRequester, - modifier: Modifier = Modifier -) { - val textFieldPadding by animateDpAsState( - targetValue = if (enabled) { - 0.dp - } else { - 16.dp - } - ) - - val textFieldTextColor by animateColorAsState( - targetValue = if (enabled) { - TextFieldDefaults.colors().focusedTextColor - } else { - TextFieldDefaults.colors().disabledTextColor - } - ) - - TextField( - value = value, - onValueChange = onValueChange, - modifier = modifier - .fillMaxWidth() - .padding(horizontal = textFieldPadding) - .focusRequester(focusRequester), - // .animateContentSize() - placeholder = { - Text("Type your message...") - }, - colors = TextFieldDefaults.colors( - unfocusedContainerColor = Color.Transparent, - focusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - disabledTextColor = textFieldTextColor - ), - enabled = enabled - ) -} - -@Composable -fun ComposerToolBar( - modifier: Modifier = Modifier, - canSend: Boolean, - onSend: () -> Unit, - onEmptySpaceClick: () -> Unit -) { - val sendButtonColor by animateColorAsState( - targetValue = if (canSend) { - IconButtonDefaults.iconButtonColors().contentColor - } else { - IconButtonDefaults.iconButtonColors().disabledContentColor - } - ) - - ElevatedCard( - modifier = modifier.clickable( - onClick = onEmptySpaceClick - ), - shape = RoundedCornerShape(100) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - IconButton( - onClick = { - onSend() - }, - modifier = Modifier.height(48.dp).padding(horizontal = 8.dp), - enabled = canSend, - colors = IconButtonDefaults.iconButtonColors( - contentColor = sendButtonColor, - disabledContentColor = sendButtonColor ) - ) { - Column ( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = "Send" - ) - } } } - } + ) } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiEvent.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiEvent.kt new file mode 100644 index 0000000..0e18fc1 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiEvent.kt @@ -0,0 +1,7 @@ +package eu.m724.chatapp.activity.chat + +sealed interface ChatActivityUiEvent { + data object ProcessingRequest : ChatActivityUiEvent + data class SuccessfulResponse(val message: String): ChatActivityUiEvent + data class Error(val error: String): ChatActivityUiEvent +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt index f1170b8..2f11741 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt @@ -1,5 +1,7 @@ package eu.m724.chatapp.activity.chat +import eu.m724.chatapp.api.data.ChatMessage + data class ChatActivityUiState( /** * The title of the current chat @@ -14,20 +16,10 @@ data class ChatActivityUiState( /** * The response right now, updates when streaming */ - val currentMessageResponse: String = "", + val liveResponse: String = "", /** * All the messages of this chat */ - val messageHistory: List = listOf(), - - /** - * Error, if any, of the last request - */ - val requestLastError: String? = null -) - -data class ChatMessageExchange( - val prompt: String, - var response: String + val messages: List = listOf() ) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt index c440d75..85922c1 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt @@ -6,9 +6,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel import eu.m724.chatapp.api.AiApiService import eu.m724.chatapp.api.data.ChatMessage import eu.m724.chatapp.api.data.request.ChatCompletionRequest +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -17,32 +19,36 @@ import javax.inject.Inject class ChatActivityViewModel @Inject constructor( private val aiApiService: AiApiService ) : ViewModel() { + private val messages = mutableListOf() + private val _uiState = MutableStateFlow(ChatActivityUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val messages = mutableListOf() + private val _uiEvents = Channel() + val uiEvents = _uiEvents.receiveAsFlow() fun sendMessage(message: String) { _uiState.update { - it.copy( + var uiState = it.copy( requestInProgress = true, - currentMessageResponse = "" + liveResponse = "", ) - } - if (_uiState.value.chatTitle == null) { - _uiState.update { - it.copy(chatTitle = "User says \"$message\"") // TODO + if (it.chatTitle == null) { + uiState = uiState.copy(chatTitle = message) } + + uiState } messages.add(ChatMessage( - role = ChatMessage.Role.USER, + role = ChatMessage.Role.User, content = message )) - viewModelScope.launch { + _uiEvents.send(ChatActivityUiEvent.ProcessingRequest) + val response = aiApiService.chatComplete(ChatCompletionRequest( model = "free-model", messages = messages, @@ -53,13 +59,16 @@ class ChatActivityViewModel @Inject constructor( )) if (!response.isSuccessful || response.body() == null) { + messages.removeLast() + _uiState.update { it.copy( - requestInProgress = false, - requestLastError = response.code().toString() + requestInProgress = false ) } + _uiEvents.send(ChatActivityUiEvent.Error(response.code().toString())) + // TODO launch toast or something return@launch } @@ -72,11 +81,11 @@ class ChatActivityViewModel @Inject constructor( _uiState.update { it.copy( requestInProgress = false, - messageHistory = it.messageHistory.plus( - ChatMessageExchange(message, choice.message.content) - ) + messages = messages.toList() ) } + + _uiEvents.send(ChatActivityUiEvent.SuccessfulResponse(message)) } } } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatState.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatState.kt new file mode 100644 index 0000000..541c948 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatState.kt @@ -0,0 +1,59 @@ +package eu.m724.chatapp.activity.chat + +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.focus.FocusRequester + +class ChatState( + val focusRequester: FocusRequester, + private val onSend: (String) -> Unit, // Store the lambda + initialRequestInProgress: Boolean +) { + var composerValue by mutableStateOf("") + var lastPrompt by mutableStateOf("") + var requestInProgress by mutableStateOf(initialRequestInProgress) + + val canSend: Boolean + get() = composerValue.isNotBlank() && !requestInProgress + + // This method will be called by the UI (e.g., the send button) + fun performSend() { + if (canSend) { + lastPrompt = composerValue + onSend(composerValue) + composerValue = "" + } + } + + fun requestFocus() { + focusRequester.requestFocus() + } + + companion object { + @Composable + fun rememberChatState( + requestInProgress: Boolean, + onSend: (String) -> Unit, // Takes the message string as a parameter + focusRequester: FocusRequester = remember { FocusRequester() } + ): ChatState { + val state = remember { + ChatState( + focusRequester = focusRequester, + onSend = onSend, // Pass the lambda directly + initialRequestInProgress = requestInProgress + ) + } + + LaunchedEffect(requestInProgress) { + state.requestInProgress = requestInProgress + } + + return state + } + } +} + diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/compose/AnimatedChangingText.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/AnimatedChangingText.kt similarity index 95% rename from app/src/main/java/eu/m724/chatapp/activity/chat/compose/AnimatedChangingText.kt rename to app/src/main/java/eu/m724/chatapp/activity/chat/composable/AnimatedChangingText.kt index 84808da..5542417 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/compose/AnimatedChangingText.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/AnimatedChangingText.kt @@ -1,4 +1,4 @@ -package eu.m724.chatapp.activity.chat.compose +package eu.m724.chatapp.activity.chat.composable import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.tween diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/composable/DisableBringIntoViewOnFocusNode.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/DisableBringIntoViewOnFocusNode.kt new file mode 100644 index 0000000..1f25aa7 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/DisableBringIntoViewOnFocusNode.kt @@ -0,0 +1,44 @@ +package eu.m724.chatapp.activity.chat.composable + +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.relocation.BringIntoViewModifierNode + +private class DisableBringIntoViewOnFocusNode : Modifier.Node(), BringIntoViewModifierNode { + // When a child of this modifier requests to be brought into view, this method is called. + // By providing an empty implementation, we effectively "swallow" the request + // and prevent it from propagating up to the LazyColumn, which would otherwise scroll. + override suspend fun bringIntoView( + childCoordinates: LayoutCoordinates, + boundsProvider: () -> Rect? + ) { + + } +} + +private class DisableBringIntoViewOnFocusElement : ModifierNodeElement() { + override fun create() = DisableBringIntoViewOnFocusNode() + + override fun equals(other: Any?): Boolean { + return other === this + } + + override fun hashCode(): Int { + return "disableBringIntoViewOnFocus".hashCode() + } + + override fun update(node: DisableBringIntoViewOnFocusNode) { + + } + + override fun InspectorInfo.inspectableProperties() { + name = "disableBringIntoViewOnFocus" + } + +} + +// This is the public Modifier function you will use in your code. +fun Modifier.disableBringIntoViewOnFocus(): Modifier = this.then(DisableBringIntoViewOnFocusElement()) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/compose/LanguageModelMistakeWarning.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/LanguageModelMistakeWarning.kt similarity index 91% rename from app/src/main/java/eu/m724/chatapp/activity/chat/compose/LanguageModelMistakeWarning.kt rename to app/src/main/java/eu/m724/chatapp/activity/chat/composable/LanguageModelMistakeWarning.kt index 167540d..b8e2af2 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/compose/LanguageModelMistakeWarning.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/LanguageModelMistakeWarning.kt @@ -1,4 +1,4 @@ -package eu.m724.chatapp.activity.chat.compose +package eu.m724.chatapp.activity.chat.composable import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/composable/NestedScrollKeyboardHider.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/NestedScrollKeyboardHider.kt new file mode 100644 index 0000000..bd597f3 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/NestedScrollKeyboardHider.kt @@ -0,0 +1,16 @@ +package eu.m724.chatapp.activity.chat.composable + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.platform.SoftwareKeyboardController + +class NestedScrollKeyboardHider( + private val softwareKeyboardController: SoftwareKeyboardController? +) : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (available.y > 0) // if scrolling up (content up, finger down) + softwareKeyboardController?.hide() + return Offset.Companion.Zero + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/composable/SimpleTextFieldWithPadding.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/SimpleTextFieldWithPadding.kt new file mode 100644 index 0000000..9b7c52a --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/SimpleTextFieldWithPadding.kt @@ -0,0 +1,55 @@ +package eu.m724.chatapp.activity.chat.composable + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SimpleTextFieldWithPadding( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + placeholder: @Composable () -> Unit, + padding: PaddingValues, + textStyle: TextStyle, + modifier: Modifier = Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + textStyle = textStyle, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface) + ) { innerTextField -> + TextFieldDefaults.DecorationBox( + value = value, + visualTransformation = VisualTransformation.None, + innerTextField = innerTextField, + singleLine = false, + enabled = true, // to make things easier + interactionSource = interactionSource, + contentPadding = padding, + placeholder = placeholder, + colors = TextFieldDefaults.colors( + unfocusedContainerColor = Color.Transparent, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/api/data/ChatMessage.kt b/app/src/main/java/eu/m724/chatapp/api/data/ChatMessage.kt index 492484f..d4887f7 100644 --- a/app/src/main/java/eu/m724/chatapp/api/data/ChatMessage.kt +++ b/app/src/main/java/eu/m724/chatapp/api/data/ChatMessage.kt @@ -1,12 +1,19 @@ package eu.m724.chatapp.api.data +import com.google.gson.annotations.SerializedName + data class ChatMessage( val role: Role, val content: String ) { enum class Role { - SYSTEM, - USER, - ASSISTANT + @SerializedName("system") + System, + + @SerializedName("user") + User, + + @SerializedName("assistant") + Assistant } } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/api/data/response/ChatCompletionResponse.kt b/app/src/main/java/eu/m724/chatapp/api/data/response/ChatCompletionResponse.kt index ac66fb0..60e6b9b 100644 --- a/app/src/main/java/eu/m724/chatapp/api/data/response/ChatCompletionResponse.kt +++ b/app/src/main/java/eu/m724/chatapp/api/data/response/ChatCompletionResponse.kt @@ -49,17 +49,20 @@ enum class CompletionFinishReason { /** * The response has stopped, because the model said so */ - STOP, + @SerializedName("stop") + Stop, /** * The response has stopped, because it got too long */ - LENGTH, + @SerializedName("length") + Length, /** * The response has stopped, because the content got flagged */ - CONTENT_FILTER + @SerializedName("content_filter") + ContentFilter } data class CompletionTokenUsage( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c62f1f..c73221a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ ksp = "2.1.21-2.0.2" retrofit = "3.0.0" secrets = "2.0.1" loggingInterceptor = "4.12.0" +material3WindowSizeClass = "1.3.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -38,6 +39,7 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit"} logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" } +androidx-material3-window-size-class1 = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "material3WindowSizeClass" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }