Further improve the chat page

This commit is contained in:
Minecon724 2025-06-20 12:43:02 +02:00
commit a2597878d1
Signed by untrusted user who does not match committer: m724
GPG key ID: A02E6E67AB961189
15 changed files with 550 additions and 248 deletions

View file

@ -57,6 +57,7 @@ dependencies {
implementation(libs.hilt.android) implementation(libs.hilt.android)
implementation(libs.retrofit) implementation(libs.retrofit)
implementation(libs.retrofit.converter.gson) implementation(libs.retrofit.converter.gson)
implementation(libs.androidx.material3.window.size.class1)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View file

@ -13,12 +13,14 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ChatApp" > android:theme="@style/Theme.ChatApp"
android:enableOnBackInvokedCallback="true">
<activity <activity
android:name=".activity.chat.ChatActivity" android:name=".activity.chat.ChatActivity"
android:exported="true" android:exported="true"
android:label="@string/title_activity_chat" android:label="@string/title_activity_chat"
android:theme="@style/Theme.ChatApp" > android:theme="@style/Theme.ChatApp"
android:windowSoftInputMode="adjustNothing">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View file

@ -8,10 +8,12 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -31,132 +34,362 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.TextFieldDefaults 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
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.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import eu.m724.chatapp.activity.chat.compose.AnimatedChangingText import eu.m724.chatapp.activity.chat.ChatState.Companion.rememberChatState
import eu.m724.chatapp.activity.chat.compose.LanguageModelMistakeWarning 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.activity.ui.theme.ChatAppTheme
import eu.m724.chatapp.api.data.ChatMessage
import kotlinx.coroutines.launch
@AndroidEntryPoint @AndroidEntryPoint
class ChatActivity : ComponentActivity() { class ChatActivity : ComponentActivity() {
private val viewModel: ChatActivityViewModel by viewModels() private val viewModel: ChatActivityViewModel by viewModels()
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { 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 @Composable
fun Content( fun ChatScreen(
viewModel: ChatActivityViewModel // = viewModel() doesn't work windowSizeClass: WindowSizeClass,
uiState: ChatActivityUiState,
chatState: ChatState,
threadViewLazyListState: LazyListState,
onRequestFocus: () -> Unit
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val isTablet = windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact
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)
}
}
}
ChatAppTheme { ChatAppTheme {
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize().imePadding(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
ChatTopAppBar(uiState.chatTitle ?: "Start a new conversation") ChatTopAppBar(uiState.chatTitle ?: "Start a new conversation")
} }
) { innerPadding -> ) { innerPadding ->
Column( ChatScreenContent(
modifier = Modifier.fillMaxSize().padding(innerPadding), 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<ChatMessage>,
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 horizontalAlignment = Alignment.CenterHorizontally
) { ) {
LazyColumn( ChatToolBar(
modifier = Modifier.fillMaxSize().weight(1f), chatState = chatState,
state = lazyListState onEmptySpaceClick = onRequestFocus
) { )
items(uiState.messageHistory) { message ->
MessageExchange(
modifier = Modifier.padding(5.dp),
isComposing = false,
composerValue = message.prompt,
responseValue = message.response
)
}
item { LanguageModelMistakeWarning(
MessageExchange( modifier = Modifier
modifier = Modifier.padding(5.dp).fillParentMaxHeight(), .padding(vertical = 10.dp) // TODO this is troublesome if there's navigation bar below or any kind of padding
isComposing = !uiState.requestInProgress, )
composerValue = composerValue, }
onComposerValueChange = { composerValue = it }, }
composerFocusRequester = composerFocusRequester, )
responseValue = uiState.currentMessageResponse // TODO animate this }
)
}
}
Box( @Composable
modifier = Modifier.fillMaxWidth(), fun ThreadView(
contentAlignment = Alignment.CenterEnd lazyListState: LazyListState,
) { messages: List<ChatMessage>,
Column( liveResponse: String,
horizontalAlignment = Alignment.CenterHorizontally chatState: ChatState,
) { modifier: Modifier = Modifier
ComposerToolBar( ) {
modifier = Modifier.width(500.dp).padding(horizontal = 10.dp), val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current
canSend = composerValue.isNotBlank() && !uiState.requestInProgress,
onSend = {
viewModel.sendMessage(composerValue)
},
onEmptySpaceClick = {
composerFocusRequester.requestFocus()
localSoftwareKeyboardController?.show()
}
)
LanguageModelMistakeWarning( val composerValue = if (chatState.requestInProgress) {
modifier = Modifier.padding(vertical = 10.dp) 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( AnimatedChangingText(
text = title, 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"
)
}
} }
} }
} )
} }

View file

@ -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
}

View file

@ -1,5 +1,7 @@
package eu.m724.chatapp.activity.chat package eu.m724.chatapp.activity.chat
import eu.m724.chatapp.api.data.ChatMessage
data class ChatActivityUiState( data class ChatActivityUiState(
/** /**
* The title of the current chat * The title of the current chat
@ -14,20 +16,10 @@ data class ChatActivityUiState(
/** /**
* The response right now, updates when streaming * The response right now, updates when streaming
*/ */
val currentMessageResponse: String = "", val liveResponse: String = "",
/** /**
* All the messages of this chat * All the messages of this chat
*/ */
val messageHistory: List<ChatMessageExchange> = listOf(), val messages: List<ChatMessage> = listOf()
/**
* Error, if any, of the last request
*/
val requestLastError: String? = null
)
data class ChatMessageExchange(
val prompt: String,
var response: String
) )

