Complete the chat page

This commit is contained in:
Minecon724 2025-06-17 18:45:29 +02:00
commit 758442d44b
Signed by untrusted user who does not match committer: m724
GPG key ID: A02E6E67AB961189
4 changed files with 280 additions and 42 deletions

View file

@ -4,14 +4,20 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
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
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.automirrored.filled.Send
@ -19,6 +25,8 @@ import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api 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.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.TextField
@ -37,11 +45,18 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import dagger.hilt.android.AndroidEntryPoint
import eu.m724.chatapp.activity.chat.compose.AnimatedChangingText
import eu.m724.chatapp.activity.ui.theme.ChatAppTheme import eu.m724.chatapp.activity.ui.theme.ChatAppTheme
@AndroidEntryPoint
class ChatActivity : ComponentActivity() { class ChatActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
Content() Content()
@ -50,18 +65,74 @@ class ChatActivity : ComponentActivity() {
} }
@Composable @Composable
fun Content() { fun Content(
viewModel: ChatActivityViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var composerValue by remember { mutableStateOf("") }
ChatAppTheme { ChatAppTheme {
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { ChatTopAppBar() } topBar = {
ChatTopAppBar(uiState.chatTitle ?: "Start a new conversation")
}
) { innerPadding -> ) { innerPadding ->
Column( Column(
modifier = Modifier.padding(innerPadding).imePadding() modifier = Modifier.fillMaxSize().padding(innerPadding).imePadding(),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
MessageComposer( val lazyListState = rememberLazyListState()
modifier = Modifier.padding(5.dp)
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
)
}
item {
MessageExchange(
modifier = Modifier.padding(5.dp).fillParentMaxHeight(),
isComposing = !uiState.requestInProgress,
composerValue = composerValue,
onComposerValueChange = { composerValue = it },
responseValue = uiState.currentMessageResponse // TODO animate this
)
}
}
ComposerToolBar(
modifier = Modifier.width(500.dp).padding(horizontal = 10.dp),
canSend = composerValue.isNotBlank() && !uiState.requestInProgress,
onSend = {
viewModel.sendMessage(composerValue)
}
) )
Text(
text = "AI can make mistakes, double-check.",
modifier = Modifier.padding(vertical = 10.dp),
color = MaterialTheme.colorScheme.secondary.copy(alpha = 0.6f),
fontSize = 12.sp,
lineHeight = 14.sp
)
LaunchedEffect(uiState.requestInProgress) { // TODO probably not the best way
if (!uiState.requestInProgress) {
composerValue = ""
if (!uiState.messageHistory.isEmpty()) {
lazyListState.animateScrollToItem(uiState.messageHistory.size - 1)
}
}
}
} }
} }
} }
@ -69,57 +140,116 @@ fun Content() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatTopAppBar() { fun ChatTopAppBar(
title: String
) {
TopAppBar( TopAppBar(
title = { title = {
Text( AnimatedChangingText(title)
modifier = Modifier.fillMaxWidth(),
text = "Start a new conversation"
)
} }
) )
} }
@Composable @Composable
fun MessageComposer( fun MessageExchange(
modifier: Modifier = Modifier modifier: Modifier = Modifier,
isComposing: Boolean,
composerValue: String,
onComposerValueChange: (String) -> Unit = {},
responseValue: String = "",
) { ) {
var value by remember { mutableStateOf("Hello") }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Column( Column(
modifier = modifier, modifier = modifier.padding(horizontal = 10.dp)
) { ) {
TextField( MessageComposer(
value = value, value = composerValue,
onValueChange = { onValueChange = onComposerValueChange,
value = it enabled = isComposing
},
modifier = Modifier.weight(1f).padding(horizontal = 10.dp).focusRequester(focusRequester),
placeholder = {
Text("Type something here...")
},
shape = RoundedCornerShape(16.dp),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
)
) )
ComposerToolBar() if (!isComposing) {
Text(
text = responseValue,
modifier = Modifier
.padding(horizontal = 16.dp)
)
}
} }
} }
@Composable @Composable
fun ComposerToolBar() { fun MessageComposer(
value: String,
onValueChange: (String) -> Unit,
enabled: Boolean,
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
}
)
val focusRequester = remember { FocusRequester() }
LaunchedEffect(enabled) {
if (enabled) {
focusRequester.requestFocus()
}
}
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
) {
val sendButtonColor by animateColorAsState(
targetValue = if (canSend) {
IconButtonDefaults.iconButtonColors().contentColor
} else {
IconButtonDefaults.iconButtonColors().disabledContentColor
}
)
ElevatedCard( ElevatedCard(
modifier = Modifier.fillMaxWidth().padding(10.dp), modifier = modifier,
shape = RoundedCornerShape(100) shape = RoundedCornerShape(100)
) { ) {
Row( Row(
@ -128,9 +258,14 @@ fun ComposerToolBar() {
) { ) {
IconButton( IconButton(
onClick = { onClick = {
// TODO send onSend()
}, },
modifier = Modifier.size(48.dp) modifier = Modifier.height(48.dp).padding(horizontal = 8.dp),
enabled = canSend,
colors = IconButtonDefaults.iconButtonColors(
contentColor = sendButtonColor,
disabledContentColor = sendButtonColor
)
) { ) {
Column ( Column (
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally

View file

@ -1,5 +1,13 @@
package eu.m724.chatapp.activity.chat package eu.m724.chatapp.activity.chat
data class ChatActivityUiState( data class ChatActivityUiState(
val requestInProgress: Boolean val chatTitle: String? = null,
val requestInProgress: Boolean = false,
val currentMessageResponse: String = "",
val messageHistory: List<ChatMessageExchange> = listOf()
)
data class ChatMessageExchange(
val prompt: String,
var response: String
) )

View file

@ -1,13 +1,67 @@
package eu.m724.chatapp.activity.chat package eu.m724.chatapp.activity.chat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import eu.m724.chatapp.api.AiApiService import eu.m724.chatapp.api.AiApiService
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ChatActivityViewModel @Inject constructor( class ChatActivityViewModel @Inject constructor(
private val aiApiService: AiApiService private val aiApiService: AiApiService
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ChatActivityUiState())
val uiState: StateFlow<ChatActivityUiState> = _uiState.asStateFlow()
val responses = arrayOf(
"Hello right back at you! How can I help you today?",
"I'm sorry, but I can't assist with that."
)
fun sendMessage(message: String) {
_uiState.update {
it.copy(
requestInProgress = true,
currentMessageResponse = ""
)
}
if (_uiState.value.chatTitle == null) {
_uiState.update {
it.copy(chatTitle = message) // TODO
}
}
viewModelScope.launch {
val response = responses.random()
val targetResponseParts = response.split(" ")
for (part in targetResponseParts) {
delay(50)
_uiState.update {
it.copy(
currentMessageResponse = it.currentMessageResponse.trim() + " $part"
)
}
}
_uiState.update {
it.copy(
requestInProgress = false,
messageHistory = it.messageHistory.plus(
ChatMessageExchange(message, response)
)
)
}
}
}
} }

View file

@ -0,0 +1,41 @@
package eu.m724.chatapp.activity.chat.compose
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun AnimatedChangingText(
text: String,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
AnimatedContent(
targetState = text,
transitionSpec = {
val enter = fadeIn() // + expandHorizontally()
val exit = fadeOut() + shrinkHorizontally(animationSpec = tween(800))
enter.togetherWith(exit)
},
label = "Animated horizontal text"
) { targetText ->
Text(
text = targetText,
softWrap = false
)
}
}
}