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
:--: | :--:
-
|
-
|
+
|
+
|
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 @@
-
\ No newline at end of file
diff --git a/app/src/test/java/levi/lin/gemini/android/ExampleUnitTest.kt b/app/src/test/java/levi/lin/gemini/android/ExampleUnitTest.kt
index 7333708..0acc257 100644
--- a/app/src/test/java/levi/lin/gemini/android/ExampleUnitTest.kt
+++ b/app/src/test/java/levi/lin/gemini/android/ExampleUnitTest.kt
@@ -1,7 +1,6 @@
package levi.lin.gemini.android
import org.junit.Test
-
import org.junit.Assert.*
/**