View file

@ -6,9 +6,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import eu.m724.chatapp.api.AiApiService import eu.m724.chatapp.api.AiApiService
import eu.m724.chatapp.api.data.ChatMessage import eu.m724.chatapp.api.data.ChatMessage
import eu.m724.chatapp.api.data.request.ChatCompletionRequest import eu.m724.chatapp.api.data.request.ChatCompletionRequest
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -17,32 +19,36 @@ import javax.inject.Inject
class ChatActivityViewModel @Inject constructor( class ChatActivityViewModel @Inject constructor(
private val aiApiService: AiApiService private val aiApiService: AiApiService
) : ViewModel() { ) : ViewModel() {
private val messages = mutableListOf<ChatMessage>()
private val _uiState = MutableStateFlow(ChatActivityUiState()) private val _uiState = MutableStateFlow(ChatActivityUiState())
val uiState: StateFlow<ChatActivityUiState> = _uiState.asStateFlow() val uiState: StateFlow<ChatActivityUiState> = _uiState.asStateFlow()
private val messages = mutableListOf<ChatMessage>() private val _uiEvents = Channel<ChatActivityUiEvent>()
val uiEvents = _uiEvents.receiveAsFlow()
fun sendMessage(message: String) { fun sendMessage(message: String) {
_uiState.update { _uiState.update {
it.copy( var uiState = it.copy(
requestInProgress = true, requestInProgress = true,
currentMessageResponse = "" liveResponse = "",
) )
}
if (_uiState.value.chatTitle == null) { if (it.chatTitle == null) {
_uiState.update { uiState = uiState.copy(chatTitle = message)
it.copy(chatTitle = "User says \"$message\"") // TODO
} }
uiState
} }
messages.add(ChatMessage( messages.add(ChatMessage(
role = ChatMessage.Role.USER, role = ChatMessage.Role.User,
content = message content = message
)) ))
viewModelScope.launch { viewModelScope.launch {
_uiEvents.send(ChatActivityUiEvent.ProcessingRequest)
val response = aiApiService.chatComplete(ChatCompletionRequest( val response = aiApiService.chatComplete(ChatCompletionRequest(
model = "free-model", model = "free-model",
messages = messages, messages = messages,
@ -53,13 +59,16 @@ class ChatActivityViewModel @Inject constructor(
)) ))
if (!response.isSuccessful || response.body() == null) { if (!response.isSuccessful || response.body() == null) {
messages.removeLast()
_uiState.update { _uiState.update {
it.copy( it.copy(
requestInProgress = false, requestInProgress = false
requestLastError = response.code().toString()
) )
} }
_uiEvents.send(ChatActivityUiEvent.Error(response.code().toString()))
// TODO launch toast or something // TODO launch toast or something
return@launch return@launch
} }
@ -72,11 +81,11 @@ class ChatActivityViewModel @Inject constructor(
_uiState.update { _uiState.update {
it.copy( it.copy(
requestInProgress = false, requestInProgress = false,
messageHistory = it.messageHistory.plus( messages = messages.toList()
ChatMessageExchange(message, choice.message.content)
)
) )
} }
_uiEvents.send(ChatActivityUiEvent.SuccessfulResponse(message))
} }
} }
} }

View file

@ -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
}
}
}

View file

@ -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.AnimatedContent
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween

View file

@ -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<DisableBringIntoViewOnFocusNode>() {
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())

View file

@ -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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text

View file

@ -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
}
}

View file

@ -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,
)
)
}
}

View file

@ -1,12 +1,19 @@
package eu.m724.chatapp.api.data package eu.m724.chatapp.api.data
import com.google.gson.annotations.SerializedName
data class ChatMessage( data class ChatMessage(
val role: Role, val role: Role,
val content: String val content: String
) { ) {
enum class Role { enum class Role {
SYSTEM, @SerializedName("system")
USER, System,
ASSISTANT
@SerializedName("user")
User,
@SerializedName("assistant")
Assistant
} }
} }

View file

@ -49,17 +49,20 @@ enum class CompletionFinishReason {
/** /**
* The response has stopped, because the model said so * The response has stopped, because the model said so
*/ */
STOP, @SerializedName("stop")
Stop,
/** /**
* The response has stopped, because it got too long * The response has stopped, because it got too long
*/ */
LENGTH, @SerializedName("length")
Length,
/** /**
* The response has stopped, because the content got flagged * The response has stopped, because the content got flagged
*/ */
CONTENT_FILTER @SerializedName("content_filter")
ContentFilter
} }
data class CompletionTokenUsage( data class CompletionTokenUsage(

View file

@ -15,6 +15,7 @@ ksp = "2.1.21-2.0.2"
retrofit = "3.0.0" retrofit = "3.0.0"
secrets = "2.0.1" secrets = "2.0.1"
loggingInterceptor = "4.12.0" loggingInterceptor = "4.12.0"
material3WindowSizeClass = "1.3.2"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", 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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }