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.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,76 +65,191 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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
|
||||||
|
fun MessageExchange(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isComposing: Boolean,
|
||||||
|
composerValue: String,
|
||||||
|
onComposerValueChange: (String) -> Unit = {},
|
||||||
|
responseValue: String = "",
|
||||||
|
) {
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.padding(horizontal = 10.dp)
|
||||||
|
) {
|
||||||
|
MessageComposer(
|
||||||
|
value = composerValue,
|
||||||
|
onValueChange = onComposerValueChange,
|
||||||
|
enabled = isComposing
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isComposing) {
|
||||||
|
Text(
|
||||||
|
text = responseValue,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageComposer(
|
fun MessageComposer(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
enabled: Boolean,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var value by remember { mutableStateOf("Hello") }
|
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() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(enabled) {
|
||||||
|
if (enabled) {
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
TextField(
|
TextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = {
|
onValueChange = onValueChange,
|
||||||
value = it
|
modifier = modifier
|
||||||
},
|
.fillMaxWidth()
|
||||||
modifier = Modifier.weight(1f).padding(horizontal = 10.dp).focusRequester(focusRequester),
|
.padding(horizontal = textFieldPadding)
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
// .animateContentSize()
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text("Type something here...")
|
Text("Type your message...")
|
||||||
},
|
},
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
unfocusedContainerColor = Color.Transparent,
|
unfocusedContainerColor = Color.Transparent,
|
||||||
focusedContainerColor = Color.Transparent,
|
focusedContainerColor = Color.Transparent,
|
||||||
|
disabledContainerColor = Color.Transparent,
|
||||||
unfocusedIndicatorColor = Color.Transparent,
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
focusedIndicatorColor = Color.Transparent,
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
disabledIndicatorColor = Color.Transparent,
|
||||||
|
disabledTextColor = textFieldTextColor
|
||||||
|
),
|
||||||
|
enabled = enabled
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
ComposerToolBar()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposerToolBar() {
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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