Complete the chat page

This commit is contained in:
Minecon724 2025-06-17 18:45:29 +02:00
commit 758442d44b
Signed by: Minecon724
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.compose.setContent
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
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.material.icons.Icons
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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.tooling.preview.Preview
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
@AndroidEntryPoint
class ChatActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Content()
@ -50,18 +65,74 @@ class ChatActivity : ComponentActivity() {
}
@Composable
fun Content() {
fun Content(
viewModel: ChatActivityViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var composerValue by remember { mutableStateOf("") }
ChatAppTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { ChatTopAppBar() }
topBar = {
ChatTopAppBar(uiState.chatTitle ?: "Start a new conversation")
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).imePadding()
modifier = Modifier.fillMaxSize().padding(innerPadding).imePadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
MessageComposer(
modifier = Modifier.padding(5.dp)
val lazyListState = rememberLazyListState()
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)
@Composable
fun ChatTopAppBar() {
fun ChatTopAppBar(
title: String
) {
TopAppBar(
title = {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Start a new conversation"
)
AnimatedChangingText(title)
}
)
}
@Composable
fun MessageComposer(
modifier: Modifier = Modifier
fun MessageExchange(
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(
modifier = modifier,
modifier = modifier.padding(horizontal = 10.dp)
) {
TextField(
value = value,
onValueChange = {
value = it
},
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,
)
MessageComposer(
value = composerValue,
onValueChange = onComposerValueChange,
enabled = isComposing
)
ComposerToolBar()
if (!isComposing) {
Text(
text = responseValue,
modifier = Modifier
.padding(horizontal = 16.dp)
)
}
}
}
@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(
modifier = Modifier.fillMaxWidth().padding(10.dp),
modifier = modifier,
shape = RoundedCornerShape(100)
) {
Row(
@ -128,9 +258,14 @@ fun ComposerToolBar() {
) {
IconButton(
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 (
horizontalAlignment = Alignment.CenterHorizontally

View file

@ -1,5 +1,13 @@
package eu.m724.chatapp.activity.chat
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
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
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
@HiltViewModel
class ChatActivityViewModel @Inject constructor(
private val aiApiService: AiApiService
) : 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
)
}
}
}