Basic settings

This commit is contained in:
Minecon724 2025-06-23 11:59:04 +02:00
commit c52638b5e0
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
28 changed files with 893 additions and 516 deletions

View file

@ -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)

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" >
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
@ -8,18 +8,18 @@
android:name=".ChatApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ChatApp"
android:enableOnBackInvokedCallback="true">
android:theme="@style/Theme.ChatApp">
<activity
android:name=".activity.chat.ChatActivity"
android:name=".activity.main.MainActivity"
android:exported="true"
android:theme="@style/Theme.ChatApp"
android:windowSoftInputMode="adjustNothing">
android:label="@string/title_activity_main"
android:theme="@style/Theme.ChatApp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -27,9 +27,11 @@
</intent-filter>
</activity>
<activity
android:name=".activity.select.SelectModelActivity"
android:exported="true"
android:theme="@style/Theme.ChatApp" />
android:name=".activity.chat.ChatActivity"
android:exported="false"
android:theme="@style/Theme.ChatApp"
android:windowSoftInputMode="adjustNothing">
</activity>
</application>
</manifest>

View file

@ -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(

View file

@ -1,23 +1,15 @@
package eu.m724.chatapp.activity.chat
import eu.m724.chatapp.activity.chat.state.ChatResponseError
import eu.m724.chatapp.api.data.response.completion.ChatMessage
import eu.m724.chatapp.chat.Chat
import eu.m724.chatapp.chat.ChatResponseError
data class ChatActivityUiState(
/**
* The title of the current chat
*/
val chatTitle: String? = null,
val chat: Chat,
/**
* Whether a request is in progress (a response is streaming)
*/
val requestInProgress: Boolean = false,
val lastResponseError: ChatResponseError? = null,
/**
* All messages in the chat
*/
val messages: List<ChatMessage> = emptyList()
val lastResponseError: ChatResponseError? = null
)

View file

@ -1,15 +1,18 @@
package eu.m724.chatapp.activity.chat
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.m724.chatapp.activity.chat.state.ChatResponseError
import eu.m724.chatapp.api.AiApiService
import eu.m724.chatapp.api.data.request.completion.ChatCompletionRequest
import eu.m724.chatapp.api.data.response.completion.ChatCompletionResponseEvent
import eu.m724.chatapp.api.data.response.completion.ChatMessage
import eu.m724.chatapp.api.data.response.completion.CompletionFinishReason
import eu.m724.chatapp.api.data.response.models.LanguageModel
import eu.m724.chatapp.api.retrofit.sse.SseEvent
import eu.m724.chatapp.chat.Chat
import eu.m724.chatapp.chat.ChatResponseError
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -24,9 +27,13 @@ import javax.inject.Inject
@HiltViewModel
class ChatActivityViewModel @Inject constructor(
private val aiApiService: AiApiService
private val aiApiService: AiApiService,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _uiState = MutableStateFlow(ChatActivityUiState())
private val _uiState = MutableStateFlow(ChatActivityUiState(
chat = savedStateHandle.get<Chat>("chat") ?: throw IllegalStateException("Chat not provided")
))
val uiState: StateFlow<ChatActivityUiState> = _uiState.asStateFlow()
private val _uiEvents = Channel<ChatActivityUiEvent>()
@ -42,7 +49,7 @@ class ChatActivityViewModel @Inject constructor(
if (lastUserMessage.role == ChatMessage.Role.Assistant) {
// If we just removed an Assistant message, we must also remove the respective User message
lastUserMessage = messages.removeLast()
messages.removeLast()
}
sendMessage(lastUserMessage.content)
@ -69,21 +76,23 @@ class ChatActivityViewModel @Inject constructor(
_uiState.update {
it.copy(
requestInProgress = true,
messages = messages + ChatMessage(
role = ChatMessage.Role.Assistant,
content = responseContent
),
chatTitle = it.chatTitle ?: promptContent,
lastResponseError = null
lastResponseError = null,
chat = it.chat.copy(
title = it.chat.title ?: promptContent,
messages = messages + ChatMessage(
role = ChatMessage.Role.Assistant,
content = responseContent
)
)
)
}
aiApiService.getChatCompletion(
ChatCompletionRequest(
model = "free-model",
model = _uiState.value.chat.model.id,
messages = messages,
temperature = 1.0f,
maxTokens = 4,
maxTokens = 128,
frequencyPenalty = 0.0f,
presencePenalty = 0.0f
)
@ -99,9 +108,11 @@ class ChatActivityViewModel @Inject constructor(
_uiState.update {
it.copy(
messages = messages + ChatMessage(
role = ChatMessage.Role.Assistant,
content = responseContent
chat = it.chat.copy(
messages = messages + ChatMessage(
role = ChatMessage.Role.Assistant,
content = responseContent
)
)
)
}
@ -137,9 +148,21 @@ class ChatActivityViewModel @Inject constructor(
it.copy(
requestInProgress = false,
lastResponseError = error,
messages = messages.toList()
chat = it.chat.copy(
messages = messages.toList()
)
)
}
}.launchIn(viewModelScope)
}
fun selectModel(model: LanguageModel) {
_uiState.update {
it.copy(
chat = it.chat.copy(
model = model
)
)
}
}
}

View file

@ -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,

View file

@ -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(

View file

@ -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)
}
)
}
}
}

View file

@ -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
}

View file

@ -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()
)

View file

@ -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
)
}
}
}
}

View file

@ -0,0 +1,5 @@
package eu.m724.chatapp.activity.chat.quick_settings
enum class ChatQuickSettingsVisibility {
CLOSED, OPEN, EXPANDED
}

View file

@ -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)
}

View file

@ -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)
)
}
}
}
}

View file

@ -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
}

View file

@ -0,0 +1,5 @@
package eu.m724.chatapp.activity.main
data class MainActivityUiState(
val loading: Boolean = false
)

View file

@ -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))
}
}
}

View file

@ -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)
}

View file

@ -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()
)

View file

@ -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)
}
}
}
}

View file

@ -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,

View file

@ -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

View file

@ -1,54 +1,5 @@
package eu.m724.chatapp.api.data.response.models
import com.google.gson.annotations.SerializedName
data class LanguageModelsResponse(
val data: List<LanguageModel>
)
data class LanguageModel(
/**
* The ID of this model.
*/
val id: String,
/**
* The readable name of this model.
*/
val name: String,
/**
* The description of this model. TODO make it null if it equals model name
*/
val description: String?,
/**
* The maximum amount of tokens this model can handle in one sitting.
*/
@SerializedName("context_length")
val contextLength: Int,
/**
* The pricing of this model
*/
val pricing: LanguageModelPricing
)
data class LanguageModelPricing(
/**
* The price per million input tokens
*/
@SerializedName("prompt")
val pricePerMillionInputTokens: Double,
/**
* The price per million output tokens
*/
@SerializedName("completion")
val pricePerMillionOutputTokens: Double,
/**
* Currency, as a code, like USD
*/
val currency: String
)

View 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

View file

@ -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

View file

@ -8,4 +8,17 @@
<string name="response_error_length_limit">Too long</string>
<string name="response_error_generic">Fatal error</string>
<string name="ai_mistake_warning">AI can make mistakes, double-check.</string>
<string name="button_settings_icon_description">Settings</string>
<string name="title_activity_main">MainActivity</string>
<string name="quick_settings_select_model">Select model</string>
<string name="model_card_select">Select</string>
<string name="model_card_icon_description">Icon for %1$s</string>
<string name="model_card_price_million_input_icon_description">Price per million input tokens</string>
<string name="model_card_price_million_output_icon_description">Price per million output tokens</string>
<string name="model_card_price_input">Input:</string>
<string name="model_card_price_output">Output:</string>
<string name="model_card_price_million_tokens">/ 1M tokens</string>
<string name="model_card_toggle_icon_description">Toggle details</string>
<string name="welcome">Welcome to ChatApp!</string>
<string name="start_new_conversation">Start a new conversation</string>
</resources>

View file

@ -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
}

View file

@ -17,6 +17,8 @@ secrets = "2.0.1"
loggingInterceptor = "4.12.0"
material3WindowSizeClass = "1.3.2"
okhttpSse = "4.12.0"
navigationCompose = "2.9.0"
parcelize = "2.1.21"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -42,6 +44,7 @@ retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-
logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" }
androidx-material3-window-size-class1 = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "material3WindowSizeClass" }
okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttpSse" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@ -49,4 +52,5 @@ kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "parcelize" }