Complete the chat page
This commit is contained in:
parent
e1245231ed
commit
758442d44b
4 changed files with 280 additions and 42 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue