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