diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt index dfe1783..b2f8319 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatPanel.kt @@ -16,15 +16,10 @@ package com.google.ai.edge.gallery.ui.common.chat -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalSharedTransitionApi -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image +import android.graphics.Bitmap import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,7 +29,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -45,13 +39,10 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Timer -import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SnackbarHost @@ -62,7 +53,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -73,16 +63,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType 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.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -92,7 +78,6 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.R import com.google.ai.edge.gallery.data.Model @@ -115,7 +100,7 @@ enum class ChatInputType { /** * Composable function for the main chat panel, displaying messages and handling user input. */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatPanel( modelManagerViewModel: ModelManagerViewModel, @@ -130,6 +115,7 @@ fun ChatPanel( onStreamImageMessage: (Model, ChatMessageImage) -> Unit = { _, _ -> }, onStreamEnd: (Int) -> Unit = {}, onStopButtonClicked: () -> Unit = {}, + onImageSelected: (Bitmap) -> Unit = {}, chatInputType: ChatInputType = ChatInputType.TEXT, showStopButtonInInputWhenInProgress: Boolean = false, ) { @@ -140,7 +126,6 @@ fun ChatPanel( val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current - var selectedImageMessage by remember { mutableStateOf(null) } val hasImageMessageToLastConfigChange = remember(messages) { var foundImageMessage = false for (message in messages.reversed()) { @@ -278,7 +263,7 @@ fun ChatPanel( bottom = 6.dp, ), horizontalAlignment = hAlign, - ) { + ) messageColumn@{ // Sender row. MessageSender( message = message, @@ -316,7 +301,8 @@ fun ChatPanel( var messageBubbleModifier = Modifier .clip( MessageBubbleShape( - radius = bubbleBorderRadius, hardCornerAtLeftOrRight = hardCornerAtLeftOrRight + radius = bubbleBorderRadius, + hardCornerAtLeftOrRight = hardCornerAtLeftOrRight ) ) .background(backgroundColor) @@ -340,9 +326,12 @@ fun ChatPanel( // Image is ChatMessageImage -> { - MessageBodyImage(message = message, modifier = Modifier.clickable { - selectedImageMessage = message - }) + MessageBodyImage( + message = message, modifier = Modifier + .clickable { + onImageSelected(message.bitmap) + } + ) } // Image with history (for image gen) @@ -534,47 +523,6 @@ fun ChatPanel( } } - // A full-screen image viewer. - val curSelectedImageMessage = selectedImageMessage - AnimatedVisibility( - visible = curSelectedImageMessage != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - if (curSelectedImageMessage == null) return@AnimatedVisibility - - ZoomableBox( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.9f)) - ) { - // Image. - Image( - bitmap = curSelectedImageMessage.imageBitMap, - contentDescription = "", - modifier = modifier - .fillMaxSize() - .graphicsLayer( - scaleX = scale, scaleY = scale, translationX = offsetX, translationY = offsetY - ), - contentScale = ContentScale.Fit, - ) - - // Close button. - IconButton( - onClick = { - selectedImageMessage = null - }, colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), modifier = Modifier.offset(x = (-8).dp, y = 8.dp) - ) { - Icon( - Icons.Rounded.Close, contentDescription = "", tint = MaterialTheme.colorScheme.primary - ) - } - } - } - // Error dialog. if (showErrorDialog) { ErrorDialog(error = modelInitializationStatus?.error ?: "", onDismiss = { @@ -636,47 +584,6 @@ fun ChatPanel( } } -@Composable -fun ZoomableBox( - modifier: Modifier = Modifier, - minScale: Float = 1f, - maxScale: Float = 5f, - content: @Composable ZoomableBoxScope.() -> Unit -) { - var scale by remember { mutableFloatStateOf(1f) } - var offsetX by remember { mutableFloatStateOf(0f) } - var offsetY by remember { mutableFloatStateOf(0f) } - var size by remember { mutableStateOf(IntSize.Zero) } - Box(modifier = modifier - .clip(RectangleShape) - .onSizeChanged { size = it } - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - scale = maxOf(minScale, minOf(scale * zoom, maxScale)) - val maxX = (size.width * (scale - 1)) / 2 - val minX = -maxX - offsetX = maxOf(minX, minOf(maxX, offsetX + pan.x)) - val maxY = (size.height * (scale - 1)) / 2 - val minY = -maxY - offsetY = maxOf(minY, minOf(maxY, offsetY + pan.y)) - } - }, contentAlignment = Alignment.TopEnd - ) { - val scope = ZoomableBoxScopeImpl(scale, offsetX, offsetY) - scope.content() - } -} - -interface ZoomableBoxScope { - val scale: Float - val offsetX: Float - val offsetY: Float -} - -private data class ZoomableBoxScopeImpl( - override val scale: Float, override val offsetX: Float, override val offsetY: Float -) : ZoomableBoxScope - @Preview(showBackground = true) @Composable fun ChatPanelPreview() { diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatView.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatView.kt index d623fd3..71c0d6d 100644 --- a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatView.kt +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ChatView.kt @@ -16,15 +16,28 @@ package com.google.ai.edge.gallery.ui.common.chat +import android.graphics.Bitmap import android.util.Log import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable @@ -37,9 +50,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.google.ai.edge.gallery.data.Model import com.google.ai.edge.gallery.data.ModelDownloadStatusType import com.google.ai.edge.gallery.data.Task @@ -84,9 +101,10 @@ fun ChatView( val uiState by viewModel.uiState.collectAsState() val modelManagerUiState by modelManagerViewModel.uiState.collectAsState() val selectedModel = modelManagerUiState.selectedModel + var selectedImage by remember { mutableStateOf(null) } + var showImageViewer by remember { mutableStateOf(false) } - val pagerState = rememberPagerState( - initialPage = task.models.indexOf(selectedModel), + val pagerState = rememberPagerState(initialPage = task.models.indexOf(selectedModel), pageCount = { task.models.size }) val context = LocalContext.current val scope = rememberCoroutineScope() @@ -139,8 +157,7 @@ fun ChatView( LaunchedEffect(pagerState) { snapshotFlow { PagerScrollState( - page = pagerState.currentPage, - offset = pagerState.currentPageOffsetFraction + page = pagerState.currentPage, offset = pagerState.currentPageOffsetFraction ) }.collect { scrollState -> modelManagerViewModel.pagerScrollState.value = scrollState @@ -164,9 +181,7 @@ fun ChatView( onResetSessionClicked = onResetSessionClicked, onConfigChanged = { old, new -> viewModel.addConfigChangedMessage( - oldConfigValues = old, - newConfigValues = new, - model = selectedModel + oldConfigValues = old, newConfigValues = new, model = selectedModel ) }, onBackClicked = { @@ -197,9 +212,7 @@ fun ChatView( .background(MaterialTheme.colorScheme.surface) ) { ModelDownloadStatusInfoPanel( - model = curSelectedModel, - task = task, - modelManagerViewModel = modelManagerViewModel + model = curSelectedModel, task = task, modelManagerViewModel = modelManagerViewModel ) // The main messages panel. @@ -223,6 +236,10 @@ fun ChatView( onStopButtonClicked = { onStopButtonClicked(curSelectedModel) }, + onImageSelected = { bitmap -> + selectedImage = bitmap + showImageViewer = true + }, modifier = Modifier .weight(1f) .graphicsLayer { alpha = curAlpha }, @@ -232,6 +249,49 @@ fun ChatView( } } } + + // Image viewer. + AnimatedVisibility( + visible = showImageViewer, + enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(), + exit = slideOutVertically( + targetOffsetY = { fullHeight -> fullHeight }, + ) + fadeOut() + ) { + selectedImage?.let { image -> + ZoomableBox( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()) + .background(Color.Black.copy(alpha = 0.95f)), + ) { + Image( + bitmap = image.asImageBitmap(), contentDescription = "", + modifier = modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, scaleY = scale, translationX = offsetX, translationY = offsetY + ), + contentScale = ContentScale.Fit, + ) + + // Close button. + IconButton( + onClick = { + showImageViewer = false + }, colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), modifier = Modifier.offset(x = (-8).dp, y = 8.dp) + ) { + Icon( + Icons.Rounded.Close, + contentDescription = "", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } } } } diff --git a/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ZoomableBox.kt b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ZoomableBox.kt new file mode 100644 index 0000000..81b2a1a --- /dev/null +++ b/Android/src/app/src/main/java/com/google/ai/edge/gallery/ui/common/chat/ZoomableBox.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.ai.edge.gallery.ui.common.chat + +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize + +@Composable +fun ZoomableBox( + modifier: Modifier = Modifier, + minScale: Float = 1f, + maxScale: Float = 5f, + content: @Composable ZoomableBoxScope.() -> Unit +) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + var size by remember { mutableStateOf(IntSize.Zero) } + Box(modifier = modifier + .onSizeChanged { size = it } + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = maxOf(minScale, minOf(scale * zoom, maxScale)) + val maxX = (size.width * (scale - 1)) / 2 + val minX = -maxX + offsetX = maxOf(minX, minOf(maxX, offsetX + pan.x)) + val maxY = (size.height * (scale - 1)) / 2 + val minY = -maxY + offsetY = maxOf(minY, minOf(maxY, offsetY + pan.y)) + } + }, contentAlignment = Alignment.TopEnd + ) { + val scope = ZoomableBoxScopeImpl(scale, offsetX, offsetY) + scope.content() + } +} + +interface ZoomableBoxScope { + val scale: Float + val offsetX: Float + val offsetY: Float +} + +private data class ZoomableBoxScopeImpl( + override val scale: Float, override val offsetX: Float, override val offsetY: Float +) : ZoomableBoxScope