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