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.hilt.android)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.secrets)
|
alias(libs.plugins.secrets)
|
||||||
|
alias(libs.plugins.parcelize)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -66,6 +67,7 @@ dependencies {
|
||||||
implementation(libs.retrofit.converter.gson)
|
implementation(libs.retrofit.converter.gson)
|
||||||
implementation(libs.androidx.material3.window.size.class1)
|
implementation(libs.androidx.material3.window.size.class1)
|
||||||
implementation(libs.okhttp.sse)
|
implementation(libs.okhttp.sse)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<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" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
|
@ -8,18 +8,18 @@
|
||||||
android:name=".ChatApplication"
|
android:name=".ChatApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ChatApp"
|
android:theme="@style/Theme.ChatApp">
|
||||||
android:enableOnBackInvokedCallback="true">
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activity.chat.ChatActivity"
|
android:name=".activity.main.MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.ChatApp"
|
android:label="@string/title_activity_main"
|
||||||
android:windowSoftInputMode="adjustNothing">
|
android:theme="@style/Theme.ChatApp">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
|
@ -27,9 +27,11 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".activity.select.SelectModelActivity"
|
android:name=".activity.chat.ChatActivity"
|
||||||
android:exported="true"
|
android:exported="false"
|
||||||
android:theme="@style/Theme.ChatApp" />
|
android:theme="@style/Theme.ChatApp"
|
||||||
|
android:windowSoftInputMode="adjustNothing">
|
||||||
|
</activity>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
@ -47,6 +47,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import eu.m724.chatapp.R
|
import eu.m724.chatapp.R
|
||||||
import eu.m724.chatapp.activity.chat.composable.ChatToolBar
|
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.LanguageModelMistakeWarning
|
||||||
import eu.m724.chatapp.activity.chat.composable.thread.ChatMessageComposer
|
import eu.m724.chatapp.activity.chat.composable.thread.ChatMessageComposer
|
||||||
import eu.m724.chatapp.activity.chat.composable.thread.ChatResponseErrorNotice
|
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.ChatComposerState
|
||||||
import eu.m724.chatapp.activity.chat.state.rememberChatComposerState
|
import eu.m724.chatapp.activity.chat.state.rememberChatComposerState
|
||||||
import eu.m724.chatapp.activity.ui.composable.disableBringIntoViewOnFocus
|
import eu.m724.chatapp.activity.ui.composable.disableBringIntoViewOnFocus
|
||||||
import eu.m724.chatapp.activity.ui.composable.hideKeyboardOnScrollUp
|
import eu.m724.chatapp.activity.ui.composable.hideKeyboardOnScrollUp
|
||||||
import eu.m724.chatapp.activity.ui.theme.ChatAppTheme
|
import eu.m724.chatapp.activity.ui.theme.ChatAppTheme
|
||||||
import eu.m724.chatapp.api.data.response.completion.ChatMessage
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
|
@ -93,6 +97,8 @@ class ChatActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var savedFocus by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
windowSizeClass = windowSizeClass,
|
windowSizeClass = windowSizeClass,
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
|
@ -105,8 +111,8 @@ class ChatActivity : ComponentActivity() {
|
||||||
|
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
if (threadViewLazyListState.layoutInfo.visibleItemsInfo.find { it.key == "composer" } == null) {
|
if (threadViewLazyListState.layoutInfo.visibleItemsInfo.find { it.key == "composer" } == null) {
|
||||||
if (uiState.messages.isNotEmpty()) {
|
if (uiState.chat.messages.isNotEmpty()) {
|
||||||
threadViewLazyListState.animateScrollToItem(uiState.messages.size)
|
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
|
// 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()
|
chatState.requestFocus()
|
||||||
softwareKeyboardController?.show()
|
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 = ""
|
chatState.composerValue = ""
|
||||||
|
|
||||||
// scroll to the last user message
|
// scroll to the last user message
|
||||||
threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2)
|
threadViewLazyListState.animateScrollToItem(uiState.chat.messages.size - 2)
|
||||||
} else {
|
} else {
|
||||||
if (uiState.messages.size > 1) {
|
if (uiState.chat.messages.size > 1) {
|
||||||
// scroll to the last user message too
|
// scroll to the last user message too
|
||||||
threadViewLazyListState.animateScrollToItem(uiState.messages.size - 2)
|
threadViewLazyListState.animateScrollToItem(uiState.chat.messages.size - 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.lastResponseError == null) {
|
if (uiState.lastResponseError == null) {
|
||||||
|
|
@ -170,7 +186,8 @@ fun ChatScreen(
|
||||||
threadViewLazyListState: LazyListState,
|
threadViewLazyListState: LazyListState,
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
onRequestFocus: () -> Unit
|
onRequestFocus: () -> Unit,
|
||||||
|
onSettingsEvent: (ChatQuickSettingsEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
val isTablet = windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact
|
val isTablet = windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact
|
||||||
|
|
||||||
|
|
@ -179,7 +196,7 @@ fun ChatScreen(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
ChatTopAppBar(
|
ChatTopAppBar(
|
||||||
title = uiState.chatTitle ?: stringResource(R.string.title_new_conversation)
|
title = uiState.chat.title ?: stringResource(R.string.title_new_conversation)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
snackbarHost = {
|
snackbarHost = {
|
||||||
|
|
@ -200,7 +217,8 @@ fun ChatScreen(
|
||||||
chatComposerState = chatComposerState,
|
chatComposerState = chatComposerState,
|
||||||
threadViewLazyListState = threadViewLazyListState,
|
threadViewLazyListState = threadViewLazyListState,
|
||||||
onSend = onSend,
|
onSend = onSend,
|
||||||
onRequestFocus = onRequestFocus
|
onRequestFocus = onRequestFocus,
|
||||||
|
onSettingsEvent = onSettingsEvent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +232,8 @@ fun ChatScreenContent(
|
||||||
chatComposerState: ChatComposerState,
|
chatComposerState: ChatComposerState,
|
||||||
threadViewLazyListState: LazyListState,
|
threadViewLazyListState: LazyListState,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
onRequestFocus: () -> Unit
|
onRequestFocus: () -> Unit,
|
||||||
|
onSettingsEvent: (ChatQuickSettingsEvent) -> Unit
|
||||||
) {
|
) {
|
||||||
val layout: @Composable (@Composable () -> Unit, @Composable () -> Unit) -> Unit =
|
val layout: @Composable (@Composable () -> Unit, @Composable () -> Unit) -> Unit =
|
||||||
if (isTablet) {
|
if (isTablet) {
|
||||||
|
|
@ -246,7 +265,7 @@ fun ChatScreenContent(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 24.dp),
|
||||||
lazyListState = threadViewLazyListState,
|
lazyListState = threadViewLazyListState,
|
||||||
messages = uiState.messages,
|
messages = uiState.chat.messages,
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
chatComposerState = chatComposerState
|
chatComposerState = chatComposerState
|
||||||
)
|
)
|
||||||
|
|
@ -263,7 +282,8 @@ fun ChatScreenContent(
|
||||||
canSend = (chatComposerState.composerValue.isNotBlank() || uiState.lastResponseError != null) && !uiState.requestInProgress,
|
canSend = (chatComposerState.composerValue.isNotBlank() || uiState.lastResponseError != null) && !uiState.requestInProgress,
|
||||||
willRestart = chatComposerState.composerValue.isBlank() && uiState.lastResponseError != null,
|
willRestart = chatComposerState.composerValue.isBlank() && uiState.lastResponseError != null,
|
||||||
onSend = onSend,
|
onSend = onSend,
|
||||||
onEmptySpaceClick = onRequestFocus
|
onEmptySpaceClick = onRequestFocus,
|
||||||
|
onSettingsEvent = onSettingsEvent,
|
||||||
)
|
)
|
||||||
|
|
||||||
LanguageModelMistakeWarning(
|
LanguageModelMistakeWarning(
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,15 @@
|
||||||
package eu.m724.chatapp.activity.chat
|
package eu.m724.chatapp.activity.chat
|
||||||
|
|
||||||
import eu.m724.chatapp.activity.chat.state.ChatResponseError
|
import eu.m724.chatapp.chat.Chat
|
||||||
import eu.m724.chatapp.api.data.response.completion.ChatMessage
|
import eu.m724.chatapp.chat.ChatResponseError
|
||||||
|
|
||||||
data class ChatActivityUiState(
|
data class ChatActivityUiState(
|
||||||
/**
|
val chat: Chat,
|
||||||
* The title of the current chat
|
|
||||||
*/
|
|
||||||
val chatTitle: String? = null,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a request is in progress (a response is streaming)
|
* Whether a request is in progress (a response is streaming)
|
||||||
*/
|
*/
|
||||||
val requestInProgress: Boolean = false,
|
val requestInProgress: Boolean = false,
|
||||||
|
|
||||||
val lastResponseError: ChatResponseError? = null,
|
val lastResponseError: ChatResponseError? = null
|
||||||
|
|
||||||
/**
|
|
||||||
* All messages in the chat
|
|
||||||
*/
|
|
||||||
val messages: List<ChatMessage> = emptyList()
|
|
||||||
)
|
)
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
package eu.m724.chatapp.activity.chat
|
package eu.m724.chatapp.activity.chat
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
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.AiApiService
|
||||||
import eu.m724.chatapp.api.data.request.completion.ChatCompletionRequest
|
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.ChatCompletionResponseEvent
|
||||||
import eu.m724.chatapp.api.data.response.completion.ChatMessage
|
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.completion.CompletionFinishReason
|
||||||
|
import eu.m724.chatapp.api.data.response.models.LanguageModel
|
||||||
import eu.m724.chatapp.api.retrofit.sse.SseEvent
|
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.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -24,9 +27,13 @@ import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ChatActivityViewModel @Inject constructor(
|
class ChatActivityViewModel @Inject constructor(
|
||||||
private val aiApiService: AiApiService
|
private val aiApiService: AiApiService,
|
||||||
|
savedStateHandle: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : 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()
|
val uiState: StateFlow<ChatActivityUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
private val _uiEvents = Channel<ChatActivityUiEvent>()
|
private val _uiEvents = Channel<ChatActivityUiEvent>()
|
||||||
|
|
@ -42,7 +49,7 @@ class ChatActivityViewModel @Inject constructor(
|
||||||
|
|
||||||
if (lastUserMessage.role == ChatMessage.Role.Assistant) {
|
if (lastUserMessage.role == ChatMessage.Role.Assistant) {
|
||||||
// If we just removed an Assistant message, we must also remove the respective User message
|
// If we just removed an Assistant message, we must also remove the respective User message
|
||||||
lastUserMessage = messages.removeLast()
|
messages.removeLast()
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(lastUserMessage.content)
|
sendMessage(lastUserMessage.content)
|
||||||
|
|
@ -69,21 +76,23 @@ class ChatActivityViewModel @Inject constructor(
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
requestInProgress = true,
|
requestInProgress = true,
|
||||||
messages = messages + ChatMessage(
|
lastResponseError = null,
|
||||||
role = ChatMessage.Role.Assistant,
|
chat = it.chat.copy(
|
||||||
content = responseContent
|
title = it.chat.title ?: promptContent,
|
||||||
),
|
messages = messages + ChatMessage(
|
||||||
chatTitle = it.chatTitle ?: promptContent,
|
role = ChatMessage.Role.Assistant,
|
||||||
lastResponseError = null
|
content = responseContent
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
aiApiService.getChatCompletion(
|
aiApiService.getChatCompletion(
|
||||||
ChatCompletionRequest(
|
ChatCompletionRequest(
|
||||||
model = "free-model",
|
model = _uiState.value.chat.model.id,
|
||||||
messages = messages,
|
messages = messages,
|
||||||
temperature = 1.0f,
|
temperature = 1.0f,
|
||||||
maxTokens = 4,
|
maxTokens = 128,
|
||||||
frequencyPenalty = 0.0f,
|
frequencyPenalty = 0.0f,
|
||||||
presencePenalty = 0.0f
|
presencePenalty = 0.0f
|
||||||
)
|
)
|
||||||
|
|
@ -99,9 +108,11 @@ class ChatActivityViewModel @Inject constructor(
|
||||||
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
messages = messages + ChatMessage(
|
chat = it.chat.copy(
|
||||||
role = ChatMessage.Role.Assistant,
|
messages = messages + ChatMessage(
|
||||||
content = responseContent
|
role = ChatMessage.Role.Assistant,
|
||||||
|
content = responseContent
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -137,9 +148,21 @@ class ChatActivityViewModel @Inject constructor(
|
||||||
it.copy(
|
it.copy(
|
||||||
requestInProgress = false,
|
requestInProgress = false,
|
||||||
lastResponseError = error,
|
lastResponseError = error,
|
||||||
messages = messages.toList()
|
chat = it.chat.copy(
|
||||||
|
messages = messages.toList()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.launchIn(viewModelScope)
|
}.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
|
package eu.m724.chatapp.activity.chat.composable
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
import androidx.compose.material.icons.automirrored.filled.Send
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import eu.m724.chatapp.R
|
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
|
@Composable
|
||||||
fun ChatToolBar(
|
fun ChatToolBar(
|
||||||
|
|
@ -28,31 +40,89 @@ fun ChatToolBar(
|
||||||
canSend: Boolean,
|
canSend: Boolean,
|
||||||
willRestart: Boolean,
|
willRestart: Boolean,
|
||||||
onSend: () -> Unit,
|
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(
|
ElevatedCard(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = RoundedCornerShape(24.dp)
|
shape = RoundedCornerShape(24.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Column {
|
||||||
modifier = Modifier
|
AnimatedVisibility(settingsOpened) {
|
||||||
.fillMaxWidth()
|
ChatQuickSettings(
|
||||||
.clickable(onClick = onEmptySpaceClick),
|
modifier = Modifier.padding(16.dp), // To match the rounded corners
|
||||||
horizontalArrangement = Arrangement.End
|
onModelSelected = {
|
||||||
) {
|
settingsOpened = false
|
||||||
SendMessageButton(
|
onSettingsEvent(ChatQuickSettingsEvent.ModelSelected(it))
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
settingsOpened = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(48.dp)
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp),
|
.clickable(onClick = onEmptySpaceClick),
|
||||||
onClick = onSend,
|
horizontalArrangement = Arrangement.End
|
||||||
enabled = canSend,
|
) {
|
||||||
willRestart = willRestart
|
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
|
@Composable
|
||||||
fun SendMessageButton(
|
fun SendMessageButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import eu.m724.chatapp.R
|
import eu.m724.chatapp.R
|
||||||
import eu.m724.chatapp.activity.chat.state.ChatResponseError
|
import eu.m724.chatapp.chat.ChatResponseError
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatResponseErrorNotice(
|
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
|
package eu.m724.chatapp.api.data.response.completion
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
data class ChatMessage(
|
data class ChatMessage(
|
||||||
val role: Role,
|
val role: Role,
|
||||||
val content: String
|
val content: String
|
||||||
) {
|
) : Parcelable {
|
||||||
enum class Role {
|
enum class Role {
|
||||||
@SerializedName("system")
|
@SerializedName("system")
|
||||||
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
|
package eu.m724.chatapp.api.data.response.models
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
|
||||||
|
|
||||||
data class LanguageModelsResponse(
|
data class LanguageModelsResponse(
|
||||||
val data: List<LanguageModel>
|
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?
|
sealed interface ChatResponseError { // TODO does this belong here?
|
||||||
data object LengthLimit: ChatResponseError
|
data object LengthLimit: ChatResponseError
|
||||||
|
|
@ -8,4 +8,17 @@
|
||||||
<string name="response_error_length_limit">Too long</string>
|
<string name="response_error_length_limit">Too long</string>
|
||||||
<string name="response_error_generic">Fatal error</string>
|
<string name="response_error_generic">Fatal error</string>
|
||||||
<string name="ai_mistake_warning">AI can make mistakes, double-check.</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>
|
</resources>
|
||||||
|
|
@ -6,4 +6,5 @@ plugins {
|
||||||
alias(libs.plugins.hilt.android) apply false
|
alias(libs.plugins.hilt.android) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
alias(libs.plugins.secrets) 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"
|
loggingInterceptor = "4.12.0"
|
||||||
material3WindowSizeClass = "1.3.2"
|
material3WindowSizeClass = "1.3.2"
|
||||||
okhttpSse = "4.12.0"
|
okhttpSse = "4.12.0"
|
||||||
|
navigationCompose = "2.9.0"
|
||||||
|
parcelize = "2.1.21"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
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" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
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" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
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