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.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)

View file

@ -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">
<activity
android:name=".activity.chat.ChatActivity"
android:exported="true"
android:label="@string/title_activity_chat"
android:theme="@style/Theme.ChatApp" >
android:theme="@style/Theme.ChatApp"
android:windowSoftInputMode="adjustNothing">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View file

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

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
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<ChatMessageExchange> = listOf(),
/**
* Error, if any, of the last request
*/
val requestLastError: String? = null
)
data class ChatMessageExchange(
val prompt: String,
var response: String
val messages: List<ChatMessage> = listOf()
)

View file

@ -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<ChatMessage>()
private val _uiState = MutableStateFlow(ChatActivityUiState())
val uiState: StateFlow<ChatActivityUiState> = _uiState.asStateFlow()
private val messages = mutableListOf<ChatMessage>()
private val _uiEvents = Channel<ChatActivityUiEvent>()
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))
}
}
}

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

View file

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

View file

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