diff --git a/ai-catalog/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt b/ai-catalog/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt index 8ee92cdd..0be222a8 100644 --- a/ai-catalog/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt +++ b/ai-catalog/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt @@ -103,7 +103,7 @@ val sampleCatalog = listOf( description = R.string.imagen_editing_sample_description, route = "ImagenMaskEditing", sampleEntryScreen = { ImagenEditingScreen() }, - tags = listOf(SampleTags.IMAGEN, SampleTags.FIREBASE, SampleTags.MEDIA3), + tags = listOf(SampleTags.IMAGEN, SampleTags.FIREBASE), needsFirebase = true, keyArt = R.drawable.img_keyart_imagen, ), diff --git a/ai-catalog/samples/imagen-editing/build.gradle.kts b/ai-catalog/samples/imagen-editing/build.gradle.kts index 8191899e..bf26d7b5 100644 --- a/ai-catalog/samples/imagen-editing/build.gradle.kts +++ b/ai-catalog/samples/imagen-editing/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(libs.hilt.navigation.compose) implementation(libs.androidx.runtime.livedata) implementation(libs.ui.tooling.preview) + implementation(project(":ui-component")) debugImplementation(libs.ui.tooling) ksp(libs.hilt.compiler) diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGeneratedContent.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGeneratedContent.kt deleted file mode 100644 index 6f1aae15..00000000 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGeneratedContent.kt +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright 2025 The Android Open Source Project - * - * 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 - * - * https://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.android.ai.samples.imagenediting.ui - -import android.graphics.Bitmap -import android.graphics.Canvas as AndroidCanvas -import android.graphics.Paint as AndroidPaint -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -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.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.asAndroidPath -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.core.graphics.createBitmap -import com.android.ai.samples.imagenediting.R - -@Composable -fun ImagenEditingGeneratedContent( - uiState: ImagenEditingUIState, - modifier: Modifier = Modifier, - onImageClick: (Bitmap) -> Unit = {}, - onMaskFinalized: (source: Bitmap, mask: Bitmap) -> Unit, -) { - var currentDrawingPath by remember { mutableStateOf(Path()) } - var pathVersion by remember { mutableIntStateOf(0) } - var bitmapToMask by remember { mutableStateOf(null) } - - Box( - modifier = modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant), - contentAlignment = Alignment.Center, - ) { - when (uiState) { - ImagenEditingUIState.Initial -> { - Text( - text = stringResource(R.string.editing_placeholder_prompt_entry), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp), - ) - currentDrawingPath = Path() - pathVersion++ - bitmapToMask = null - } - - ImagenEditingUIState.Loading -> { - CircularProgressIndicator() - currentDrawingPath = Path() - pathVersion++ - bitmapToMask = null - } - - is ImagenEditingUIState.ImageGenerated -> { - // Set the bitmap that can be masked - bitmapToMask = uiState.bitmap - Image( - bitmap = uiState.bitmap.asImageBitmap(), - contentDescription = uiState.contentDescription, - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxSize() - .clickable { - currentDrawingPath = Path() - pathVersion++ - onImageClick(uiState.bitmap) - }, - ) - - DrawingCanvas( - currentDrawingPath = currentDrawingPath, - pathVersion = pathVersion, - onPathUpdate = { newPath, newVersion -> - currentDrawingPath = newPath - pathVersion = newVersion - }, - modifier = Modifier.fillMaxSize(), - ) - bitmapToMask?.let { currentSourceBitmap -> - Button( - onClick = { - val maskBitmap = createMaskBitmap( - currentSourceBitmap.width, - currentSourceBitmap.height, - currentDrawingPath, - ) - onMaskFinalized(currentSourceBitmap, maskBitmap) - // Optionally reset the path after finalizing - currentDrawingPath = Path() - pathVersion++ - }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(16.dp), - enabled = !currentDrawingPath.isEmpty, - ) { - Text(stringResource(R.string.editing_finalize_mask_button)) - } - } - } - - is ImagenEditingUIState.ImageMasked -> { - bitmapToMask = null - - Box(modifier = Modifier.fillMaxSize()) { - Image( - bitmap = uiState.originalBitmap.asImageBitmap(), - contentDescription = uiState.contentDescription, - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxSize() - .clickable { - bitmapToMask = uiState.originalBitmap - currentDrawingPath = Path() - pathVersion++ - onImageClick(uiState.originalBitmap) - }, - ) - Image( - bitmap = uiState.maskBitmap.asImageBitmap(), - contentDescription = "Mask Overlay", - contentScale = ContentScale.Fit, - modifier = Modifier - .fillMaxSize() - .graphicsLayer(alpha = 0.5f), - ) - } - } - - is ImagenEditingUIState.Error -> { - Text( - text = uiState.message ?: stringResource(R.string.editing_error_message_unknown), - modifier = Modifier - .fillMaxSize() - .wrapContentSize(Alignment.Center), - textAlign = TextAlign.Center, - ) - currentDrawingPath = Path() - pathVersion++ - bitmapToMask = null - } - } - } -} - -@Composable -private fun DrawingCanvas(currentDrawingPath: Path, pathVersion: Int, onPathUpdate: (Path, Int) -> Unit, modifier: Modifier = Modifier) { - var internalPath by remember(pathVersion) { mutableStateOf(currentDrawingPath) } - var internalVersion by remember { mutableIntStateOf(pathVersion) } - val pathToDraw = remember(internalVersion) { internalPath } - - Canvas( - modifier = modifier - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { offset -> - internalPath = Path().apply { moveTo(offset.x, offset.y) } - internalVersion++ - onPathUpdate(internalPath, internalVersion) - }, - onDrag = { change, _ -> - internalPath.lineTo(change.position.x, change.position.y) - internalVersion++ - onPathUpdate(internalPath, internalVersion) - change.consume() - }, - ) - }, - ) { - if (!pathToDraw.isEmpty) { - drawPath( - path = pathToDraw, - color = Color.White.copy(alpha = 0.7f), - style = Stroke( - width = 40f, - cap = StrokeCap.Round, - join = StrokeJoin.Round, - ), - ) - } - } -} -private fun createMaskBitmap(width: Int, height: Int, composePath: Path?): Bitmap { - val maskBitmap = createBitmap(width, height) - val canvas = AndroidCanvas(maskBitmap) - canvas.drawColor(android.graphics.Color.BLACK) - - composePath?.let { - if (!it.isEmpty) { - val androidPath = it.asAndroidPath() - val paint = AndroidPaint().apply { - color = android.graphics.Color.WHITE // Drawn area is white in the mask - isAntiAlias = true - style = AndroidPaint.Style.STROKE - strokeWidth = 40f - strokeCap = AndroidPaint.Cap.ROUND - strokeJoin = AndroidPaint.Join.ROUND - } - canvas.drawPath(androidPath, paint) - } - } - return maskBitmap -} diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGenerationInput.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGenerationInput.kt deleted file mode 100644 index 47bbc20d..00000000 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingGenerationInput.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2025 The Android Open Source Project - * - * 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 - * - * https://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. - */ -@file:Suppress("ktlint:standard:import-ordering") - -package com.android.ai.samples.imagenediting.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AutoFixHigh // Icon for Inpaint/Edit -import androidx.compose.material.icons.filled.SmartToy -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.unit.dp -import com.android.ai.samples.imagenediting.R - -@Composable -fun GenerationInput( - uiState: ImagenEditingUIState, - onGenerateClick: (String) -> Unit, - onInpaintClick: (prompt: String) -> Unit, - enabled: Boolean, - modifier: Modifier = Modifier, -) { - val placeholder = stringResource(R.string.editing_placeholder_prompt_entry) - var promptTextField by rememberSaveable { mutableStateOf(placeholder) } - - val canInpaint = uiState is ImagenEditingUIState.ImageMasked && enabled - - val canGenerate = uiState !is ImagenEditingUIState.ImageMasked && enabled - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - TextField( - value = promptTextField, - onValueChange = { promptTextField = it }, - label = { Text(stringResource(R.string.editing_prompt_label)) }, - modifier = Modifier.fillMaxWidth(), - enabled = enabled, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions( - onSend = { - if (uiState is ImagenEditingUIState.ImageMasked) { - if (canInpaint) onInpaintClick(promptTextField) - } else { - if (canGenerate) onGenerateClick(promptTextField) - } - }, - ), - ) - - if (uiState !is ImagenEditingUIState.ImageMasked) { - Button( - onClick = { - onGenerateClick(promptTextField) - }, - enabled = canGenerate, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - Icons.Default.SmartToy, - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.editing_generate_button)) - } - } - - if (uiState is ImagenEditingUIState.ImageMasked) { - Button( - onClick = { - onInpaintClick(promptTextField) - }, - enabled = canInpaint, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - Icons.Default.AutoFixHigh, // Using a different icon for inpainting - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize), - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(text = stringResource(R.string.editing_inpaint_button)) - } - } - } -} diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingMaskEditor.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingMaskEditor.kt index a172939a..6d66f321 100644 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingMaskEditor.kt +++ b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingMaskEditor.kt @@ -19,16 +19,22 @@ import android.graphics.Bitmap import android.graphics.Paint import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.Text +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -103,7 +109,7 @@ fun ImagenEditingMaskEditor(sourceBitmap: Bitmap, onMaskFinalized: (Bitmap) -> U bitmap = sourceBitmap.asImageBitmap(), contentDescription = stringResource(R.string.editing_image_to_mask), modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, + contentScale = ContentScale.Crop, ) Canvas(modifier = Modifier.fillMaxSize()) { val canvasWidth = size.width @@ -130,36 +136,51 @@ fun ImagenEditingMaskEditor(sourceBitmap: Bitmap, onMaskFinalized: (Bitmap) -> U } } } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - Button(onClick = { if (paths.isNotEmpty()) paths.removeAt(paths.lastIndex) }, enabled = paths.isNotEmpty()) { - Text("Undo") - } - Button(onClick = onCancel) { - Text("Cancel") - } - Button( - onClick = { - val maskBitmap = createBitmap(sourceBitmap.width, sourceBitmap.height) - val canvas = android.graphics.Canvas(maskBitmap) - val paint = Paint().apply { - color = android.graphics.Color.WHITE - strokeWidth = 70f - style = Paint.Style.STROKE - strokeCap = Paint.Cap.ROUND - strokeJoin = Paint.Join.ROUND - isAntiAlias = true - } - paths.forEach { path -> canvas.drawPath(path.asAndroidPath(), paint) } - onMaskFinalized(maskBitmap) - }, + + Row( + modifier = Modifier + .padding(16.dp) + .align(Alignment.BottomEnd) + .background(color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(20.dp)), ) { - Text("Finalize Mask") + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.cancel_masking), + modifier = Modifier + .padding(10.dp) + .clickable(true) { + onCancel() + }, + ) + Icon( + Icons.AutoMirrored.Filled.Undo, + contentDescription = stringResource(R.string.undo_the_mask), + modifier = Modifier + .padding(10.dp) + .clickable(true) { + if (paths.isNotEmpty()) paths.removeAt(paths.lastIndex) + }, + ) + Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.save_the_mask), + modifier = Modifier + .padding(10.dp) + .clickable(true) { + val maskBitmap = createBitmap(sourceBitmap.width, sourceBitmap.height) + val canvas = android.graphics.Canvas(maskBitmap) + val paint = Paint().apply { + color = android.graphics.Color.WHITE + strokeWidth = 70f + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + isAntiAlias = true + } + paths.forEach { path -> canvas.drawPath(path.asAndroidPath(), paint) } + onMaskFinalized(maskBitmap) + }, + ) } } } diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingScreen.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingScreen.kt index 65ea7dde..d76ff549 100644 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingScreen.kt +++ b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingScreen.kt @@ -16,44 +16,56 @@ package com.android.ai.samples.imagenediting.ui import android.graphics.Bitmap -import androidx.activity.compose.BackHandler +import android.graphics.BitmapFactory +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.BoxScope +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.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.ai.samples.imagenediting.R +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.TextInput @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,171 +74,215 @@ fun ImagenEditingScreen(viewModel: ImagenEditingViewModel = hiltViewModel()) { val showMaskEditor: Boolean by viewModel.showMaskEditor.collectAsStateWithLifecycle() val bitmapForMasking: Bitmap? by viewModel.bitmapForMasking.collectAsStateWithLifecycle() - BackHandler(enabled = showMaskEditor) { - viewModel.onCancelMasking() - } - - Box(modifier = Modifier.fillMaxSize()) { - ImagenEditingScreenContent( - uiState = uiState, - showMaskEditor = showMaskEditor, - bitmapForMasking = bitmapForMasking, - onGenerateClick = viewModel::generateImage, - onInpaintClick = { source, mask, prompt -> viewModel.inpaintImage(source, mask, prompt) }, - onImageToMaskClicked = { bitmap -> viewModel.onStartMasking(bitmap) }, - onImageMaskReady = { source, mask -> viewModel.onImageMaskReady(source, mask) }, - onCancelMasking = viewModel::onCancelMasking, - modifier = Modifier.fillMaxSize(), - ) - } + ImagenEditingScreenContent( + uiState = uiState, + showMaskEditor = showMaskEditor, + bitmapForMasking = bitmapForMasking, + onGenerateClick = viewModel::generateImage, + onInpaintClick = { source, mask, prompt -> viewModel.inpaintImage(source, mask, prompt) }, + onImageMaskReady = { source, mask -> viewModel.onImageMaskReady(source, mask) }, + onCancelMasking = viewModel::onCancelMasking, + modifier = Modifier.fillMaxSize(), + ) } @Composable -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) private fun ImagenEditingScreenContent( uiState: ImagenEditingUIState, showMaskEditor: Boolean, bitmapForMasking: Bitmap?, onGenerateClick: (String) -> Unit, onInpaintClick: (source: Bitmap, mask: Bitmap, prompt: String) -> Unit, - onImageToMaskClicked: (Bitmap) -> Unit, onImageMaskReady: (source: Bitmap, mask: Bitmap) -> Unit, onCancelMasking: () -> Unit, modifier: Modifier = Modifier, ) { val isGenerating = uiState is ImagenEditingUIState.Loading + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher Scaffold( - modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, topBar = { - TopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - ), - title = { - Text(text = stringResource(R.string.editing_title_image_generation_screen)) - }, + SampleDetailTopAppBar( + sampleName = stringResource(R.string.editing_title_image_generation_title), + sampleDescription = stringResource(R.string.editing_title_image_generation_subtitle), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/imagen-editing", + onBackClick = { backDispatcher?.onBackPressed() }, ) }, + modifier = Modifier.fillMaxWidth(), ) { innerPadding -> - Column( - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) + val context = LocalContext.current + val imageBitmap = remember { + val bitmap = BitmapFactory.decodeResource(context.resources, com.android.ai.uicomponent.R.drawable.img_fill) + bitmap.asImageBitmap() + } + val imageShader = remember { + ImageShader( + image = imageBitmap, + tileModeX = TileMode.Repeated, + tileModeY = TileMode.Repeated, + ) + } + + Box( + modifier = Modifier .padding(innerPadding) - .padding(16.dp) - .imePadding(), + .fillMaxSize(), + contentAlignment = Alignment.Center, ) { - ImagenEditingGeneratedContent( - uiState = uiState, - showMaskEditor = showMaskEditor, - bitmapForMasking = bitmapForMasking, - onImageClick = { - if (uiState is ImagenEditingUIState.ImageGenerated) { - onImageToMaskClicked(it) - } - }, - onMaskFinalized = onImageMaskReady, - onCancelMasking = onCancelMasking, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f), - ) + Box( + Modifier + .padding(16.dp) + .imePadding() + .widthIn(max = 440.dp) + .fillMaxHeight(0.85f) + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(40.dp), + ) + .clip(RoundedCornerShape(40.dp)) + .background(ShaderBrush(imageShader)), + contentAlignment = Alignment.Center, + ) { + val keyboardController = LocalSoftwareKeyboardController.current + + when (uiState) { + is ImagenEditingUIState.Initial -> { + Text( + text = stringResource(R.string.generate_an_image_to_edit), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(24.dp) + .align(Alignment.Center), + ) - Spacer(modifier = Modifier.height(16.dp)) + val textFieldState = rememberTextFieldState() - GenerationInput( - uiState = uiState, - onGenerateClick = onGenerateClick, - onInpaintClick = { prompt -> - if (uiState is ImagenEditingUIState.ImageMasked) { - onInpaintClick(uiState.originalBitmap, uiState.maskBitmap, prompt) + TextField( + textFieldState, + isGenerating, + onGenerateClick, + keyboardController, + placeholder = stringResource(R.string.describe_the_image_to_generate), + ) } - }, - enabled = !isGenerating, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) - } - } -} + is ImagenEditingUIState.Loading -> { + Box(modifier.fillMaxSize()) { + ContainedLoadingIndicator( + modifier = Modifier + .size(60.dp) + .align(Alignment.Center), + ) + } + } -@Composable -fun ImagenEditingGeneratedContent( - uiState: ImagenEditingUIState, - showMaskEditor: Boolean, - bitmapForMasking: Bitmap?, - onImageClick: (Bitmap) -> Unit, - onMaskFinalized: (source: Bitmap, mask: Bitmap) -> Unit, - onCancelMasking: () -> Unit, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant), - contentAlignment = Alignment.Center, - ) { - if (showMaskEditor && bitmapForMasking != null) { - ImagenEditingMaskEditor( - sourceBitmap = bitmapForMasking, - onMaskFinalized = { maskBitmap -> - onMaskFinalized(bitmapForMasking, maskBitmap) - }, - onCancel = onCancelMasking, - modifier = Modifier.fillMaxSize(), - ) - } else { - when (uiState) { - is ImagenEditingUIState.Loading -> { - CircularProgressIndicator() - } + is ImagenEditingUIState.ImageGenerated -> { + if (showMaskEditor && bitmapForMasking != null) { + val textFieldState = rememberTextFieldState() - is ImagenEditingUIState.ImageGenerated -> { - Box(modifier = Modifier.fillMaxSize()) { - Image( - bitmap = uiState.bitmap.asImageBitmap(), - contentDescription = stringResource(R.string.editing_generated_image), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - ) - Button( - onClick = { onImageClick(uiState.bitmap) }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(16.dp), - ) { - Text(text = stringResource(R.string.editing_edit_mask_button)) + ImagenEditingMaskEditor( + sourceBitmap = bitmapForMasking, + onMaskFinalized = { maskBitmap -> + onImageMaskReady(bitmapForMasking, maskBitmap) + }, + onCancel = { onCancelMasking() }, + modifier = Modifier.fillMaxSize(), + ) + + Text( + text = "Draw a mask on the image", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(24.dp) + .align(Alignment.TopCenter) + .background(color = MaterialTheme.colorScheme.surfaceContainer), + ) + } else { + val textFieldState = rememberTextFieldState() + + Image( + bitmap = uiState.bitmap.asImageBitmap(), + contentDescription = uiState.contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + TextField( + textFieldState, + isGenerating, + onGenerateClick, + keyboardController, + placeholder = stringResource(R.string.describe_the_image_to_generate), + ) } } - } - is ImagenEditingUIState.ImageMasked -> { - Box(modifier = Modifier.fillMaxSize()) { - Image( - bitmap = uiState.originalBitmap.asImageBitmap(), - contentDescription = stringResource(R.string.editing_generated_image), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - ) - Image( - bitmap = uiState.maskBitmap.asImageBitmap(), - contentDescription = stringResource(R.string.editing_generated_mask), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(Color.Red.copy(alpha = 0.5f)), + is ImagenEditingUIState.ImageMasked -> { + Box(modifier = Modifier.fillMaxSize()) { + Image( + bitmap = uiState.originalBitmap.asImageBitmap(), + contentDescription = stringResource(R.string.editing_generated_image), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + Image( + bitmap = uiState.maskBitmap.asImageBitmap(), + contentDescription = stringResource(R.string.editing_generated_mask), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + colorFilter = ColorFilter.tint(Color.Red.copy(alpha = 0.5f)), + ) + } + val textFieldState = rememberTextFieldState() + + TextField( + textFieldState = textFieldState, + isGenerating = isGenerating, + onGenerateClick = { prompt -> onInpaintClick(uiState.originalBitmap, uiState.maskBitmap, prompt) }, + keyboardController, + placeholder = stringResource(R.string.describe_the_image_to_in_paint), ) } - } - - is ImagenEditingUIState.Error -> { - uiState.message?.let { Text(text = it) } - } - else -> { - Text(text = stringResource(R.string.editing_placeholder_prompt)) + else -> {} } } } } } + +@Composable +private fun BoxScope.TextField( + textFieldState: TextFieldState, + isGenerating: Boolean, + onGenerateClick: (String) -> Unit, + keyboardController: SoftwareKeyboardController?, + placeholder: String = "", +) { + TextInput( + state = textFieldState, + placeholder = placeholder, + primaryButton = { + GenerateButton( + text = "", + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_img), + modifier = Modifier + .width(72.dp) + .height(55.dp) + .padding(4.dp), + enabled = !isGenerating, + onClick = { + onGenerateClick(textFieldState.text.toString()) + keyboardController?.hide() + }, + ) + }, + modifier = Modifier + .widthIn(max = 646.dp) + .padding(start = 10.dp, end = 10.dp, bottom = 10.dp) + .align(Alignment.BottomCenter), + ) +} diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingSeeCodeButton.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingSeeCodeButton.kt deleted file mode 100644 index e69de29b..00000000 diff --git a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingViewModel.kt b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingViewModel.kt index f259b160..4dbb5c8b 100644 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingViewModel.kt +++ b/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingViewModel.kt @@ -16,6 +16,7 @@ package com.android.ai.samples.imagenediting.ui import android.graphics.Bitmap +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.ai.samples.imagenediting.data.ImagenEditingDataSource @@ -42,7 +43,11 @@ class ImagenEditingViewModel @Inject constructor(private val imagenDataSource: I viewModelScope.launch { try { val bitmap = imagenDataSource.generateImage(prompt) + + _bitmapForMasking.value = bitmap + _showMaskEditor.value = true _uiState.value = ImagenEditingUIState.ImageGenerated(bitmap, contentDescription = prompt) + } catch (e: Exception) { _uiState.value = ImagenEditingUIState.Error(e.message) } @@ -69,11 +74,6 @@ class ImagenEditingViewModel @Inject constructor(private val imagenDataSource: I } } - fun onStartMasking(bitmap: Bitmap) { - _bitmapForMasking.value = bitmap - _showMaskEditor.value = true - } - fun onImageMaskReady(originalBitmap: Bitmap, maskBitmap: Bitmap) { val originalContentDescription = (_uiState.value as? ImagenEditingUIState.ImageGenerated)?.contentDescription ?: "Edited image" _uiState.value = ImagenEditingUIState.ImageMasked( @@ -86,7 +86,9 @@ class ImagenEditingViewModel @Inject constructor(private val imagenDataSource: I } fun onCancelMasking() { + Log.d("ImagenEditingViewModel", "onCancelMasking") _showMaskEditor.value = false _bitmapForMasking.value = null + _uiState.value = ImagenEditingUIState.Initial } } diff --git a/ai-catalog/samples/imagen-editing/src/main/res/values/strings.xml b/ai-catalog/samples/imagen-editing/src/main/res/values/strings.xml index cfa2181b..4f7efff1 100644 --- a/ai-catalog/samples/imagen-editing/src/main/res/values/strings.xml +++ b/ai-catalog/samples/imagen-editing/src/main/res/values/strings.xml @@ -16,7 +16,6 @@ --> - See Code Generate Generating… Prompt @@ -29,9 +28,16 @@ Generate an image, then tap to draw a mask. An image of dog working as a chef An unknown error occurred. - Imagen Editing + Imagen Editing + Generate images with Imagen, Google\'s image generation model. Image to be masked The generated image The generated mask Draw a mask + Cancel masking + Undo the mask + Save the mask + describe the image to generate + Generate an image to edit + describe the image to in-paint \ No newline at end of file diff --git a/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/TextInput.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/TextInput.kt index 1ee4788a..32f29a9a 100644 --- a/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/TextInput.kt +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/TextInput.kt @@ -21,12 +21,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -37,7 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.android.ai.theme.AISampleCatalogTheme @@ -73,8 +71,8 @@ fun TextInput( placeholder = { Text( text = placeholder, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), ) }, lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = maxLines),