diff --git a/README.md b/README.md index 91dc42f..b6b7149 100644 --- a/README.md +++ b/README.md @@ -19,5 +19,5 @@ apiKey=[your api key] Text | Image :--: | :--: -Text | Image -Text | Image +Text | Image +Text | Image diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 056131a..b4652c1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,3 @@ -import java.util.Properties - plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.jetbrainsKotlinAndroid) @@ -14,8 +12,8 @@ android { applicationId = "levi.lin.gemini.android" minSdk = 30 targetSdk = 34 - versionCode = 9 - versionName = "0.2.0" + versionCode = 10 + versionName = "0.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/levi/lin/gemini/android/MainActivity.kt b/app/src/main/java/levi/lin/gemini/android/MainActivity.kt index 8137756..8d39965 100644 --- a/app/src/main/java/levi/lin/gemini/android/MainActivity.kt +++ b/app/src/main/java/levi/lin/gemini/android/MainActivity.kt @@ -64,19 +64,19 @@ class MainActivity : ComponentActivity() { val imageBitmaps = result.data?.let { intent -> extractBitmapsFromIntent(intent = intent) }.orEmpty() - viewModel.clearSelectedImages() - viewModel.setImageCount(imageBitmaps.size) - viewModel.setImageBitmaps(imageBitmaps) + viewModel.clearImageList() + viewModel.setImageCount(count = imageBitmaps.size) + viewModel.setImageList(imageList = imageBitmaps) } } private fun extractBitmapsFromIntent(intent: Intent): List { return intent.clipData?.let { clipData -> (0 until clipData.itemCount).mapNotNull { index -> - getBitmapFromUri(clipData.getItemAt(index).uri) + getBitmapFromUri(uri = clipData.getItemAt(index).uri) } } ?: intent.data?.let { uri -> - listOfNotNull(getBitmapFromUri(uri)) + listOfNotNull(getBitmapFromUri(uri = uri)) }.orEmpty() } diff --git a/app/src/main/java/levi/lin/gemini/android/model/MessageItem.kt b/app/src/main/java/levi/lin/gemini/android/model/MessageItem.kt new file mode 100644 index 0000000..ad70cf7 --- /dev/null +++ b/app/src/main/java/levi/lin/gemini/android/model/MessageItem.kt @@ -0,0 +1,10 @@ +package levi.lin.gemini.android.model + +import java.util.UUID + +data class MessageItem( + val id: String = UUID.randomUUID().toString(), + var text: String = "", + val type: MessageType = MessageType.User, + var isPending: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/levi/lin/gemini/android/model/MessageType.kt b/app/src/main/java/levi/lin/gemini/android/model/MessageType.kt new file mode 100644 index 0000000..07d3316 --- /dev/null +++ b/app/src/main/java/levi/lin/gemini/android/model/MessageType.kt @@ -0,0 +1,7 @@ +package levi.lin.gemini.android.model + +enum class MessageType { + User, + Gemini, + Error +} \ No newline at end of file diff --git a/app/src/main/java/levi/lin/gemini/android/state/MessageState.kt b/app/src/main/java/levi/lin/gemini/android/state/MessageState.kt new file mode 100644 index 0000000..6e9fba1 --- /dev/null +++ b/app/src/main/java/levi/lin/gemini/android/state/MessageState.kt @@ -0,0 +1,36 @@ +package levi.lin.gemini.android.state + +import androidx.compose.runtime.toMutableStateList +import levi.lin.gemini.android.model.MessageItem + +class MessageState( + messageList: List = emptyList() +) { + private val _messageItems: MutableList = messageList.toMutableStateList() + val messageItems: List = _messageItems + + fun addMessage(message: MessageItem) { + _messageItems.add(element = message) + } + + fun replaceLastMessage() { + val lastMessage = _messageItems.lastOrNull() + lastMessage?.let { + val newMessage = lastMessage.apply { + isPending = false + } + _messageItems.removeLast() + _messageItems.add(newMessage) + } + } + + fun updateMessagePendingStatus(messageId: String, isPending: Boolean) { + val index = _messageItems.indexOfFirst { messageItem -> + messageItem.id == messageId + } + if (index != -1) { + val message = _messageItems[index].copy(isPending = isPending) + _messageItems[index] = message + } + } +} \ No newline at end of file diff --git a/app/src/main/java/levi/lin/gemini/android/ui/state/GeminiUiState.kt b/app/src/main/java/levi/lin/gemini/android/ui/state/GeminiUiState.kt deleted file mode 100644 index 676b07c..0000000 --- a/app/src/main/java/levi/lin/gemini/android/ui/state/GeminiUiState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package levi.lin.gemini.android.ui.state - -sealed interface GeminiUiState { - data object Initial : GeminiUiState - - data object Loading : GeminiUiState - - data class Success( - val outputText: String - ) : GeminiUiState - - data class Error( - val errorMessage: String - ) : GeminiUiState -} \ No newline at end of file diff --git a/app/src/main/java/levi/lin/gemini/android/ui/theme/Color.kt b/app/src/main/java/levi/lin/gemini/android/ui/theme/Color.kt index 246b0bd..13842ea 100644 --- a/app/src/main/java/levi/lin/gemini/android/ui/theme/Color.kt +++ b/app/src/main/java/levi/lin/gemini/android/ui/theme/Color.kt @@ -8,4 +8,17 @@ val LightBlue80 = Color(0xFF73A0FF) val Blue40 = Color(0xFF87B5FF) val BlueGrey40 = Color(0xFF8793FF) -val LightBlue40 = Color(0xFFAECBFF) \ No newline at end of file +val LightBlue40 = Color(0xFFAECBFF) + +val LightGray = Color(0xFFFAFAFA) +val LightMediumGray = Color(0xFFD1CFCF) +val MediumGray = Color(0xFFACA9A9) +val MediumDarkGray = Color(0xFF6F6C6C) +val DarkGray = Color(0xFF3D3C3C) + +val LightOrange = Color(0xFFF1AC47) +val Orange = Color(0xFFFF9800) +val DarkOrange = Color(0xFF533201) + +val LightRed = Color(0xFFFF3939) +val LightGreen = Color(0xFF9CFF39) \ No newline at end of file diff --git a/app/src/main/java/levi/lin/gemini/android/ui/view/GeminiScreen.kt b/app/src/main/java/levi/lin/gemini/android/ui/view/GeminiScreen.kt index 24e08d0..af09d21 100644 --- a/app/src/main/java/levi/lin/gemini/android/ui/view/GeminiScreen.kt +++ b/app/src/main/java/levi/lin/gemini/android/ui/view/GeminiScreen.kt @@ -1,102 +1,81 @@ package levi.lin.gemini.android.ui.view import android.graphics.Bitmap -import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -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.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AddCircle -import androidx.compose.material.icons.filled.Clear -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Send -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel -import levi.lin.gemini.android.ui.state.GeminiUiState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import levi.lin.gemini.android.viewmodel.GeminiViewModel -import levi.lin.gemini.android.R +import levi.lin.gemini.android.state.MessageState +import levi.lin.gemini.android.ui.view.component.InputBar +import levi.lin.gemini.android.ui.view.component.MessageList @Composable internal fun GeminiScreenContainer( geminiViewModel: GeminiViewModel = viewModel(), onImageSelected: () -> Unit = {} ) { - val geminiUiState by geminiViewModel.uiState.collectAsState() - val selectedImageBitmapList by geminiViewModel.selectedImageBitmaps.collectAsState() + val messageState by geminiViewModel.messageState.collectAsState() + val selectedImageList by geminiViewModel.selectedImageList.collectAsState() val selectedImageCount by geminiViewModel.selectedImageCount.collectAsState() + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() - LaunchedEffect(selectedImageBitmapList) { + LaunchedEffect(selectedImageList) { val targetModelName = - if (selectedImageBitmapList.isNotEmpty()) "gemini-pro-vision" else "gemini-pro" + if (selectedImageList.isNotEmpty()) "gemini-pro-vision" else "gemini-pro" geminiViewModel.updateGenerativeModel(targetModelName = targetModelName) } + LaunchedEffect(Unit) { + geminiViewModel.scrollToLatestMessageEvent.collect { + coroutineScope.launch { + listState.animateScrollToItem(index = messageState.messageItems.size - 1) + } + } + } + GeminiScreen( - uiState = geminiUiState, - selectedImageBitmapList = selectedImageBitmapList, + coroutineScope = coroutineScope, + messageState = messageState, + listState = listState, + selectedImageBitmapList = selectedImageList, selectedImageCount = selectedImageCount, onButtonClicked = { inputText -> - geminiViewModel.respond(inputText) + geminiViewModel.sendMessage(inputText) }, onImageSelected = onImageSelected, - onClearImages = { - geminiViewModel.clearSelectedImages() + onClearImage = { + geminiViewModel.clearImageList() } ) } @Composable fun GeminiScreen( - uiState: GeminiUiState = GeminiUiState.Initial, + coroutineScope: CoroutineScope, + messageState: MessageState, + listState: LazyListState, selectedImageBitmapList: List = emptyList(), selectedImageCount: Int = 0, onButtonClicked: (String) -> Unit = {}, onImageSelected: () -> Unit = {}, - onClearImages: () -> Unit = {} + onClearImage: () -> Unit = {} ) { var inputText by remember { mutableStateOf(value = "") } val focusManager = LocalFocusManager.current @@ -121,180 +100,19 @@ fun GeminiScreen( }, onButtonClicked = onButtonClicked, onImageSelected = onImageSelected, - onClearImages = onClearImages - ) - } - ) { innerPadding -> - ScreenContent(uiState, innerPadding) - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun InputBar( - inputText: String, - selectedImageBitmapList: List, - selectedImageCount: Int, - onTextChange: (String) -> Unit, - onButtonClicked: (String) -> Unit, - onImageSelected: () -> Unit, - onClearImages: () -> Unit -) { - val keyboardController = LocalSoftwareKeyboardController.current - Row( - modifier = Modifier - .background(color = MaterialTheme.colorScheme.primary) - .fillMaxWidth() - .padding(all = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - TextField( - value = inputText, - onValueChange = onTextChange, - modifier = Modifier.weight(1f), - label = { Text(text = stringResource(id = R.string.gemini_input_label)) }, - placeholder = { Text(text = stringResource(id = R.string.gemini_input_hint)) }, - trailingIcon = { - if (inputText.isNotEmpty()) { - IconButton(onClick = { - onTextChange("") - }) { - Icon( - imageVector = Icons.Filled.Clear, - contentDescription = "Clear" - ) - } - } - } - ) - Spacer(modifier = Modifier.weight(0.05f)) - Box( - modifier = Modifier - .size(48.dp) - .clip(RectangleShape), - contentAlignment = Alignment.TopEnd - ) { - IconButton( - modifier = Modifier - .size(48.dp) - .align(Alignment.BottomStart), - onClick = { onImageSelected() } - ) { - if (selectedImageBitmapList.isNotEmpty()) { - selectedImageBitmapList.first()?.let { image -> - Image( - bitmap = image.asImageBitmap(), - modifier = Modifier.fillMaxSize(), - contentDescription = "Selected Image", - contentScale = ContentScale.Crop - ) - } - if (selectedImageCount > 1) { - Box( - modifier = Modifier - .size(24.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - contentAlignment = Alignment.Center - ) { - val fontSize = when (selectedImageCount.toString().length) { - 1 -> 10.sp - 2 -> 8.sp - else -> 6.sp - } - Text( - text = selectedImageCount.toString(), - color = MaterialTheme.colorScheme.onPrimary, - fontSize = fontSize, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center - ) - } + onClearImage = onClearImage, + onResetScroll = { + coroutineScope.launch { + listState.scrollToItem(index = messageState.messageItems.size - 1) } - } else { - Icon( - imageVector = Icons.Default.AddCircle, - contentDescription = "Select Image", - tint = MaterialTheme.colorScheme.onPrimary - ) } - } - - if (selectedImageBitmapList.isNotEmpty()) { - IconButton( - onClick = { onClearImages() }, - modifier = Modifier - .clip(CircleShape) - .size(15.dp) - .background(Color.Gray) - .align(Alignment.TopEnd), - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Clear Images", - tint = Color.White - ) - } - } - } - Spacer(modifier = Modifier.weight(0.01f)) - IconButton( - onClick = { - onButtonClicked(inputText) - keyboardController?.hide() - }, - modifier = Modifier - .weight(0.2f) - .aspectRatio(ratio = 1f) - ) { - Icon( - imageVector = Icons.Default.Send, - contentDescription = "Send", - tint = MaterialTheme.colorScheme.onPrimary ) } + ) { innerPadding -> + MessageList( + messages = messageState.messageItems, + listState = listState, + innerPadding = innerPadding + ) } -} - -@Composable -fun ScreenContent(uiState: GeminiUiState, innerPadding: PaddingValues) { - val scrollState = rememberScrollState() - Column(modifier = Modifier.padding(innerPadding)) { - when (uiState) { - is GeminiUiState.Success -> { - Box(modifier = Modifier.verticalScroll(scrollState)) { - SelectionContainer { - Text( - text = uiState.outputText.trim(), - modifier = Modifier.padding(10.dp) - ) - } - } - } - - is GeminiUiState.Loading -> { - LoadingIndicator() - } - - is GeminiUiState.Error -> { - ErrorMessage(uiState.errorMessage) - } - - else -> Spacer(modifier = Modifier.fillMaxHeight()) - } - } -} - -@Composable -fun LoadingIndicator() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } -} - -@Composable -fun ErrorMessage(message: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text = message, color = Color.Red) - } -} +} \ No newline at end of file diff --git a/app/src/main/java/levi/lin/gemini/android/ui/view/component/InputBar.kt b/app/src/main/java/levi/lin/gemini/android/ui/view/component/InputBar.kt new file mode 100644 index 0000000..8426993 --- /dev/null +++ b/app/src/main/java/levi/lin/gemini/android/ui/view/component/InputBar.kt @@ -0,0 +1,169 @@ +package levi.lin.gemini.android.ui.view.component + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddCircle +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import levi.lin.gemini.android.R + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun InputBar( + inputText: String, + selectedImageBitmapList: List, + selectedImageCount: Int, + onTextChange: (String) -> Unit, + onButtonClicked: (String) -> Unit, + onImageSelected: () -> Unit, + onClearImage: () -> Unit, + onResetScroll: () -> Unit +) { + val keyboardController = LocalSoftwareKeyboardController.current + Row( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.primary) + .fillMaxWidth() + .padding(all = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = inputText, + onValueChange = onTextChange, + modifier = Modifier.weight(1f), + label = { Text(text = stringResource(id = R.string.gemini_input_label)) }, + placeholder = { Text(text = stringResource(id = R.string.gemini_input_hint)) }, + trailingIcon = { + if (inputText.isNotEmpty()) { + IconButton(onClick = { + onTextChange("") + }) { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = "Clear" + ) + } + } + } + ) + Spacer(modifier = Modifier.weight(0.05f)) + Box( + modifier = Modifier + .size(48.dp) + .clip(RectangleShape), + contentAlignment = Alignment.TopEnd + ) { + IconButton( + modifier = Modifier + .size(48.dp) + .align(Alignment.BottomStart), + onClick = { onImageSelected() } + ) { + if (selectedImageBitmapList.isNotEmpty()) { + selectedImageBitmapList.first()?.let { image -> + Image( + bitmap = image.asImageBitmap(), + modifier = Modifier.fillMaxSize(), + contentDescription = "Selected Image", + contentScale = ContentScale.Crop + ) + } + if (selectedImageCount > 1) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + val fontSize = when (selectedImageCount.toString().length) { + 1 -> 10.sp + 2 -> 8.sp + else -> 6.sp + } + Text( + text = selectedImageCount.toString(), + color = MaterialTheme.colorScheme.onPrimary, + fontSize = fontSize, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + } + } + } else { + Icon( + imageVector = Icons.Default.AddCircle, + contentDescription = "Select Image", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + + if (selectedImageBitmapList.isNotEmpty()) { + IconButton( + onClick = { onClearImage() }, + modifier = Modifier + .clip(CircleShape) + .size(15.dp) + .background(Color.Gray) + .align(Alignment.TopEnd), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear Images", + tint = Color.White + ) + } + } + } + Spacer(modifier = Modifier.weight(0.01f)) + IconButton( + onClick = { + onButtonClicked(inputText) + keyboardController?.hide() + onResetScroll() + }, + modifier = Modifier + .weight(0.2f) + .aspectRatio(ratio = 1f) + ) { + Icon( + imageVector = Icons.Default.Send, + contentDescription = "Send", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/levi/lin/gemini/android/ui/view/component/MessageBubble.kt b/app/src/main/java/levi/lin/gemini/android/ui/view/component/MessageBubble.kt new file mode 100644 index 0000000..71c5ebb --- /dev/null +++ b/app/src/main/java/levi/lin/gemini/android/ui/view/component/MessageBubble.kt @@ -0,0 +1,103 @@ +package levi.lin.gemini.android.ui.view.component + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import levi.lin.gemini.android.model.MessageItem +import levi.lin.gemini.android.model.MessageType + +@Composable +fun MessageList( + messages: List, + listState: LazyListState, + innerPadding: PaddingValues +) { + Column(modifier = Modifier.padding(innerPadding)) { + LazyColumn( + state = listState + ) { + items(messages) { message -> + MessageBubble(messageItem = message) + } + } + } +} + +@Composable +fun MessageBubble( + messageItem: MessageItem +) { + val isGeminiMessage = messageItem.type == MessageType.Gemini + + val backgroundColor = when (messageItem.type) { + MessageType.User -> MaterialTheme.colorScheme.primaryContainer + MessageType.Gemini -> MaterialTheme.colorScheme.secondaryContainer + MessageType.Error -> MaterialTheme.colorScheme.errorContainer + } + + val bubbleShape = if (isGeminiMessage) { + RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp) + } else { + RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp) + } + + val horizontalAlignment = if (isGeminiMessage) { + Alignment.Start + } else { + Alignment.End + } + + Column( + horizontalAlignment = horizontalAlignment, + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 4.dp) + .fillMaxWidth() + ) { + Text( + text = messageItem.type.name, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 4.dp) + ) + Row { + if (messageItem.isPending) { + CircularProgressIndicator( + modifier = Modifier + .align(alignment = Alignment.CenterVertically) + .padding(all = 8.dp) + ) + } + BoxWithConstraints { + Card( + colors = CardDefaults.cardColors(containerColor = backgroundColor), + shape = bubbleShape, + modifier = Modifier.widthIn(min = 0.dp, max = maxWidth * 0.9f) + ) { + SelectionContainer { + Text( + text = messageItem.text, + modifier = Modifier.padding(16.dp) + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/levi/lin/gemini/android/viewmodel/GeminiViewModel.kt b/app/src/main/java/levi/lin/gemini/android/viewmodel/GeminiViewModel.kt index a01b6a9..f17b3c3 100644 --- a/app/src/main/java/levi/lin/gemini/android/viewmodel/GeminiViewModel.kt +++ b/app/src/main/java/levi/lin/gemini/android/viewmodel/GeminiViewModel.kt @@ -4,6 +4,8 @@ import android.graphics.Bitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.ai.client.generativeai.GenerativeModel +import com.google.ai.client.generativeai.type.Content +import com.google.ai.client.generativeai.type.asTextOrNull import com.google.ai.client.generativeai.type.content import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -13,20 +15,16 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import levi.lin.gemini.android.BuildConfig -import levi.lin.gemini.android.ui.state.GeminiUiState +import levi.lin.gemini.android.model.MessageItem +import levi.lin.gemini.android.model.MessageType +import levi.lin.gemini.android.state.MessageState import java.util.Locale class GeminiViewModel( private var generativeModel: GenerativeModel ) : ViewModel() { - - private val _uiState: MutableStateFlow = - MutableStateFlow(GeminiUiState.Initial) - val uiState: StateFlow = - _uiState.asStateFlow() - - private val _selectedImageBitmaps = MutableStateFlow>(emptyList()) - val selectedImageBitmaps: StateFlow> = _selectedImageBitmaps.asStateFlow() + private val _selectedImageList = MutableStateFlow>(emptyList()) + val selectedImageList: StateFlow> = _selectedImageList.asStateFlow() private val _selectedImageCount = MutableStateFlow(value = 0) val selectedImageCount: StateFlow = _selectedImageCount.asStateFlow() @@ -34,14 +32,41 @@ class GeminiViewModel( private val _generativeModelFlow = MutableSharedFlow() val generativeModelFlow: SharedFlow = _generativeModelFlow.asSharedFlow() - fun respond(inputText: String) { - _uiState.value = GeminiUiState.Loading + private val _scrollToLatestMessageEvent = MutableSharedFlow(extraBufferCapacity = 1) + val scrollToLatestMessageEvent: SharedFlow = _scrollToLatestMessageEvent.asSharedFlow() + + // Chat with Gemini + private var chat = generativeModel.startChat(history = emptyList()) + + private val _messageState: MutableStateFlow = + MutableStateFlow( + MessageState( + chat.history.map { content -> + MessageItem( + text = content.parts.first().asTextOrNull() ?: "", + type = if (content.role == "user") MessageType.User else MessageType.Gemini, + isPending = false + ) + } + ) + ) + val messageState: StateFlow = _messageState.asStateFlow() + fun sendMessage(inputMessage: String) { val deviceLanguage = Locale.getDefault().displayLanguage - val prompt = - "$inputText (respond in $deviceLanguage)" - val imageList = selectedImageBitmaps.value - val inputContent = content { + val imageList = selectedImageList.value + val displayMessage = when { + imageList.isNotEmpty() && inputMessage.isNotBlank() -> "$inputMessage\n\uD83D\uDDBC\uFE0F :${_selectedImageCount.value}" + imageList.isNotEmpty() -> "\uD83D\uDDBC\uFE0F :${_selectedImageCount.value}" + else -> inputMessage + } + val prompt = if (imageList.isNotEmpty() && inputMessage.isBlank()) { + "Describe all the images provided." + } else { + "$inputMessage (respond in $deviceLanguage)" + } + + val inputContent = content(role = "user") { if (imageList.isNotEmpty()) { imageList.forEach { image -> image(image = image) @@ -50,38 +75,83 @@ class GeminiViewModel( text(text = prompt) } + // User's message + val inputMessageItem = MessageItem( + text = displayMessage.trim(), + type = MessageType.User, + isPending = true + ) + _messageState.value.addMessage(message = inputMessageItem) + viewModelScope.launch { try { - val response = generativeModel.generateContent(inputContent) + val response = if (imageList.isNotEmpty()) { + generativeModel.generateContent(inputContent) + } else { + chat.sendMessage(prompt = inputContent) + } + _messageState.value.replaceLastMessage() + response.text?.let { outputContent -> - _uiState.value = GeminiUiState.Success(outputContent) + _messageState.value.addMessage( + message = MessageItem( + text = outputContent.trim(), + type = MessageType.Gemini, + isPending = false + ) + ) } } catch (e: Exception) { - _uiState.value = GeminiUiState.Error(errorMessage = e.localizedMessage ?: "") + _messageState.value.replaceLastMessage() + _messageState.value.addMessage( + message = MessageItem( + text = e.localizedMessage ?: "", + type = MessageType.Error, + isPending = false + ) + ) } + _messageState.value.updateMessagePendingStatus(messageId = inputMessageItem.id, isPending = false) + _scrollToLatestMessageEvent.emit(Unit) } } - fun setImageBitmaps(images: List) { - _selectedImageBitmaps.value = images + fun setImageList(imageList: List) { + _selectedImageList.value = imageList } fun setImageCount(count: Int) { _selectedImageCount.value = count } - fun clearSelectedImages() { - _selectedImageBitmaps.value = emptyList() + fun clearImageList() { + _selectedImageList.value = emptyList() _selectedImageCount.value = 0 } suspend fun updateGenerativeModel(targetModelName: String) { if (generativeModel.modelName != targetModelName) { + val currentHistory = getCurrentHistory() + generativeModel = GenerativeModel( modelName = targetModelName, apiKey = BuildConfig.apiKey ) + + restartChatWithHistory(currentHistory) _generativeModelFlow.emit(generativeModel) } } + + private fun getCurrentHistory(): List { + return _messageState.value.messageItems.map { messageItem -> + content(role = if (messageItem.type == MessageType.User) "user" else "model") { + text(messageItem.text) + } + } + } + + private fun restartChatWithHistory(history: List) { + chat = generativeModel.startChat(history = history) + } } \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 6d14b2f..f9e12b2 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,4 @@ -