Basic settings
This commit is contained in:
parent
5ab88d9063
commit
c52638b5e0
28 changed files with 893 additions and 516 deletions
|
@ -5,6 +5,7 @@ plugins {
|
|||
alias(libs.plugins.hilt.android)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.secrets)
|
||||
alias(libs.plugins.parcelize)
|
||||
}
|
||||
|
||||
android {
|
||||
|
@ -66,6 +67,7 @@ dependencies {
|
|||
implementation(libs.retrofit.converter.gson)
|
||||
implementation(libs.androidx.material3.window.size.class1)
|
||||
implementation(libs.okhttp.sse)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools" >
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
|
@ -8,18 +8,18 @@
|
|||
android:name=".ChatApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.ChatApp"
|
||||
android:enableOnBackInvokedCallback="true">
|
||||
android:theme="@style/Theme.ChatApp">
|
||||
<activity
|
||||
android:name=".activity.chat.ChatActivity"
|
||||
android:name=".activity.main.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.ChatApp"
|
||||
android:windowSoftInputMode="adjustNothing">
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/Theme.ChatApp">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
|
@ -27,9 +27,11 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".activity.select.SelectModelActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.ChatApp" />
|
||||
android:name=".activity.chat.ChatActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.ChatApp"
|
||||
android:windowSoftInputMode="adjustNothing">
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -47,6 +47,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.m724.chatapp.R
|
||||
import eu.m724.chatapp.activity.chat.composable.ChatToolBar
|
||||
|
@ -54,12 +55,15 @@ import eu.m724.chatapp.activity.ui.composable.AnimatedChangingText
|
|||
import eu.m724.chatapp.activity.chat.composable.LanguageModelMistakeWarning
|
||||
import eu.m724.chatapp.activity.chat.composable.thread.ChatMessageComposer
|
||||
import eu.m724.chatapp.activity.chat.composable.thread.ChatResponseErrorNotice
|
||||
import eu.m724.chatapp.activity.chat.quick_settings.ChatQuickSettingsEvent
|
||||
import eu.m724.chatapp.activity.chat.state.ChatComposerState
|
||||
import eu.m724.chatapp.activity.chat.state.rememberChatComposerState
|
||||
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 eu.m724.chatapp.api.data.response.models.LanguageModel
|
||||
import eu.m724.chatapp.chat.Chat
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
|
@ -93,6 +97,8 @@ class ChatActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
var savedFocus by remember { mutableStateOf(false) }
|
||||
|
||||
ChatScreen(
|
||||
windowSizeClass = windowSizeClass,
|
||||
uiState = uiState,
|
||||
|
@ -105,8 +111,8 @@ class ChatActivity : ComponentActivity() {
|
|||
|
||||
coroutineScope.launch {
|
||||
if (threadViewLazyListState.layoutInfo.visibleItemsInfo.find { it.key == "composer" } == null) {
|
||||
if (uiState.messages.isNotEmpty()) {
|
||||
threadViewLazyListState.animateScrollToItem(uiState.messages.size)
|
||||
if (uiState.chat.messages.isNotEmpty()) {
|
||||
threadViewLazyListState.animateScrollToItem(uiState.chat.messages.size)
|
||||
// TODO this makes the composer full screen but if the condition above is false it doesn't, that may be kind of unintuitive
|
||||
}
|
||||
}
|
||||
|
@ -114,6 +120,16 @@ class ChatActivity : ComponentActivity() {
|
|||
chatState.requestFocus()
|
||||
softwareKeyboardController?.show()
|
||||
}
|
||||
},
|
||||
onSettingsEvent = { event ->
|
||||
when (event) {
|
||||
is ChatQuickSettingsEvent.Visibility -> {
|
||||
// TODO focus / unfocus
|
||||
}
|
||||
is ChatQuickSettingsEvent.ModelSelected -> {
|
||||
viewModel.selectModel(event.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -122,11 +138,11 @@ class ChatActivity : ComponentActivity() {
|
|||
chatState.composerValue = ""
|
||||
|
||||
// scroll to the last user message
|
||||
threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2)
|
||||
threadViewLazyListState.animateScrollToItem(uiState.chat.messages.size - 2)
|
||||
} else {
|
||||
if (uiState.messages.size > 1) {
|
||||
if (uiState.chat.messages.size > 1) {
|
||||
// scroll to the last user message too
|
||||
threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2)
|
||||
threadViewLazyListState.animateScrollToItem(uiState.chat.messages.size - 2)
|
||||
}
|
||||
|
||||
if (uiState.lastResponseError == null) {
|
||||
|
@ -170,7 +186,8 @@ fun ChatScreen(
|
|||
threadViewLazyListState: LazyListState,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onSend: () -> Unit,
|
||||
onRequestFocus: () -> Unit
|
||||
onRequestFocus: () -> Unit,
|
||||
onSettingsEvent: (ChatQuickSettingsEvent) -> Unit
|
||||
) {
|
||||
val isTablet = windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact
|
||||
|
||||
|
@ -179,7 +196,7 @@ fun ChatScreen(
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
ChatTopAppBar(
|
||||
title = uiState.chatTitle ?: stringResource(R.string.title_new_conversation)
|
||||
title = uiState.chat.title ?: stringResource(R.string.title_new_conversation)
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
|
@ -200,7 +217,8 @@ fun ChatScreen(
|
|||
chatComposerState = chatComposerState,
|
||||
threadViewLazyListState = threadViewLazyListState,
|
||||
onSend = onSend,
|
||||
onRequestFocus = onRequestFocus
|
||||
onRequestFocus = onRequestFocus,
|
||||
onSettingsEvent = onSettingsEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -214,7 +232,8 @@ fun ChatScreenContent(
|
|||
chatComposerState: ChatComposerState,
|
||||
threadViewLazyListState: LazyListState,
|
||||
onSend: () -> Unit,
|
||||
onRequestFocus: () -> Unit
|
||||
onRequestFocus: () -> Unit,
|
||||
onSettingsEvent: (ChatQuickSettingsEvent) -> Unit
|
||||
) {
|
||||
val layout: @Composable (@Composable () -> Unit, @Composable () -> Unit) -> Unit =
|
||||
if (isTablet) {
|
||||
|
@ -246,7 +265,7 @@ fun ChatScreenContent(
|
|||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
lazyListState = threadViewLazyListState,
|
||||
messages = uiState.messages,
|
||||
messages = uiState.chat.messages,
|
||||
uiState = uiState,
|
||||
chatComposerState = chatComposerState
|
||||
)
|
||||
|
@ -263,7 +282,8 @@ fun ChatScreenContent(
|
|||
canSend = (chatComposerState.composerValue.isNotBlank() || uiState.lastResponseError != null) && !uiState.requestInProgress,
|
||||
willRestart = chatComposerState.composerValue.isBlank() && uiState.lastResponseError != null,
|
||||
onSend = onSend,
|
||||
onEmptySpaceClick = onRequestFocus
|
||||
onEmptySpaceClick = onRequestFocus,
|
||||
onSettingsEvent = onSettingsEvent,
|
||||
)
|
||||
|
||||
LanguageModelMistakeWarning(
|
||||
|
|
|
@ -1,23 +1,15 @@
|
|||
package eu.m724.chatapp.activity.chat
|
||||
|
||||
import eu.m724.chatapp.activity.chat.state.ChatResponseError
|
||||
import eu.m724.chatapp.api.data.response.completion.ChatMessage
|
||||
import eu.m724.chatapp.chat.Chat
|
||||
import eu.m724.chatapp.chat.ChatResponseError
|
||||
|
||||
data class ChatActivityUiState(
|
||||
/**
|
||||
* The title of the current chat
|
||||
*/
|
||||
val chatTitle: String? = null,
|
||||
val chat: Chat,
|
||||
|
||||
/**
|
||||
* Whether a request is in progress (a response is streaming)
|
||||
*/
|
||||
val requestInProgress: Boolean = false,
|
||||
|
||||
val lastResponseError: ChatResponseError? = null,
|
||||
|
||||
/**
|
||||
* All messages in the chat
|
||||
*/
|
||||
val messages: List<ChatMessage> = emptyList()
|
||||
val lastResponseError: ChatResponseError? = null
|
||||
)
|
|
@ -1,15 +1,18 @@
|
|||
package eu.m724.chatapp.activity.chat
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
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
|
||||
import eu.m724.chatapp.api.data.response.completion.ChatMessage
|
||||
import eu.m724.chatapp.api.data.response.completion.CompletionFinishReason
|
||||
import eu.m724.chatapp.api.data.response.models.LanguageModel
|
||||
import eu.m724.chatapp.api.retrofit.sse.SseEvent
|
||||
import eu.m724.chatapp.chat.Chat
|
||||
import eu.m724.chatapp.chat.ChatResponseError
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
@ -24,9 +27,13 @@ import javax.inject.Inject
|
|||
|
||||
@HiltViewModel
|
||||
class ChatActivityViewModel @Inject constructor(
|
||||
private val aiApiService: AiApiService
|
||||
private val aiApiService: AiApiService,
|
||||
savedStateHandle: SavedStateHandle
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ChatActivityUiState())
|
||||
private val _uiState = MutableStateFlow(ChatActivityUiState(
|
||||
chat = savedStateHandle.get<Chat>("chat") ?: throw IllegalStateException("Chat not provided")
|
||||
))
|
||||
|
||||
val uiState: StateFlow<ChatActivityUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _uiEvents = Channel<ChatActivityUiEvent>()
|
||||
|
@ -42,7 +49,7 @@ class ChatActivityViewModel @Inject constructor(
|
|||
|
||||
if (lastUserMessage.role == ChatMessage.Role.Assistant) {
|
||||
// If we just removed an Assistant message, we must also remove the respective User message
|
||||
lastUserMessage = messages.removeLast()
|
||||
messages.removeLast()
|
||||
}
|
||||
|
||||
sendMessage(lastUserMessage.content)
|
||||
|
@ -69,21 +76,23 @@ class ChatActivityViewModel @Inject constructor(
|
|||
_uiState.update {
|
||||
it.copy(
|
||||
requestInProgress = true,
|
||||
messages = messages + ChatMessage(
|
||||
role = ChatMessage.Role.Assistant,
|
||||
content = responseContent
|
||||
),
|
||||
chatTitle = it.chatTitle ?: promptContent,
|
||||
lastResponseError = null
|
||||
lastResponseError = null,
|
||||
chat = it.chat.copy(
|
||||
title = it.chat.title ?: promptContent,
|
||||
messages = messages + ChatMessage(
|
||||
role = ChatMessage.Role.Assistant,
|
||||
content = responseContent
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
aiApiService.getChatCompletion(
|
||||
ChatCompletionRequest(
|
||||
model = "free-model",
|
||||
model = _uiState.value.chat.model.id,
|
||||
messages = messages,
|
||||
temperature = 1.0f,
|
||||
maxTokens = 4,
|
||||
maxTokens = 128,
|
||||
frequencyPenalty = 0.0f,
|
||||
presencePenalty = 0.0f
|
||||
)
|
||||
|
@ -99,9 +108,11 @@ class ChatActivityViewModel @Inject constructor(
|
|||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
messages = messages + ChatMessage(
|
||||
role = ChatMessage.Role.Assistant,
|
||||
content = responseContent
|
||||
chat = it.chat.copy(
|
||||
messages = messages + ChatMessage(
|
||||
role = ChatMessage.Role.Assistant,
|
||||
content = responseContent
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -137,9 +148,21 @@ class ChatActivityViewModel @Inject constructor(
|
|||
it.copy(
|
||||
requestInProgress = false,
|
||||
lastResponseError = error,
|
||||
messages = messages.toList()
|
||||
chat = it.chat.copy(
|
||||
messages = messages.toList()
|
||||
)
|
||||
)
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun selectModel(model: LanguageModel) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
chat = it.chat.copy(
|
||||
model = model
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,26 +1,38 @@
|
|||
package eu.m724.chatapp.activity.chat.composable
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.material.icons.filled.Menu
|
||||
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.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.Modifier
|
||||
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 eu.m724.chatapp.R
|
||||
import eu.m724.chatapp.activity.chat.quick_settings.ChatQuickSettings
|
||||
import eu.m724.chatapp.activity.chat.quick_settings.ChatQuickSettingsEvent
|
||||
import eu.m724.chatapp.activity.chat.quick_settings.ChatQuickSettingsVisibility
|
||||
|
||||
@Composable
|
||||
fun ChatToolBar(
|
||||
|
@ -28,31 +40,89 @@ fun ChatToolBar(
|
|||
canSend: Boolean,
|
||||
willRestart: Boolean,
|
||||
onSend: () -> Unit,
|
||||
onEmptySpaceClick: () -> Unit
|
||||
onEmptySpaceClick: () -> Unit,
|
||||
onSettingsEvent: (ChatQuickSettingsEvent) -> Unit
|
||||
) {
|
||||
var settingsOpened by remember { mutableStateOf(false) }
|
||||
val localSoftwareKeyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(settingsOpened) {
|
||||
onSettingsEvent(ChatQuickSettingsEvent.Visibility(
|
||||
if (settingsOpened) {
|
||||
ChatQuickSettingsVisibility.OPEN
|
||||
} else {
|
||||
ChatQuickSettingsVisibility.CLOSED
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
ElevatedCard(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onEmptySpaceClick),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
SendMessageButton(
|
||||
Column {
|
||||
AnimatedVisibility(settingsOpened) {
|
||||
ChatQuickSettings(
|
||||
modifier = Modifier.padding(16.dp), // To match the rounded corners
|
||||
onModelSelected = {
|
||||
settingsOpened = false
|
||||
onSettingsEvent(ChatQuickSettingsEvent.ModelSelected(it))
|
||||
},
|
||||
onDismiss = {
|
||||
settingsOpened = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
onClick = onSend,
|
||||
enabled = canSend,
|
||||
willRestart = willRestart
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onEmptySpaceClick),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
SettingsButton(
|
||||
onClick = {
|
||||
settingsOpened = !settingsOpened
|
||||
localSoftwareKeyboardController?.hide() // TODO re show when done
|
||||
},
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
)
|
||||
|
||||
Spacer(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
SendMessageButton(
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
onClick = onSend,
|
||||
enabled = canSend,
|
||||
willRestart = willRestart
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Menu,
|
||||
contentDescription = stringResource(R.string.button_settings_icon_description)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SendMessageButton(
|
||||
onClick: () -> Unit,
|
||||
|
|
|
@ -14,7 +14,7 @@ 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
|
||||
import eu.m724.chatapp.chat.ChatResponseError
|
||||
|
||||
@Composable
|
||||
fun ChatResponseErrorNotice(
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
package eu.m724.chatapp.activity.chat.quick_settings
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.min
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import eu.m724.chatapp.R
|
||||
import eu.m724.chatapp.activity.chat.quick_settings.composable.ModelCard
|
||||
import eu.m724.chatapp.api.data.response.models.LanguageModel
|
||||
|
||||
@Composable
|
||||
fun ChatQuickSettings(
|
||||
modifier: Modifier = Modifier,
|
||||
onModelSelected: (LanguageModel) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: ChatQuickSettingsViewModel = viewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (uiState.modelsLoaded) {
|
||||
ModelList(
|
||||
models = uiState.models,
|
||||
onModelSelected = onModelSelected,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModelList(
|
||||
models: List<LanguageModel>,
|
||||
onModelSelected: (LanguageModel) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val minHeight = 250.dp
|
||||
val maxHeight = 600.dp // TODO
|
||||
|
||||
var targetHeight by remember { mutableStateOf(minHeight) }
|
||||
val height by animateDpAsState(targetHeight)
|
||||
|
||||
var listState = rememberLazyListState()
|
||||
|
||||
val isScrolledToTop by remember {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(listState.isScrollInProgress) {
|
||||
if (!listState.isScrollInProgress) {
|
||||
if (targetHeight < 50.dp) {
|
||||
onDismiss()
|
||||
} else if (targetHeight < minHeight + 10.dp) {
|
||||
targetHeight = minHeight
|
||||
} else {
|
||||
targetHeight = maxHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is the connection that will intercept scroll events.
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
// onPreScroll is called before the child (LazyColumn) gets to scroll.
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
if (available.y < 0) { // scroll down (content down, finger up)
|
||||
if (targetHeight < maxHeight) {
|
||||
targetHeight = min(targetHeight - available.y.dp / 2, maxHeight)
|
||||
return available
|
||||
}
|
||||
}
|
||||
|
||||
// scroll up (content up, finger down)
|
||||
if (available.y > 0 && isScrolledToTop) {
|
||||
targetHeight = targetHeight - available.y.dp / 2
|
||||
return available
|
||||
}
|
||||
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
return super.onPostScroll(consumed, available, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
state = listState
|
||||
) { // TODO make this rounded
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.quick_settings_select_model),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
) // TODO center this maybe? but this looks cool too
|
||||
}
|
||||
|
||||
items(
|
||||
items = models,
|
||||
key = { it.id }
|
||||
) { model ->
|
||||
ModelCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
model = model,
|
||||
onSelected = {
|
||||
onModelSelected(model)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package eu.m724.chatapp.activity.chat.quick_settings
|
||||
|
||||
import eu.m724.chatapp.api.data.response.models.LanguageModel
|
||||
|
||||
sealed interface ChatQuickSettingsEvent {
|
||||
data class Visibility(val visibility: ChatQuickSettingsVisibility): ChatQuickSettingsEvent
|
||||
data class ModelSelected(val model: LanguageModel): ChatQuickSettingsEvent
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package eu.m724.chatapp.activity.chat.quick_settings
|
||||
|
||||
import eu.m724.chatapp.api.data.response.models.LanguageModel
|
||||
|
||||
data class ChatQuickSettingsUiState(
|
||||
val modelsLoaded: Boolean = false,
|
||||
|
||||
/**
|
||||
* A list of all available language models.
|
||||
*/
|
||||
val models: List<LanguageModel> = listOf()
|
||||
)
|
|
@ -0,0 +1,44 @@
|
|||
package eu.m724.chatapp.activity.chat.quick_settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.m724.chatapp.api.AiApiService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ChatQuickSettingsViewModel @Inject constructor(
|
||||
val aiApiService: AiApiService
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ChatQuickSettingsUiState())
|
||||
val uiState: StateFlow<ChatQuickSettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadModels()
|
||||
}
|
||||
|
||||
private fun loadModels() {
|
||||
viewModelScope.launch {
|
||||
val modelsResponse = try {
|
||||
aiApiService.getModels()
|
||||
} catch (e: Exception) {
|
||||
// TODO
|
||||
return@launch
|
||||
}
|
||||
|
||||
val models = modelsResponse.body()!!.data
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
modelsLoaded = true,
|
||||
models = models
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package eu.m724.chatapp.activity.chat.quick_settings
|
||||
|
||||
enum class ChatQuickSettingsVisibility {
|
||||
CLOSED, OPEN, EXPANDED
|
||||
}
|
|
@ -0,0 +1,228 @@
|
|||
package eu.m724.chatapp.activity.chat.quick_settings.composable
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.m724.chatapp.R
|
||||
import eu.m724.chatapp.api.data.response.models.LanguageModel
|
||||
import java.math.RoundingMode
|
||||
import java.text.DecimalFormat
|
||||
|
||||
@Composable
|
||||
fun ModelCard(
|
||||
model: LanguageModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onSelected: () -> Unit = {},
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AccountCircle,
|
||||
contentDescription = stringResource(
|
||||
R.string.model_card_icon_description,
|
||||
model.name
|
||||
),
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.fillMaxHeight(),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
// TODO this button is awkward
|
||||
AnimatedVisibility(expanded) {
|
||||
TextButton(
|
||||
onClick = onSelected,
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 1.dp, minHeight = 1.dp)
|
||||
.width(48.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.model_card_select)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = model.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
|
||||
ExpandArrowIcon(
|
||||
expanded = expanded
|
||||
)
|
||||
}
|
||||
|
||||
if (model.description != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = model.description,
|
||||
modifier = Modifier.animateContentSize(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
PriceItem(
|
||||
icon = Icons.Default.KeyboardArrowUp,
|
||||
label = stringResource(R.string.model_card_price_input),
|
||||
price = model.pricing.pricePerMillionInputTokens,
|
||||
contentDescription = stringResource(R.string.model_card_price_million_input_icon_description)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
PriceItem(
|
||||
icon = Icons.Default.KeyboardArrowDown,
|
||||
label = stringResource(R.string.model_card_price_output),
|
||||
price = model.pricing.pricePerMillionOutputTokens,
|
||||
contentDescription = stringResource(R.string.model_card_price_million_output_icon_description)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.model_card_price_million_tokens),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small, horizontal composable for displaying a single price point.
|
||||
*/
|
||||
@Composable
|
||||
private fun PriceItem(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
price: Double,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
// Smaller icon for a more compact look
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "$${roundToAtMost(price, 3)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandArrowIcon(
|
||||
expanded: Boolean
|
||||
) {
|
||||
val rotation by animateFloatAsState(
|
||||
targetValue = if (expanded) -180f else 0f,
|
||||
animationSpec = tween(),
|
||||
label = "Arrow rotation animation"
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = stringResource(R.string.model_card_toggle_icon_description),
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.rotate(rotation),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
fun roundToAtMost(number: Double, n: Int): String { // TODO localize
|
||||
// Create a pattern with 'n' optional decimal places
|
||||
// For n=3, pattern is "#.###"
|
||||
// For n=0, pattern is "#"
|
||||
val pattern = if (n > 0) "#." + "#".repeat(n) else "#"
|
||||
|
||||
val df = DecimalFormat(pattern)
|
||||
df.roundingMode = RoundingMode.HALF_UP // Or choose another rounding mode
|
||||
return df.format(number)
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
package eu.m724.chatapp.activity.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.m724.chatapp.R
|
||||
import eu.m724.chatapp.activity.chat.ChatActivity
|
||||
import eu.m724.chatapp.activity.ui.theme.ChatAppTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ChatAppTheme {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { innerPadding ->
|
||||
Content(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Content(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MainActivityViewModel = viewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.uiEvents.collect { event ->
|
||||
when (event) {
|
||||
is MainActivityUiEvent.StartChat -> {
|
||||
val intent = Intent(context, ChatActivity::class.java).apply {
|
||||
putExtra("chat", event.chat)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.welcome)
|
||||
)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
onClick = {
|
||||
viewModel.startConversation()
|
||||
},
|
||||
enabled = !uiState.loading
|
||||
) {
|
||||
if (uiState.loading) {
|
||||
LinearProgressIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.start_new_conversation)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package eu.m724.chatapp.activity.main
|
||||
|
||||
import eu.m724.chatapp.chat.Chat
|
||||
|
||||
sealed interface MainActivityUiEvent {
|
||||
data class StartChat(
|
||||
val chat: Chat,
|
||||
): MainActivityUiEvent
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
package eu.m724.chatapp.activity.main
|
||||
|
||||
data class MainActivityUiState(
|
||||
val loading: Boolean = false
|
||||
)
|
|
@ -0,0 +1,49 @@
|
|||
package eu.m724.chatapp.activity.main
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.m724.chatapp.api.AiApiService
|
||||
import eu.m724.chatapp.chat.Chat
|
||||
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
|
||||
|
||||
@HiltViewModel
|
||||
class MainActivityViewModel @Inject constructor(
|
||||
val aiApiService: AiApiService
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(MainActivityUiState())
|
||||
val uiState: StateFlow<MainActivityUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _uiEvents = Channel<MainActivityUiEvent>()
|
||||
val uiEvents = _uiEvents.receiveAsFlow()
|
||||
|
||||
fun startConversation() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loading = true
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val modelsResponse = aiApiService.getModels()
|
||||
val models = modelsResponse.body()!!.data
|
||||
val model = models.find { it.id == "meta-llama/llama-3.2-3b-instruct" }
|
||||
println(models)
|
||||
|
||||
val chat = Chat(
|
||||
title = null,
|
||||
model = model!!,
|
||||
messages = emptyList()
|
||||
)
|
||||
|
||||
_uiEvents.send(MainActivityUiEvent.StartChat(chat))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,352 +0,0 @@
|
|||
package eu.m724.chatapp.activity.select
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.m724.chatapp.activity.ui.theme.ChatAppTheme
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.m724.chatapp.api.data.response.models.LanguageModel
|
||||
import java.math.RoundingMode
|
||||
import java.text.DecimalFormat
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SelectModelActivity : ComponentActivity() {
|
||||
private val viewModel: SelectModelViewModel by viewModels()
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
viewModel.getModels()
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ChatAppTheme {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Model picker",
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
// TODO animate this
|
||||
if (!searching) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
// TODO call callback here or whatever
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "Exit model picker"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = {
|
||||
searching = false
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Exit search"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Row(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
TextField(
|
||||
value = searchQuery,
|
||||
onValueChange = {
|
||||
searchQuery = it
|
||||
}, // TODO cal here too search or something
|
||||
modifier = Modifier.fillMaxWidth(
|
||||
if (searching) 1f else 0f
|
||||
).padding(start = 64.dp, end = 64.dp).focusRequester(focusRequester),
|
||||
singleLine = true,
|
||||
placeholder = {
|
||||
Text("Search for a model...")
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
searching = !searching
|
||||
if (searching) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = "Search"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
items(uiState.models) { model ->
|
||||
ModelCard(model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModelCard(
|
||||
model: LanguageModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onSelected: () -> Unit = {},
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
onClick = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AccountCircle,
|
||||
contentDescription = "Icon for ${model.name}",
|
||||
modifier = Modifier.size(48.dp).fillMaxHeight(),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
// TODO this button is awkward
|
||||
AnimatedVisibility(expanded) {
|
||||
TextButton(
|
||||
onClick = onSelected,
|
||||
contentPadding = PaddingValues(horizontal = 0.dp),
|
||||
modifier = Modifier.defaultMinSize(minWidth = 1.dp, minHeight = 1.dp).width(48.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Select"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = model.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
|
||||
ExpandArrowIcon(
|
||||
expanded = expanded
|
||||
)
|
||||
}
|
||||
|
||||
if (model.description != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = model.description,
|
||||
modifier = Modifier.animateContentSize(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = if (expanded) Int.MAX_VALUE else 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
PriceItem(
|
||||
icon = Icons.Default.KeyboardArrowUp,
|
||||
label = "Input:",
|
||||
price = model.pricing.pricePerMillionInputTokens,
|
||||
contentDescription = "Price per million input tokens"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
PriceItem(
|
||||
icon = Icons.Default.KeyboardArrowDown,
|
||||
label = "Output:",
|
||||
price = model.pricing.pricePerMillionOutputTokens,
|
||||
contentDescription = "Price per million output tokens"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
text = "/ 1M tokens",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A small, horizontal composable for displaying a single price point.
|
||||
*/
|
||||
@Composable
|
||||
private fun PriceItem(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
label: String,
|
||||
price: Double,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
// Smaller icon for a more compact look
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "$${roundToAtMost(price, 3)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExpandArrowIcon(
|
||||
expanded: Boolean
|
||||
) {
|
||||
val rotation by animateFloatAsState(
|
||||
targetValue = if (expanded) -180f else 0f,
|
||||
animationSpec = tween(),
|
||||
label = "Arrow rotation animation"
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = "Toggle details",
|
||||
modifier = Modifier.size(16.dp).rotate(rotation),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
fun roundToAtMost(number: Double, n: Int): String {
|
||||
// Create a pattern with 'n' optional decimal places
|
||||
// For n=3, pattern is "#.###"
|
||||
// For n=0, pattern is "#"
|
||||
val pattern = if (n > 0) "#." + "#".repeat(n) else "#"
|
||||
|
||||
val df = DecimalFormat(pattern)
|
||||
df.roundingMode = RoundingMode.HALF_UP // Or choose another rounding mode
|
||||
return df.format(number)
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package eu.m724.chatapp.activity.select
|
||||
|
||||
import eu.m724.chatapp.api.data.response.models.LanguageModel
|
||||
|
||||
data class SelectModelUiState(
|
||||
val models: List<LanguageModel> = listOf()
|
||||
)
|
|
@ -1,42 +0,0 @@
|
|||
package eu.m724.chatapp.activity.select
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import eu.m724.chatapp.api.AiApiService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SelectModelViewModel @Inject constructor(
|
||||
private val aiApiService: AiApiService
|
||||
) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SelectModelUiState())
|
||||
val uiState: StateFlow<SelectModelUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun getModels() {
|
||||
viewModelScope.launch {
|
||||
val models = aiApiService.getModels()
|
||||
|
||||
if (!models.isSuccessful) {
|
||||
// TODO fail
|
||||
return@launch
|
||||
}
|
||||
|
||||
val languageModels = models.body()?.data
|
||||
|
||||
if (languageModels == null) {
|
||||
// TODO fail too
|
||||
return@launch
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(models = languageModels)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,14 @@
|
|||
package eu.m724.chatapp.api.data.response.completion
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class ChatMessage(
|
||||
val role: Role,
|
||||
val content: String
|
||||
) {
|
||||
) : Parcelable {
|
||||
enum class Role {
|
||||
@SerializedName("system")
|
||||
System,
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
package eu.m724.chatapp.api.data.response.models
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Represents a language model.
|
||||
*/
|
||||
@Parcelize
|
||||
data class LanguageModel(
|
||||
/**
|
||||
* The ID of this model.
|
||||
*/
|
||||
val id: String,
|
||||
|
||||
/**
|
||||
* The readable name of this model.
|
||||
*/
|
||||
val name: String,
|
||||
|
||||
/**
|
||||
* The description of this model. TODO make it null if it equals model name
|
||||
*/
|
||||
val description: String?,
|
||||
|
||||
/**
|
||||
* The maximum amount of tokens this model can handle in one sitting.
|
||||
*/
|
||||
@SerializedName("context_length")
|
||||
val contextLength: Int,
|
||||
|
||||
/**
|
||||
* The pricing of this model
|
||||
*/
|
||||
val pricing: LanguageModelPricing
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Represents the pricing of a language model.
|
||||
*/
|
||||
@Parcelize
|
||||
data class LanguageModelPricing(
|
||||
/**
|
||||
* The price per million input tokens
|
||||
*/
|
||||
@SerializedName("prompt")
|
||||
val pricePerMillionInputTokens: Double,
|
||||
|
||||
/**
|
||||
* The price per million output tokens
|
||||
*/
|
||||
@SerializedName("completion")
|
||||
val pricePerMillionOutputTokens: Double,
|
||||
|
||||
/**
|
||||
* Currency, as a code, like USD
|
||||
*/
|
||||
val currency: String
|
||||
) : Parcelable
|
|
@ -1,54 +1,5 @@
|
|||
package eu.m724.chatapp.api.data.response.models
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class LanguageModelsResponse(
|
||||
val data: List<LanguageModel>
|
||||
)
|
||||
|
||||
data class LanguageModel(
|
||||
/**
|
||||
* The ID of this model.
|
||||
*/
|
||||
val id: String,
|
||||
|
||||
/**
|
||||
* The readable name of this model.
|
||||
*/
|
||||
val name: String,
|
||||
|
||||
/**
|
||||
* The description of this model. TODO make it null if it equals model name
|
||||
*/
|
||||
val description: String?,
|
||||
|
||||
/**
|
||||
* The maximum amount of tokens this model can handle in one sitting.
|
||||
*/
|
||||
@SerializedName("context_length")
|
||||
val contextLength: Int,
|
||||
|
||||
/**
|
||||
* The pricing of this model
|
||||
*/
|
||||
val pricing: LanguageModelPricing
|
||||
)
|
||||
|
||||
data class LanguageModelPricing(
|
||||
/**
|
||||
* The price per million input tokens
|
||||
*/
|
||||
@SerializedName("prompt")
|
||||
val pricePerMillionInputTokens: Double,
|
||||
|
||||
/**
|
||||
* The price per million output tokens
|
||||
*/
|
||||
@SerializedName("completion")
|
||||
val pricePerMillionOutputTokens: Double,
|
||||
|
||||
/**
|
||||
* Currency, as a code, like USD
|
||||
*/
|
||||
val currency: String
|
||||
)
|
24
app/src/main/java/eu/m724/chatapp/chat/Chat.kt
Normal file
24
app/src/main/java/eu/m724/chatapp/chat/Chat.kt
Normal file
|
@ -0,0 +1,24 @@
|
|||
package eu.m724.chatapp.chat
|
||||
|
||||
import android.os.Parcelable
|
||||
import eu.m724.chatapp.api.data.response.completion.ChatMessage
|
||||
import eu.m724.chatapp.api.data.response.models.LanguageModel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Chat(
|
||||
/**
|
||||
* The title of this chat.
|
||||
*/
|
||||
val title: String?,
|
||||
|
||||
/**
|
||||
* The model used in this chat.
|
||||
*/
|
||||
val model: LanguageModel,
|
||||
|
||||
/**
|
||||
* The messages in this chat.
|
||||
*/
|
||||
val messages: List<ChatMessage>
|
||||
) : Parcelable
|
|
@ -1,4 +1,4 @@
|
|||
package eu.m724.chatapp.activity.chat.state
|
||||
package eu.m724.chatapp.chat
|
||||
|
||||
sealed interface ChatResponseError { // TODO does this belong here?
|
||||
data object LengthLimit: ChatResponseError
|
|
@ -8,4 +8,17 @@
|
|||
<string name="response_error_length_limit">Too long</string>
|
||||
<string name="response_error_generic">Fatal error</string>
|
||||
<string name="ai_mistake_warning">AI can make mistakes, double-check.</string>
|
||||
<string name="button_settings_icon_description">Settings</string>
|
||||
<string name="title_activity_main">MainActivity</string>
|
||||
<string name="quick_settings_select_model">Select model</string>
|
||||
<string name="model_card_select">Select</string>
|
||||
<string name="model_card_icon_description">Icon for %1$s</string>
|
||||
<string name="model_card_price_million_input_icon_description">Price per million input tokens</string>
|
||||
<string name="model_card_price_million_output_icon_description">Price per million output tokens</string>
|
||||
<string name="model_card_price_input">Input:</string>
|
||||
<string name="model_card_price_output">Output:</string>
|
||||
<string name="model_card_price_million_tokens">/ 1M tokens</string>
|
||||
<string name="model_card_toggle_icon_description">Toggle details</string>
|
||||
<string name="welcome">Welcome to ChatApp!</string>
|
||||
<string name="start_new_conversation">Start a new conversation</string>
|
||||
</resources>
|
|
@ -6,4 +6,5 @@ plugins {
|
|||
alias(libs.plugins.hilt.android) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
alias(libs.plugins.secrets) apply false
|
||||
alias(libs.plugins.parcelize) apply false
|
||||
}
|
|
@ -17,6 +17,8 @@ secrets = "2.0.1"
|
|||
loggingInterceptor = "4.12.0"
|
||||
material3WindowSizeClass = "1.3.2"
|
||||
okhttpSse = "4.12.0"
|
||||
navigationCompose = "2.9.0"
|
||||
parcelize = "2.1.21"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
|
@ -42,6 +44,7 @@ retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-
|
|||
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" }
|
||||
okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttpSse" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
@ -49,4 +52,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
|||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
|
||||
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
|
||||
parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "parcelize" }
|
Loading…
Add table
Add a link
Reference in a new issue