Move stuff around

This commit is contained in:
Minecon724 2025-06-22 17:12:07 +02:00
commit 95ea10b305
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
13 changed files with 250 additions and 190 deletions

View file

@ -5,20 +5,15 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.animateContentSize
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.Spacer
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.width
@ -26,17 +21,8 @@ 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
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.CenterAlignedTopAppBar
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.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
@ -57,21 +43,21 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import eu.m724.chatapp.R
import eu.m724.chatapp.activity.chat.ChatState.Companion.rememberChatState
import eu.m724.chatapp.activity.chat.composable.AnimatedChangingText
import eu.m724.chatapp.activity.chat.composable.ChatToolBar
import eu.m724.chatapp.activity.chat.state.ChatComposerState.Companion.rememberChatComposerState
import eu.m724.chatapp.activity.ui.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.chat.composable.thread.ChatMessageComposer
import eu.m724.chatapp.activity.chat.composable.thread.ChatResponseErrorNotice
import eu.m724.chatapp.activity.chat.state.ChatComposerState
import eu.m724.chatapp.activity.ui.composable.disableBringIntoViewOnFocus
import eu.m724.chatapp.activity.ui.composable.hideKeyboardOnScrollUp
import eu.m724.chatapp.activity.ui.theme.ChatAppTheme
import eu.m724.chatapp.api.data.response.completion.ChatMessage
import kotlinx.coroutines.launch
@ -91,10 +77,9 @@ class ChatActivity : ComponentActivity() {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val softwareKeyboardController = LocalSoftwareKeyboardController.current
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val chatState = rememberChatState()
val chatState = rememberChatComposerState()
val threadViewLazyListState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
@ -111,7 +96,7 @@ class ChatActivity : ComponentActivity() {
ChatScreen(
windowSizeClass = windowSizeClass,
uiState = uiState,
chatState = chatState,
chatComposerState = chatState,
threadViewLazyListState = threadViewLazyListState,
snackbarHostState = snackbarHostState,
onSend = onSend,
@ -181,7 +166,7 @@ class ChatActivity : ComponentActivity() {
fun ChatScreen(
windowSizeClass: WindowSizeClass,
uiState: ChatActivityUiState,
chatState: ChatState,
chatComposerState: ChatComposerState,
threadViewLazyListState: LazyListState,
snackbarHostState: SnackbarHostState,
onSend: () -> Unit,
@ -212,7 +197,7 @@ fun ChatScreen(
.padding(innerPadding),
isTablet = isTablet,
uiState = uiState,
chatState = chatState,
chatComposerState = chatComposerState,
threadViewLazyListState = threadViewLazyListState,
onSend = onSend,
onRequestFocus = onRequestFocus
@ -226,7 +211,7 @@ fun ChatScreenContent(
modifier: Modifier = Modifier,
isTablet: Boolean,
uiState: ChatActivityUiState,
chatState: ChatState,
chatComposerState: ChatComposerState,
threadViewLazyListState: LazyListState,
onSend: () -> Unit,
onRequestFocus: () -> Unit
@ -263,7 +248,7 @@ fun ChatScreenContent(
lazyListState = threadViewLazyListState,
messages = uiState.messages,
uiState = uiState,
chatState = chatState
chatComposerState = chatComposerState
)
},
{
@ -275,8 +260,8 @@ fun ChatScreenContent(
horizontalAlignment = Alignment.CenterHorizontally
) {
ChatToolBar(
canSend = (chatState.composerValue.isNotBlank() || uiState.lastResponseError != null) && !uiState.requestInProgress,
canRestart = chatState.composerValue.isBlank() && uiState.lastResponseError != null,
canSend = (chatComposerState.composerValue.isNotBlank() || uiState.lastResponseError != null) && !uiState.requestInProgress,
canRestart = chatComposerState.composerValue.isBlank() && uiState.lastResponseError != null,
onSend = onSend,
onEmptySpaceClick = onRequestFocus
)
@ -295,16 +280,14 @@ fun ThreadView(
lazyListState: LazyListState,
messages: List<ChatMessage>,
uiState: ChatActivityUiState,
chatState: ChatState,
chatComposerState: ChatComposerState,
modifier: Modifier = Modifier
) {
val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current
LazyColumn(
modifier = modifier
.nestedScroll( // Hides the keyboard when scrolling
NestedScrollKeyboardHider(localSoftwareKeyboardController)
),
.hideKeyboardOnScrollUp(localSoftwareKeyboardController!!),
state = lazyListState
) {
items(messages) { message ->
@ -323,7 +306,7 @@ fun ThreadView(
if (uiState.lastResponseError != null) {
item(key = "error") {
ResponseErrorDisplay(
ChatResponseErrorNotice(
error = uiState.lastResponseError
)
}
@ -335,10 +318,10 @@ fun ThreadView(
modifier = Modifier
.fillParentMaxHeight() // so that you can click anywhere on the screen to focus the text field
.disableBringIntoViewOnFocus()
.focusRequester(chatState.focusRequester),
value = chatState.composerValue,
.focusRequester(chatComposerState.focusRequester),
value = chatComposerState.composerValue,
onValueChange = {
chatState.composerValue = it
chatComposerState.composerValue = it
}
)
} else {
@ -381,36 +364,6 @@ fun ChatMessagePrompt(
)
}
@Composable
fun ResponseErrorDisplay(
error: ChatResponseError,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.height(18.dp)
.padding(horizontal = 4.dp),
imageVector = Icons.Default.Warning,
contentDescription = stringResource(R.string.response_error_icon_description),
tint = MaterialTheme.colorScheme.error
)
val errorMessage = when (error) {
is ChatResponseError.LengthLimit -> stringResource(R.string.response_error_length_limit)
is ChatResponseError.Error -> stringResource(R.string.response_error_generic)
}
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error
)
}
}
@Composable
fun ChatMessageResponse(
content: String,
@ -422,80 +375,7 @@ fun ChatMessageResponse(
)
}
@Composable
fun ChatMessageComposer(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
SimpleTextFieldWithPadding(
modifier = modifier,
value = value,
onValueChange = onValueChange,
placeholder = {
Text(
text = stringResource(R.string.composer_placeholder_type)
) // TODO hide when just browsing history?
},
padding = PaddingValues(vertical = 10.dp),
textStyle = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface
)
)
}
@Composable
fun ChatToolBar(
modifier: Modifier = Modifier,
canSend: Boolean,
canRestart: Boolean,
onSend: () -> Unit,
onEmptySpaceClick: () -> Unit
) {
val sendButtonColor by animateColorAsState(
targetValue = if (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 = onSend,
modifier = Modifier
.height(48.dp)
.padding(horizontal = 8.dp),
enabled = canSend,
colors = IconButtonDefaults.iconButtonColors(
contentColor = sendButtonColor,
disabledContentColor = sendButtonColor
)
) {
if (canRestart) {
Icon(
painter = painterResource(R.drawable.outline_restart_alt_24),
contentDescription = stringResource(R.string.button_send_restart_icon_description)
)
} else {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.button_send_icon_description)
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View file

@ -1,5 +1,6 @@
package eu.m724.chatapp.activity.chat
import eu.m724.chatapp.activity.chat.state.ChatResponseError
import eu.m724.chatapp.api.data.response.completion.ChatMessage
data class ChatActivityUiState(
@ -19,5 +20,4 @@ data class ChatActivityUiState(
* All messages in the chat
*/
val messages: List<ChatMessage> = emptyList()
)
)

View file

@ -3,6 +3,7 @@ package eu.m724.chatapp.activity.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.m724.chatapp.activity.chat.state.ChatResponseError
import eu.m724.chatapp.api.AiApiService
import eu.m724.chatapp.api.data.request.completion.ChatCompletionRequest
import eu.m724.chatapp.api.data.response.completion.ChatCompletionResponseEvent
@ -58,10 +59,12 @@ class ChatActivityViewModel @Inject constructor(
messages.removeLast()
} // If there was an error and no response was generated, this shouldn't be a follow-up
messages.add(ChatMessage(
role = ChatMessage.Role.User,
content = promptContent
))
messages.add(
ChatMessage(
role = ChatMessage.Role.User,
content = promptContent
)
)
_uiState.update {
it.copy(
@ -75,14 +78,16 @@ class ChatActivityViewModel @Inject constructor(
)
}
aiApiService.getChatCompletion(ChatCompletionRequest(
model = "fre-model",
messages = messages,
temperature = 1.0f,
maxTokens = 4,
frequencyPenalty = 0.0f,
presencePenalty = 0.0f
)).onEach { event ->
aiApiService.getChatCompletion(
ChatCompletionRequest(
model = "free-model",
messages = messages,
temperature = 1.0f,
maxTokens = 4,
frequencyPenalty = 0.0f,
presencePenalty = 0.0f
)
).onEach { event ->
when (event) {
is SseEvent.Open -> {
// There is nothing to do here
@ -110,10 +115,12 @@ class ChatActivityViewModel @Inject constructor(
is SseEvent.Closed -> {
// Closed is not used in case of an error
messages.add(ChatMessage(
role = ChatMessage.Role.Assistant,
content = responseContent
))
messages.add(
ChatMessage(
role = ChatMessage.Role.Assistant,
content = responseContent
)
)
}
is SseEvent.Failure -> {
// The below should do. More investigation is needed but I believe this should do

View file

@ -0,0 +1,76 @@
package eu.m724.chatapp.activity.chat.composable
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.m724.chatapp.R
@Composable
fun ChatToolBar(
modifier: Modifier = Modifier,
canSend: Boolean,
canRestart: Boolean,
onSend: () -> Unit,
onEmptySpaceClick: () -> Unit
) {
val sendButtonColor by animateColorAsState(
targetValue = if (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 = onSend,
modifier = Modifier
.height(48.dp)
.padding(horizontal = 8.dp),
enabled = canSend,
colors = IconButtonDefaults.iconButtonColors(
contentColor = sendButtonColor,
disabledContentColor = sendButtonColor
)
) {
if (canRestart) {
Icon(
painter = painterResource(R.drawable.outline_restart_alt_24),
contentDescription = stringResource(R.string.button_send_restart_icon_description)
)
} else {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.button_send_icon_description)
)
}
}
}
}
}

View file

@ -1,16 +0,0 @@
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
}
}

View file

@ -0,0 +1,34 @@
package eu.m724.chatapp.activity.chat.composable.thread
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.m724.chatapp.R
import eu.m724.chatapp.activity.ui.composable.LessBasicTextField
@Composable
fun ChatMessageComposer(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
LessBasicTextField(
modifier = modifier,
value = value,
onValueChange = onValueChange,
placeholder = {
Text(
text = stringResource(R.string.composer_placeholder_type)
) // TODO hide when just browsing history?
},
padding = PaddingValues(vertical = 10.dp),
textStyle = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onSurface
)
)
}

View file

@ -0,0 +1,47 @@
package eu.m724.chatapp.activity.chat.composable.thread
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.m724.chatapp.R
import eu.m724.chatapp.activity.chat.state.ChatResponseError
@Composable
fun ChatResponseErrorNotice(
error: ChatResponseError,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.height(18.dp)
.padding(horizontal = 4.dp),
imageVector = Icons.Default.Warning,
contentDescription = stringResource(R.string.response_error_icon_description),
tint = MaterialTheme.colorScheme.error
)
val errorMessage = when (error) {
is ChatResponseError.LengthLimit -> stringResource(R.string.response_error_length_limit)
is ChatResponseError.Error -> stringResource(R.string.response_error_generic)
}
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error
)
}
}

View file

@ -1,4 +1,4 @@
package eu.m724.chatapp.activity.chat
package eu.m724.chatapp.activity.chat.state
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -7,7 +7,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
class ChatState(
class ChatComposerState(
val focusRequester: FocusRequester
) {
var composerValue by mutableStateOf("")
@ -18,14 +18,12 @@ class ChatState(
companion object {
@Composable
fun rememberChatState(
fun rememberChatComposerState(
focusRequester: FocusRequester = remember { FocusRequester() }
): ChatState {
): ChatComposerState {
return remember {
ChatState(focusRequester = focusRequester)
ChatComposerState(focusRequester = focusRequester)
}
}
}
}
}

View file

@ -1,4 +1,4 @@
package eu.m724.chatapp.activity.chat
package eu.m724.chatapp.activity.chat.state
sealed interface ChatResponseError { // TODO does this belong here?
data object LengthLimit: ChatResponseError

View file

@ -1,4 +1,4 @@
package eu.m724.chatapp.activity.chat.composable
package eu.m724.chatapp.activity.ui.composable
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
@ -11,6 +11,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
/**
* A text that transitions nicely when changed.
* Not appropriate for completing text.
*/
@Composable
fun AnimatedChangingText(
text: String,

View file

@ -1,4 +1,4 @@
package eu.m724.chatapp.activity.chat.composable
package eu.m724.chatapp.activity.ui.composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
@ -40,5 +40,7 @@ private class DisableBringIntoViewOnFocusElement : ModifierNodeElement<DisableBr
}
// This is the public Modifier function you will use in your code.
/**
* Disables bringing this item (scrolling to accommodate it, for instance, in a column) into view when it is focused.
*/
fun Modifier.disableBringIntoViewOnFocus(): Modifier = this.then(DisableBringIntoViewOnFocusElement())

View file

@ -1,4 +1,4 @@
package eu.m724.chatapp.activity.chat.composable
package eu.m724.chatapp.activity.ui.composable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
@ -14,9 +14,12 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation
/**
* A text field that's not as basic as a BasicTextField, but not as bloated as the standard TextField.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SimpleTextFieldWithPadding(
fun LessBasicTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: @Composable () -> Unit,

View file

@ -0,0 +1,25 @@
package eu.m724.chatapp.activity.ui.composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
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) // scroll up (content up, finger down)
softwareKeyboardController.hide()
return Offset.Companion.Zero
}
}
/**
* Hides the keyboard when scrolling up in a column.
*/
fun Modifier.hideKeyboardOnScrollUp(
softwareKeyboardController: SoftwareKeyboardController
): Modifier = this.nestedScroll(NestedScrollKeyboardHider(softwareKeyboardController))