diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt index 55c3787..a9e02ea 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt @@ -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 diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt index c658b4c..9921b6b 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt @@ -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 = listOf() +) + +data class ChatMessageExchange( + val prompt: String, + var response: String ) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt index 9604d56..16f0694 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt @@ -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 = _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) + ) + ) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/compose/AnimatedChangingText.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/compose/AnimatedChangingText.kt new file mode 100644 index 0000000..41d421b --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/compose/AnimatedChangingText.kt @@ -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 + ) + } + } +} \ No newline at end of file