Further improve the chat page
This commit is contained in:
parent
aca314d218
commit
a2597878d1
15 changed files with 550 additions and 248 deletions
|
|
@ -57,6 +57,7 @@ dependencies {
|
|||
implementation(libs.hilt.android)
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.converter.gson)
|
||||
implementation(libs.androidx.material3.window.size.class1)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@
|
|||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ChatApp" >
|
||||
android:theme="@style/Theme.ChatApp"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
<activity
|
||||
android:name=".activity.chat.ChatActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/title_activity_chat"
|
||||
android:theme="@style/Theme.ChatApp" >
|
||||
android:theme="@style/Theme.ChatApp"
|
||||
android:windowSoftInputMode="adjustNothing">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@ import androidx.activity.enableEdgeToEdge
|
|||
import androidx.activity.viewModels
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -20,6 +22,7 @@ import androidx.compose.foundation.layout.imePadding
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
|
@ -31,132 +34,362 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.m724.chatapp.activity.chat.compose.AnimatedChangingText
|
||||
import eu.m724.chatapp.activity.chat.compose.LanguageModelMistakeWarning
|
||||
import eu.m724.chatapp.activity.chat.ChatState.Companion.rememberChatState
|
||||
import eu.m724.chatapp.activity.chat.composable.AnimatedChangingText
|
||||
import eu.m724.chatapp.activity.chat.composable.LanguageModelMistakeWarning
|
||||
import eu.m724.chatapp.activity.chat.composable.NestedScrollKeyboardHider
|
||||
import eu.m724.chatapp.activity.chat.composable.SimpleTextFieldWithPadding
|
||||
import eu.m724.chatapp.activity.chat.composable.disableBringIntoViewOnFocus
|
||||
import eu.m724.chatapp.activity.ui.theme.ChatAppTheme
|
||||
import eu.m724.chatapp.api.data.ChatMessage
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChatActivity : ComponentActivity() {
|
||||
private val viewModel: ChatActivityViewModel by viewModels()
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
Content(viewModel)
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val softwareKeyboardController = LocalSoftwareKeyboardController.current
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val chatState = rememberChatState(
|
||||
requestInProgress = uiState.requestInProgress,
|
||||
onSend = { message ->
|
||||
viewModel.sendMessage(message)
|
||||
}
|
||||
)
|
||||
|
||||
val threadViewLazyListState = rememberLazyListState()
|
||||
|
||||
ChatScreen(
|
||||
windowSizeClass = windowSizeClass,
|
||||
uiState = uiState,
|
||||
chatState = chatState,
|
||||
threadViewLazyListState = threadViewLazyListState,
|
||||
onRequestFocus = {
|
||||
coroutineScope.launch {
|
||||
if (threadViewLazyListState.layoutInfo.visibleItemsInfo.find { it.key == "composer" } == null) {
|
||||
if (uiState.messages.isNotEmpty()) {
|
||||
threadViewLazyListState.animateScrollToItem(uiState.messages.size)
|
||||
}
|
||||
}
|
||||
|
||||
chatState.requestFocus()
|
||||
softwareKeyboardController?.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.uiEvents.collect { event ->
|
||||
when (event) {
|
||||
is ChatActivityUiEvent.ProcessingRequest -> {
|
||||
threadViewLazyListState.animateScrollToItem(uiState.messages.size)
|
||||
}
|
||||
is ChatActivityUiEvent.SuccessfulResponse -> {
|
||||
chatState.composerValue = ""
|
||||
|
||||
if (uiState.messages.isNotEmpty()) {
|
||||
threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2)
|
||||
}
|
||||
|
||||
threadViewLazyListState.layoutInfo.visibleItemsInfo.firstOrNull {
|
||||
it.key == "composer"
|
||||
}?.let {
|
||||
chatState.requestFocus()
|
||||
softwareKeyboardController?.show() // TODO perhaps it's pointless to focus since we can click on the toolbar?
|
||||
}
|
||||
}
|
||||
is ChatActivityUiEvent.Error -> {
|
||||
Toast.makeText(context, event.error, Toast.LENGTH_SHORT)
|
||||
.show() // TODO better way of showing this. snackbar?
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Content(
|
||||
viewModel: ChatActivityViewModel // = viewModel() doesn't work
|
||||
fun ChatScreen(
|
||||
windowSizeClass: WindowSizeClass,
|
||||
uiState: ChatActivityUiState,
|
||||
chatState: ChatState,
|
||||
threadViewLazyListState: LazyListState,
|
||||
onRequestFocus: () -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current
|
||||
val context = LocalContext.current
|
||||
|
||||
var composerValue by remember { mutableStateOf("") }
|
||||
val composerFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(uiState.requestInProgress) { // TODO probably not the best way
|
||||
if (!uiState.requestInProgress) {
|
||||
if (uiState.requestLastError == null) {
|
||||
composerValue = ""
|
||||
composerFocusRequester.requestFocus() // TODO maybe make toggleable? or smart?
|
||||
} else {
|
||||
Toast.makeText(context, uiState.requestLastError, Toast.LENGTH_SHORT).show() // TODO better way of showing this
|
||||
}
|
||||
} else {
|
||||
if (!uiState.messageHistory.isEmpty()) {
|
||||
lazyListState.animateScrollToItem(uiState.messageHistory.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
val isTablet = windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact
|
||||
|
||||
ChatAppTheme {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize().imePadding(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
ChatTopAppBar(uiState.chatTitle ?: "Start a new conversation")
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
ChatScreenContent(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
isTablet = isTablet,
|
||||
messages = uiState.messages,
|
||||
liveResponse = uiState.liveResponse,
|
||||
chatState = chatState,
|
||||
threadViewLazyListState = threadViewLazyListState,
|
||||
onRequestFocus = onRequestFocus
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatScreenContent(
|
||||
modifier: Modifier = Modifier,
|
||||
isTablet: Boolean,
|
||||
messages: List<ChatMessage>,
|
||||
liveResponse: String,
|
||||
chatState: ChatState,
|
||||
threadViewLazyListState: LazyListState,
|
||||
onRequestFocus: () -> Unit
|
||||
) {
|
||||
val layout: @Composable (@Composable () -> Unit, @Composable () -> Unit) -> Unit =
|
||||
if (isTablet) {
|
||||
{ thread, composer ->
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) { thread() }
|
||||
Box(modifier = Modifier.width(500.dp)) { composer() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
{ thread, composer ->
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) { thread() }
|
||||
composer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layout(
|
||||
{
|
||||
ThreadView(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp),
|
||||
lazyListState = threadViewLazyListState,
|
||||
messages = messages,
|
||||
liveResponse = liveResponse,
|
||||
chatState = chatState
|
||||
)
|
||||
},
|
||||
{
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.imePadding()
|
||||
.padding(horizontal = 10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
ChatToolBar(
|
||||
chatState = chatState,
|
||||
onEmptySpaceClick = onRequestFocus
|
||||
)
|
||||
|
||||
item {
|
||||
MessageExchange(
|
||||
modifier = Modifier.padding(5.dp).fillParentMaxHeight(),
|
||||
isComposing = !uiState.requestInProgress,
|
||||
composerValue = composerValue,
|
||||
onComposerValueChange = { composerValue = it },
|
||||
composerFocusRequester = composerFocusRequester,
|
||||
responseValue = uiState.currentMessageResponse // TODO animate this
|
||||
)
|
||||
}
|
||||
}
|
||||
LanguageModelMistakeWarning(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 10.dp) // TODO this is troublesome if there's navigation bar below or any kind of padding
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ComposerToolBar(
|
||||
modifier = Modifier.width(500.dp).padding(horizontal = 10.dp),
|
||||
canSend = composerValue.isNotBlank() && !uiState.requestInProgress,
|
||||
onSend = {
|
||||
viewModel.sendMessage(composerValue)
|
||||
},
|
||||
onEmptySpaceClick = {
|
||||
composerFocusRequester.requestFocus()
|
||||
localSoftwareKeyboardController?.show()
|
||||
}
|
||||
)
|
||||
@Composable
|
||||
fun ThreadView(
|
||||
lazyListState: LazyListState,
|
||||
messages: List<ChatMessage>,
|
||||
liveResponse: String,
|
||||
chatState: ChatState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LanguageModelMistakeWarning(
|
||||
modifier = Modifier.padding(vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
val composerValue = if (chatState.requestInProgress) {
|
||||
chatState.lastPrompt
|
||||
} else {
|
||||
chatState.composerValue
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.nestedScroll( // Hides the keyboard when scrolling
|
||||
NestedScrollKeyboardHider(localSoftwareKeyboardController)
|
||||
),
|
||||
state = lazyListState
|
||||
) {
|
||||
items(messages) { message ->
|
||||
if (message.role == ChatMessage.Role.User) {
|
||||
ChatMessagePrompt(
|
||||
content = message.content,
|
||||
modifier = Modifier.padding(vertical = 10.dp)
|
||||
)
|
||||
} else if (message.role == ChatMessage.Role.Assistant) {
|
||||
ChatMessageResponse(
|
||||
content = message.content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "composer") {
|
||||
ChatMessageComposer(
|
||||
modifier = Modifier
|
||||
.fillParentMaxHeight() // so that you can click anywhere on the screen to focus the text field
|
||||
.disableBringIntoViewOnFocus()
|
||||
.focusRequester(chatState.focusRequester),
|
||||
value = composerValue,
|
||||
onValueChange = {
|
||||
chatState.composerValue = it
|
||||
},
|
||||
submitted = chatState.requestInProgress
|
||||
)
|
||||
}
|
||||
|
||||
if (chatState.requestInProgress) {
|
||||
item {
|
||||
ChatMessageResponse(
|
||||
content = liveResponse
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMessagePrompt(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
modifier = modifier
|
||||
.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMessageResponse(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = content,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMessageComposer(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
submitted: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val textPadding by animateDpAsState(
|
||||
targetValue = if (submitted) 16.dp else 0.dp,
|
||||
label = "composerTextPaddingAnimation"
|
||||
)
|
||||
|
||||
val textOpacity by animateFloatAsState(
|
||||
targetValue = if (submitted) 0.7f else 1.0f,
|
||||
label = "composerTextOpacityAnimation"
|
||||
)
|
||||
|
||||
SimpleTextFieldWithPadding(
|
||||
modifier = modifier,
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
enabled = !submitted,
|
||||
placeholder = {
|
||||
Text("Type your message...") // TODO hide when just browsing history?
|
||||
},
|
||||
padding = PaddingValues(vertical = 10.dp, horizontal = textPadding),
|
||||
textStyle = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = textOpacity)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatToolBar(
|
||||
modifier: Modifier = Modifier,
|
||||
chatState: ChatState,
|
||||
onEmptySpaceClick: () -> Unit
|
||||
) {
|
||||
val sendButtonColor by animateColorAsState(
|
||||
targetValue = if (chatState.canSend) {
|
||||
IconButtonDefaults.iconButtonColors().contentColor
|
||||
} else {
|
||||
IconButtonDefaults.iconButtonColors().disabledContentColor
|
||||
}, label = "sendButtonColor"
|
||||
)
|
||||
|
||||
ElevatedCard(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onEmptySpaceClick),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(
|
||||
onClick = chatState::performSend,
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
enabled = chatState.canSend,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
contentColor = sendButtonColor,
|
||||
disabledContentColor = sendButtonColor
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -175,136 +408,8 @@ fun ChatTopAppBar(
|
|||
) {
|
||||
AnimatedChangingText(
|
||||
text = title,
|
||||
) // TODO fade
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageExchange(
|
||||
isComposing: Boolean,
|
||||
composerValue: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onComposerValueChange: (String) -> Unit = {},
|
||||
composerFocusRequester: FocusRequester = FocusRequester(),
|
||||
responseValue: String = "",
|
||||
) {
|
||||
|
||||
Column(
|
||||
modifier = modifier.padding(horizontal = 10.dp)
|
||||
) {
|
||||
MessageComposer(
|
||||
value = composerValue,
|
||||
onValueChange = onComposerValueChange,
|
||||
enabled = isComposing,
|
||||
focusRequester = composerFocusRequester
|
||||
)
|
||||
|
||||
if (!isComposing) {
|
||||
Text(
|
||||
text = responseValue,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageComposer(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
enabled: Boolean,
|
||||
focusRequester: FocusRequester,
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
onEmptySpaceClick: () -> Unit
|
||||
) {
|
||||
val sendButtonColor by animateColorAsState(
|
||||
targetValue = if (canSend) {
|
||||
IconButtonDefaults.iconButtonColors().contentColor
|
||||
} else {
|
||||
IconButtonDefaults.iconButtonColors().disabledContentColor
|
||||
}
|
||||
)
|
||||
|
||||
ElevatedCard(
|
||||
modifier = modifier.clickable(
|
||||
onClick = onEmptySpaceClick
|
||||
),
|
||||
shape = RoundedCornerShape(100)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
onSend()
|
||||
},
|
||||
modifier = Modifier.height(48.dp).padding(horizontal = 8.dp),
|
||||
enabled = canSend,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
contentColor = sendButtonColor,
|
||||
disabledContentColor = sendButtonColor
|
||||
)
|
||||
) {
|
||||
Column (
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package eu.m724.chatapp.activity.chat
|
||||
|
||||
sealed interface ChatActivityUiEvent {
|
||||
data object ProcessingRequest : ChatActivityUiEvent
|
||||
data class SuccessfulResponse(val message: String): ChatActivityUiEvent
|
||||
data class Error(val error: String): ChatActivityUiEvent
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
package eu.m724.chatapp.activity.chat
|
||||
|
||||
import eu.m724.chatapp.api.data.ChatMessage
|
||||
|
||||
data class ChatActivityUiState(
|
||||
/**
|
||||
* The title of the current chat
|
||||
|
|
@ -14,20 +16,10 @@ data class ChatActivityUiState(
|
|||
/**
|
||||
* The response right now, updates when streaming
|
||||
*/
|
||||
val currentMessageResponse: String = "",
|
||||
val liveResponse: String = "",
|
||||
|
||||
/**
|
||||
* All the messages of this chat
|
||||
*/
|
||||
val messageHistory: List<ChatMessageExchange> = listOf(),
|
||||
|
||||
/**
|
||||
* Error, if any, of the last request
|
||||
*/
|
||||
val requestLastError: String? = null
|
||||
)
|
||||
|
||||
data class ChatMessageExchange(
|
||||
val prompt: String,
|
||||
var response: String
|
||||
val messages: List<ChatMessage> = listOf()
|
||||
)
|
||||
|
|
@ -6,9 +6,11 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import eu.m724.chatapp.api.AiApiService
|
||||
import eu.m724.chatapp.api.data.ChatMessage
|
||||
import eu.m724.chatapp.api.data.request.ChatCompletionRequest
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
|
@ -17,32 +19,36 @@ import javax.inject.Inject
|
|||
class ChatActivityViewModel @Inject constructor(
|
||||
private val aiApiService: AiApiService
|
||||
) : ViewModel() {
|
||||
private val messages = mutableListOf<ChatMessage>()
|
||||
|
||||
private val _uiState = MutableStateFlow(ChatActivityUiState())
|
||||
val uiState: StateFlow<ChatActivityUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val messages = mutableListOf<ChatMessage>()
|
||||
private val _uiEvents = Channel<ChatActivityUiEvent>()
|
||||
val uiEvents = _uiEvents.receiveAsFlow()
|
||||
|
||||
fun sendMessage(message: String) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
var uiState = it.copy(
|
||||
requestInProgress = true,
|
||||
currentMessageResponse = ""
|
||||
liveResponse = "",
|
||||
)
|
||||
}
|
||||
|
||||
if (_uiState.value.chatTitle == null) {
|
||||
_uiState.update {
|
||||
it.copy(chatTitle = "User says \"$message\"") // TODO
|
||||
if (it.chatTitle == null) {
|
||||
uiState = uiState.copy(chatTitle = message)
|
||||
}
|
||||
|
||||
uiState
|
||||
}
|
||||
|
||||
messages.add(ChatMessage(
|
||||
role = ChatMessage.Role.USER,
|
||||
role = ChatMessage.Role.User,
|
||||
content = message
|
||||
))
|
||||
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiEvents.send(ChatActivityUiEvent.ProcessingRequest)
|
||||
|
||||
val response = aiApiService.chatComplete(ChatCompletionRequest(
|
||||
model = "free-model",
|
||||
messages = messages,
|
||||
|
|
@ -53,13 +59,16 @@ class ChatActivityViewModel @Inject constructor(
|
|||
))
|
||||
|
||||
if (!response.isSuccessful || response.body() == null) {
|
||||
messages.removeLast()
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
requestInProgress = false,
|
||||
requestLastError = response.code().toString()
|
||||
requestInProgress = false
|
||||
)
|
||||
}
|
||||
|
||||
_uiEvents.send(ChatActivityUiEvent.Error(response.code().toString()))
|
||||
|
||||
// TODO launch toast or something
|
||||
return@launch
|
||||
}
|
||||
|
|
@ -72,11 +81,11 @@ class ChatActivityViewModel @Inject constructor(
|
|||
_uiState.update {
|
||||
it.copy(
|
||||
requestInProgress = false,
|
||||
messageHistory = it.messageHistory.plus(
|
||||
ChatMessageExchange(message, choice.message.content)
|
||||
)
|
||||
messages = messages.toList()
|
||||
)
|
||||
}
|
||||
|
||||
_uiEvents.send(ChatActivityUiEvent.SuccessfulResponse(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/src/main/java/eu/m724/chatapp/activity/chat/ChatState.kt
Normal file
59
app/src/main/java/eu/m724/chatapp/activity/chat/ChatState.kt
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package eu.m724.chatapp.activity.chat
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
||||
class ChatState(
|
||||
val focusRequester: FocusRequester,
|
||||
private val onSend: (String) -> Unit, // Store the lambda
|
||||
initialRequestInProgress: Boolean
|
||||
) {
|
||||
var composerValue by mutableStateOf("")
|
||||
var lastPrompt by mutableStateOf("")
|
||||
var requestInProgress by mutableStateOf(initialRequestInProgress)
|
||||
|
||||
val canSend: Boolean
|
||||
get() = composerValue.isNotBlank() && !requestInProgress
|
||||
|
||||
// This method will be called by the UI (e.g., the send button)
|
||||
fun performSend() {
|
||||
if (canSend) {
|
||||
lastPrompt = composerValue
|
||||
onSend(composerValue)
|
||||
composerValue = ""
|
||||
}
|
||||
}
|
||||
|
||||
fun requestFocus() {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@Composable
|
||||
fun rememberChatState(
|
||||
requestInProgress: Boolean,
|
||||
onSend: (String) -> Unit, // Takes the message string as a parameter
|
||||
focusRequester: FocusRequester = remember { FocusRequester() }
|
||||
): ChatState {
|
||||
val state = remember {
|
||||
ChatState(
|
||||
focusRequester = focusRequester,
|
||||
onSend = onSend, // Pass the lambda directly
|
||||
initialRequestInProgress = requestInProgress
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(requestInProgress) {
|
||||
state.requestInProgress = requestInProgress
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package eu.m724.chatapp.activity.chat.compose
|
||||
package eu.m724.chatapp.activity.chat.composable
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.tween
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package eu.m724.chatapp.activity.chat.composable
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.node.ModifierNodeElement
|
||||
import androidx.compose.ui.platform.InspectorInfo
|
||||
import androidx.compose.ui.relocation.BringIntoViewModifierNode
|
||||
|
||||
private class DisableBringIntoViewOnFocusNode : Modifier.Node(), BringIntoViewModifierNode {
|
||||
// When a child of this modifier requests to be brought into view, this method is called.
|
||||
// By providing an empty implementation, we effectively "swallow" the request
|
||||
// and prevent it from propagating up to the LazyColumn, which would otherwise scroll.
|
||||
override suspend fun bringIntoView(
|
||||
childCoordinates: LayoutCoordinates,
|
||||
boundsProvider: () -> Rect?
|
||||
) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private class DisableBringIntoViewOnFocusElement : ModifierNodeElement<DisableBringIntoViewOnFocusNode>() {
|
||||
override fun create() = DisableBringIntoViewOnFocusNode()
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return other === this
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return "disableBringIntoViewOnFocus".hashCode()
|
||||
}
|
||||
|
||||
override fun update(node: DisableBringIntoViewOnFocusNode) {
|
||||
|
||||
}
|
||||
|
||||
override fun InspectorInfo.inspectableProperties() {
|
||||
name = "disableBringIntoViewOnFocus"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// This is the public Modifier function you will use in your code.
|
||||
fun Modifier.disableBringIntoViewOnFocus(): Modifier = this.then(DisableBringIntoViewOnFocusElement())
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package eu.m724.chatapp.activity.chat.compose
|
||||
package eu.m724.chatapp.activity.chat.composable
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package eu.m724.chatapp.activity.chat.composable
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.platform.SoftwareKeyboardController
|
||||
|
||||
class NestedScrollKeyboardHider(
|
||||
private val softwareKeyboardController: SoftwareKeyboardController?
|
||||
) : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
if (available.y > 0) // if scrolling up (content up, finger down)
|
||||
softwareKeyboardController?.hide()
|
||||
return Offset.Companion.Zero
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package eu.m724.chatapp.activity.chat.composable
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SimpleTextFieldWithPadding(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
enabled: Boolean,
|
||||
placeholder: @Composable () -> Unit,
|
||||
padding: PaddingValues,
|
||||
textStyle: TextStyle,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
textStyle = textStyle,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface)
|
||||
) { innerTextField ->
|
||||
TextFieldDefaults.DecorationBox(
|
||||
value = value,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
innerTextField = innerTextField,
|
||||
singleLine = false,
|
||||
enabled = true, // to make things easier
|
||||
interactionSource = interactionSource,
|
||||
contentPadding = padding,
|
||||
placeholder = placeholder,
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,19 @@
|
|||
package eu.m724.chatapp.api.data
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class ChatMessage(
|
||||
val role: Role,
|
||||
val content: String
|
||||
) {
|
||||
enum class Role {
|
||||
SYSTEM,
|
||||
USER,
|
||||
ASSISTANT
|
||||
@SerializedName("system")
|
||||
System,
|
||||
|
||||
@SerializedName("user")
|
||||
User,
|
||||
|
||||
@SerializedName("assistant")
|
||||
Assistant
|
||||
}
|
||||
}
|
||||
|
|
@ -49,17 +49,20 @@ enum class CompletionFinishReason {
|
|||
/**
|
||||
* The response has stopped, because the model said so
|
||||
*/
|
||||
STOP,
|
||||
@SerializedName("stop")
|
||||
Stop,
|
||||
|
||||
/**
|
||||
* The response has stopped, because it got too long
|
||||
*/
|
||||
LENGTH,
|
||||
@SerializedName("length")
|
||||
Length,
|
||||
|
||||
/**
|
||||
* The response has stopped, because the content got flagged
|
||||
*/
|
||||
CONTENT_FILTER
|
||||
@SerializedName("content_filter")
|
||||
ContentFilter
|
||||
}
|
||||
|
||||
data class CompletionTokenUsage(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ ksp = "2.1.21-2.0.2"
|
|||
retrofit = "3.0.0"
|
||||
secrets = "2.0.1"
|
||||
loggingInterceptor = "4.12.0"
|
||||
material3WindowSizeClass = "1.3.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
|
|
@ -38,6 +39,7 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r
|
|||
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit"}
|
||||
logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" }
|
||||
androidx-material3-window-size-class1 = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "material3WindowSizeClass" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue