From c52638b5e0f3e960a8dee262d007788e6a07d815 Mon Sep 17 00:00:00 2001 From: Minecon724 Date: Mon, 23 Jun 2025 11:59:04 +0200 Subject: [PATCH] Basic settings --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 20 +- .../chatapp/activity/chat/ChatActivity.kt | 42 ++- .../activity/chat/ChatActivityUiState.kt | 16 +- .../activity/chat/ChatActivityViewModel.kt | 55 ++- .../activity/chat/composable/ChatToolBar.kt | 98 ++++- .../thread/ChatResponseErrorNotice.kt | 2 +- .../chat/quick_settings/ChatQuickSettings.kt | 153 ++++++++ .../quick_settings/ChatQuickSettingsEvent.kt | 9 + .../ChatQuickSettingsUiState.kt | 12 + .../ChatQuickSettingsViewModel.kt | 44 +++ .../ChatQuickSettingsVisibility.kt | 5 + .../quick_settings/composable/ModelCard.kt | 228 ++++++++++++ .../chatapp/activity/main/MainActivity.kt | 99 +++++ .../activity/main/MainActivityUiEvent.kt | 9 + .../activity/main/MainActivityUiState.kt | 5 + .../activity/main/MainActivityViewModel.kt | 49 +++ .../activity/select/SelectModelActivity.kt | 352 ------------------ .../activity/select/SelectModelUiState.kt | 7 - .../activity/select/SelectModelViewModel.kt | 42 --- .../data/response/completion/ChatMessage.kt | 5 +- .../api/data/response/models/LanguageModel.kt | 60 +++ .../response/models/LanguageModelsResponse.kt | 49 --- .../main/java/eu/m724/chatapp/chat/Chat.kt | 24 ++ .../chat/state => chat}/ChatResponseError.kt | 2 +- app/src/main/res/values/strings.xml | 13 + build.gradle.kts | 1 + gradle/libs.versions.toml | 6 +- 28 files changed, 893 insertions(+), 516 deletions(-) create mode 100644 app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettings.kt create mode 100644 app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsEvent.kt create mode 100644 app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsUiState.kt create mode 100644 app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsViewModel.kt create mode 100644 app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsVisibility.kt create mode 100644 app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/composable/ModelCard.kt create mode 100644 app/src/main/java/eu/m724/chatapp/activity/main/MainActivity.kt create mode 100644 app/src/main/java/eu/m724/chatapp/activity/main/MainActivityUiEvent.kt create mode 100644 app/src/main/java/eu/m724/chatapp/activity/main/MainActivityUiState.kt create mode 100644 app/src/main/java/eu/m724/chatapp/activity/main/MainActivityViewModel.kt delete mode 100644 app/src/main/java/eu/m724/chatapp/activity/select/SelectModelActivity.kt delete mode 100644 app/src/main/java/eu/m724/chatapp/activity/select/SelectModelUiState.kt delete mode 100644 app/src/main/java/eu/m724/chatapp/activity/select/SelectModelViewModel.kt create mode 100644 app/src/main/java/eu/m724/chatapp/api/data/response/models/LanguageModel.kt create mode 100644 app/src/main/java/eu/m724/chatapp/chat/Chat.kt rename app/src/main/java/eu/m724/chatapp/{activity/chat/state => chat}/ChatResponseError.kt (80%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7f85e04..71aa7a5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4213431..d18fc87 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> @@ -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"> + android:label="@string/title_activity_main" + android:theme="@style/Theme.ChatApp"> @@ -27,9 +27,11 @@ + android:name=".activity.chat.ChatActivity" + android:exported="false" + android:theme="@style/Theme.ChatApp" + android:windowSoftInputMode="adjustNothing"> + \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt index 14a102c..9f5899d 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivity.kt @@ -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( diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt index f45642d..e7754e6 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityUiState.kt @@ -1,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 = emptyList() + val lastResponseError: ChatResponseError? = null ) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt index 920bd0f..4cf8e64 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/ChatActivityViewModel.kt @@ -1,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") ?: throw IllegalStateException("Chat not provided") + )) + val uiState: StateFlow = _uiState.asStateFlow() private val _uiEvents = Channel() @@ -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 + ) + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/composable/ChatToolBar.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/ChatToolBar.kt index b1bc6f4..da5266d 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/composable/ChatToolBar.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/ChatToolBar.kt @@ -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, diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/composable/thread/ChatResponseErrorNotice.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/thread/ChatResponseErrorNotice.kt index 7e05ccd..a847ab5 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/composable/thread/ChatResponseErrorNotice.kt +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/composable/thread/ChatResponseErrorNotice.kt @@ -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( diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettings.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettings.kt new file mode 100644 index 0000000..fbc07cd --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettings.kt @@ -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, + 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) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsEvent.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsEvent.kt new file mode 100644 index 0000000..7fced89 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsEvent.kt @@ -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 +} + diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsUiState.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsUiState.kt new file mode 100644 index 0000000..2c32e94 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsUiState.kt @@ -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 = listOf() +) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsViewModel.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsViewModel.kt new file mode 100644 index 0000000..612d7d8 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsViewModel.kt @@ -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 = _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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsVisibility.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsVisibility.kt new file mode 100644 index 0000000..3189c7c --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/ChatQuickSettingsVisibility.kt @@ -0,0 +1,5 @@ +package eu.m724.chatapp.activity.chat.quick_settings + +enum class ChatQuickSettingsVisibility { + CLOSED, OPEN, EXPANDED +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/composable/ModelCard.kt b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/composable/ModelCard.kt new file mode 100644 index 0000000..ba6117c --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/chat/quick_settings/composable/ModelCard.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/main/MainActivity.kt b/app/src/main/java/eu/m724/chatapp/activity/main/MainActivity.kt new file mode 100644 index 0000000..184e60d --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/main/MainActivity.kt @@ -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) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/main/MainActivityUiEvent.kt b/app/src/main/java/eu/m724/chatapp/activity/main/MainActivityUiEvent.kt new file mode 100644 index 0000000..5b29cb1 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/main/MainActivityUiEvent.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/main/MainActivityUiState.kt b/app/src/main/java/eu/m724/chatapp/activity/main/MainActivityUiState.kt new file mode 100644 index 0000000..16e190e --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/main/MainActivityUiState.kt @@ -0,0 +1,5 @@ +package eu.m724.chatapp.activity.main + +data class MainActivityUiState( + val loading: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/main/MainActivityViewModel.kt b/app/src/main/java/eu/m724/chatapp/activity/main/MainActivityViewModel.kt new file mode 100644 index 0000000..b7950db --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/activity/main/MainActivityViewModel.kt @@ -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 = _uiState.asStateFlow() + + private val _uiEvents = Channel() + 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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelActivity.kt b/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelActivity.kt deleted file mode 100644 index 246c03e..0000000 --- a/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelActivity.kt +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelUiState.kt b/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelUiState.kt deleted file mode 100644 index 46468d9..0000000 --- a/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelUiState.kt +++ /dev/null @@ -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 = listOf() -) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelViewModel.kt b/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelViewModel.kt deleted file mode 100644 index 2df9ec1..0000000 --- a/app/src/main/java/eu/m724/chatapp/activity/select/SelectModelViewModel.kt +++ /dev/null @@ -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 = _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) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/api/data/response/completion/ChatMessage.kt b/app/src/main/java/eu/m724/chatapp/api/data/response/completion/ChatMessage.kt index b85971c..de61432 100644 --- a/app/src/main/java/eu/m724/chatapp/api/data/response/completion/ChatMessage.kt +++ b/app/src/main/java/eu/m724/chatapp/api/data/response/completion/ChatMessage.kt @@ -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, diff --git a/app/src/main/java/eu/m724/chatapp/api/data/response/models/LanguageModel.kt b/app/src/main/java/eu/m724/chatapp/api/data/response/models/LanguageModel.kt new file mode 100644 index 0000000..2c0e865 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/api/data/response/models/LanguageModel.kt @@ -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 \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/api/data/response/models/LanguageModelsResponse.kt b/app/src/main/java/eu/m724/chatapp/api/data/response/models/LanguageModelsResponse.kt index 4e8d69b..abf7ffc 100644 --- a/app/src/main/java/eu/m724/chatapp/api/data/response/models/LanguageModelsResponse.kt +++ b/app/src/main/java/eu/m724/chatapp/api/data/response/models/LanguageModelsResponse.kt @@ -1,54 +1,5 @@ package eu.m724.chatapp.api.data.response.models -import com.google.gson.annotations.SerializedName - data class LanguageModelsResponse( val data: List -) - -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 ) \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/chat/Chat.kt b/app/src/main/java/eu/m724/chatapp/chat/Chat.kt new file mode 100644 index 0000000..ee0e410 --- /dev/null +++ b/app/src/main/java/eu/m724/chatapp/chat/Chat.kt @@ -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 +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/eu/m724/chatapp/activity/chat/state/ChatResponseError.kt b/app/src/main/java/eu/m724/chatapp/chat/ChatResponseError.kt similarity index 80% rename from app/src/main/java/eu/m724/chatapp/activity/chat/state/ChatResponseError.kt rename to app/src/main/java/eu/m724/chatapp/chat/ChatResponseError.kt index a951dd8..97237e3 100644 --- a/app/src/main/java/eu/m724/chatapp/activity/chat/state/ChatResponseError.kt +++ b/app/src/main/java/eu/m724/chatapp/chat/ChatResponseError.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c259696..99f5f1a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,4 +8,17 @@ Too long Fatal error AI can make mistakes, double-check. + Settings + MainActivity + Select model + Select + Icon for %1$s + Price per million input tokens + Price per million output tokens + Input: + Output: + / 1M tokens + Toggle details + Welcome to ChatApp! + Start a new conversation \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 5e364d9..f0567e0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 12301fa..7c556f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } \ No newline at end of file +secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" } +parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "parcelize" } \ No newline at end of file