diff --git a/ai-catalog/app/build.gradle.kts b/ai-catalog/app/build.gradle.kts index e011b4ea..cb0cef6c 100644 --- a/ai-catalog/app/build.gradle.kts +++ b/ai-catalog/app/build.gradle.kts @@ -80,6 +80,7 @@ dependencies { implementation(libs.firebase.ai) ksp(libs.hilt.compiler) + implementation(project(":ui-component")) implementation(project(":samples:gemini-multimodal")) implementation(project(":samples:gemini-chatbot")) implementation(project(":samples:genai-summarization")) diff --git a/ai-catalog/app/src/main/AndroidManifest.xml b/ai-catalog/app/src/main/AndroidManifest.xml index c0b29615..2db16161 100644 --- a/ai-catalog/app/src/main/AndroidManifest.xml +++ b/ai-catalog/app/src/main/AndroidManifest.xml @@ -9,7 +9,6 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" - android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.AISampleCatalog" tools:targetApi="31" diff --git a/ai-catalog/app/src/main/java/com/android/ai/catalog/MainActivity.kt b/ai-catalog/app/src/main/java/com/android/ai/catalog/MainActivity.kt index 0b1eaf88..ad61c64c 100644 --- a/ai-catalog/app/src/main/java/com/android/ai/catalog/MainActivity.kt +++ b/ai-catalog/app/src/main/java/com/android/ai/catalog/MainActivity.kt @@ -22,7 +22,7 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import com.android.ai.catalog.ui.CatalogApp -import com.android.ai.catalog.ui.theme.AISampleCatalogTheme +import com.android.ai.theme.AISampleCatalogTheme import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint diff --git a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/domain/SampleCatalog.kt b/ai-catalog/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt similarity index 76% rename from ai-catalog/app/src/main/java/com/android/ai/catalog/ui/domain/SampleCatalog.kt rename to ai-catalog/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt index 43d2b9dd..0be222a8 100644 --- a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/domain/SampleCatalog.kt +++ b/ai-catalog/app/src/main/java/com/android/ai/catalog/domain/SampleCatalog.kt @@ -13,8 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.ai.catalog.ui.domain +package com.android.ai.catalog.domain +import android.Manifest +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresPermission import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color @@ -23,16 +26,17 @@ import com.android.ai.samples.geminichatbot.GeminiChatbotScreen import com.android.ai.samples.geminiimagechat.GeminiImageChatScreen import com.android.ai.samples.geminilivetodo.ui.TodoScreen import com.android.ai.samples.geminimultimodal.ui.GeminiMultimodalScreen -import com.android.ai.samples.geminivideometadatacreation.VideoMetadataCreationScreen -import com.android.ai.samples.geminivideosummary.VideoSummarizationScreen +import com.android.ai.samples.geminivideometadatacreation.ui.VideoMetadataCreationScreen +import com.android.ai.samples.geminivideosummary.ui.VideoSummarizationScreen import com.android.ai.samples.genai_image_description.GenAIImageDescriptionScreen import com.android.ai.samples.genai_summarization.GenAISummarizationScreen import com.android.ai.samples.genai_writing_assistance.GenAIWritingAssistanceScreen import com.android.ai.samples.imagen.ui.ImagenScreen import com.android.ai.samples.imagenediting.ui.ImagenEditingScreen import com.android.ai.samples.magicselfie.ui.MagicSelfieScreen +import com.android.ai.theme.extendedColorScheme -@androidx.annotation.RequiresPermission(android.Manifest.permission.RECORD_AUDIO) +@RequiresPermission(Manifest.permission.RECORD_AUDIO) val sampleCatalog = listOf( SampleCatalogItem( title = R.string.gemini_image_chat, @@ -49,6 +53,8 @@ val sampleCatalog = listOf( sampleEntryScreen = { GeminiMultimodalScreen() }, tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE), needsFirebase = true, + isFeatured = true, + keyArt = R.drawable.img_keyart_multimodal, ), SampleCatalogItem( title = R.string.gemini_chatbot_sample_title, @@ -57,6 +63,7 @@ val sampleCatalog = listOf( sampleEntryScreen = { GeminiChatbotScreen() }, tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE), needsFirebase = true, + keyArt = R.drawable.img_keyart_chatbot, ), SampleCatalogItem( title = R.string.genai_summarization_sample_title, @@ -64,6 +71,7 @@ val sampleCatalog = listOf( route = "GenAISummarizationScreen", sampleEntryScreen = { GenAISummarizationScreen() }, tags = listOf(SampleTags.GEMINI_NANO, SampleTags.ML_KIT), + keyArt = R.drawable.img_keyart_summary, ), SampleCatalogItem( title = R.string.genai_image_description_sample_title, @@ -71,6 +79,7 @@ val sampleCatalog = listOf( route = "GenAIImageDescriptionScreen", sampleEntryScreen = { GenAIImageDescriptionScreen() }, tags = listOf(SampleTags.GEMINI_NANO, SampleTags.ML_KIT), + keyArt = R.drawable.img_keyart_img_desc, ), SampleCatalogItem( title = R.string.genai_writing_assistance_sample_title, @@ -78,6 +87,7 @@ val sampleCatalog = listOf( route = "GenAIWritingAssistanceScreen", sampleEntryScreen = { GenAIWritingAssistanceScreen() }, tags = listOf(SampleTags.GEMINI_NANO, SampleTags.ML_KIT), + keyArt = R.drawable.img_keyart_text, ), SampleCatalogItem( title = R.string.imagen_sample_title, @@ -86,14 +96,16 @@ val sampleCatalog = listOf( sampleEntryScreen = { ImagenScreen() }, tags = listOf(SampleTags.IMAGEN, SampleTags.FIREBASE), needsFirebase = true, + keyArt = R.drawable.img_keyart_imagen, ), SampleCatalogItem( title = R.string.imagen_editing_sample_title, 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, ), SampleCatalogItem( title = R.string.magic_selfie_sample_title, @@ -102,6 +114,7 @@ val sampleCatalog = listOf( sampleEntryScreen = { MagicSelfieScreen() }, tags = listOf(SampleTags.IMAGEN, SampleTags.FIREBASE, SampleTags.ML_KIT), needsFirebase = true, + keyArt = R.drawable.img_keyart_magic_selfie, ), SampleCatalogItem( title = R.string.gemini_video_summarization_sample_title, @@ -118,6 +131,16 @@ val sampleCatalog = listOf( sampleEntryScreen = { VideoMetadataCreationScreen() }, tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE, SampleTags.MEDIA3), needsFirebase = true, + keyArt = R.drawable.img_keyart_video_summary, + ), + SampleCatalogItem( + title = R.string.gemini_video_metadata_creation_sample_title, + description = R.string.gemini_video_metadata_creation_sample_description, + route = "VideoMetadataCreationScreen", + sampleEntryScreen = { VideoMetadataCreationScreen() }, + tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE, SampleTags.MEDIA3), + needsFirebase = true, + keyArt = R.drawable.img_keyart_video_summary, ), SampleCatalogItem( title = R.string.gemini_live_todo_title, @@ -126,6 +149,7 @@ val sampleCatalog = listOf( sampleEntryScreen = { TodoScreen() }, tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE), needsFirebase = true, + keyArt = R.drawable.img_keyart_todo, ), // To create a new sample entry, add a new SampleCatalogItem here. @@ -138,17 +162,18 @@ data class SampleCatalogItem( val sampleEntryScreen: @Composable () -> Unit, val tags: List = emptyList(), val needsFirebase: Boolean = false, + val isFeatured: Boolean = false, + @DrawableRes val keyArt: Int? = null, ) enum class SampleTags( val label: String, val backgroundColor: Color, - val textColor: Color, ) { - FIREBASE("Firebase", Color(0xFFFF9100), Color.White), - GEMINI_FLASH("Gemini Flash", Color(0xFF4285F4), Color.White), - GEMINI_NANO("Gemini Nano", Color(0xFF7abafe), Color.White), - IMAGEN("Imagen", Color(0xFF7CB342), Color.White), - MEDIA3("Media3", Color(0xFF7CB584), Color.White), - ML_KIT("ML Kit", Color.White, Color(0xFF4285F4)), + FIREBASE("Firebase", extendedColorScheme.firebase), + GEMINI_FLASH("Gemini Flash", extendedColorScheme.geminiProFlash), + GEMINI_NANO("Gemini Nano", extendedColorScheme.geminiNano), + IMAGEN("Imagen", extendedColorScheme.imagen), + MEDIA3("Media3", extendedColorScheme.media3), + ML_KIT("ML Kit", extendedColorScheme.mLKit), } diff --git a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogApp.kt b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogApp.kt index 349493c3..813d638d 100644 --- a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogApp.kt +++ b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogApp.kt @@ -17,53 +17,57 @@ package com.android.ai.catalog.ui import android.content.Intent import android.util.Log +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.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TwoRowsTopAppBar +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.net.toUri import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.android.ai.catalog.R -import com.android.ai.catalog.ui.domain.SampleCatalogItem -import com.android.ai.catalog.ui.domain.sampleCatalog +import com.android.ai.catalog.domain.sampleCatalog import com.google.firebase.FirebaseApp import kotlinx.serialization.Serializable -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun CatalogApp(modifier: Modifier = Modifier) { val context = LocalContext.current val navController = rememberNavController() - var isDialogOpened by remember { mutableStateOf(false) } NavHost( @@ -71,35 +75,72 @@ fun CatalogApp(modifier: Modifier = Modifier) { startDestination = HomeScreen, ) { composable { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { - TopAppBar( + TwoRowsTopAppBar( colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, + containerColor = Color.Transparent, + scrolledContainerColor = MaterialTheme.colorScheme.surface, titleContentColor = MaterialTheme.colorScheme.primary, ), - title = { - Text(text = stringResource(id = R.string.top_bar_title)) + navigationIcon = { AppBarPill() }, + title = { expanded -> + if (expanded) { + Text( + text = stringResource(id = R.string.top_bar_title_expanded), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + modifier = Modifier.padding(bottom = 12.dp), + ) + } else { + Row { + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = stringResource(id = R.string.top_bar_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + } }, + scrollBehavior = scrollBehavior, ) }, ) { innerPadding -> + Image( + painter = painterResource(id = R.drawable.img_bg_landing), + contentDescription = "Background Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillWidth, + ) LazyColumn( contentPadding = innerPadding, + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, ) { items(sampleCatalog) { - CatalogListItem(catalogItem = it) { + val onClick = { if (it.needsFirebase && !isFirebaseInitialized()) { isDialogOpened = true } else { navController.navigate(it.route) } } + if (it.isFeatured) { + CatalogWideCard(catalogItem = it, onClick = onClick) + } else { + CatalogRowCard(catalogItem = it, onClick = onClick) + } } } } } - sampleCatalog.forEach { val catalogItem = it composable(catalogItem.route) { @@ -123,59 +164,27 @@ fun CatalogApp(modifier: Modifier = Modifier) { } } +@Serializable +object HomeScreen + @Composable -fun CatalogListItem(catalogItem: SampleCatalogItem, onButtonClick: () -> Unit) { - val context = LocalContext.current - ElevatedCard( - modifier = Modifier.padding(18.dp), - onClick = { - onButtonClick() - }, - ) { - Column( - Modifier.padding(15.dp), - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - text = context.getString(catalogItem.title), - ) - Text( - modifier = Modifier.padding(bottom = 8.dp), - text = context.getString(catalogItem.description), - ) - Row { - Spacer(Modifier.weight(1f)) - catalogItem.tags.forEach { - Spacer(Modifier.width(8.dp)) - Box( - modifier = Modifier - .background( - color = it.backgroundColor, - shape = RoundedCornerShape( - 8.dp, - ), - ) - .padding(start = 4.dp, end = 4.dp), - ) { - Text( - fontSize = 9.sp, - text = it.label, - color = it.textColor, - ) - } - } - } - } +fun AppBarPill() { + Row { + Spacer(Modifier.width(12.dp)) + Icon( + painter = painterResource(R.drawable.spark_android), + contentDescription = null, + modifier = Modifier.height(40.dp) + .width(58.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(24.dp), + ).padding(10.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) } } -@Serializable -object HomeScreen - @Composable fun FirebaseRequiredAlert(onDismiss: () -> Unit = {}, onOpenFirebaseDocClick: () -> Unit = {}) { AlertDialog( diff --git a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogRowCard.kt b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogRowCard.kt new file mode 100644 index 00000000..f607a0eb --- /dev/null +++ b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogRowCard.kt @@ -0,0 +1,116 @@ +/* + * 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.catalog.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.ai.catalog.R +import com.android.ai.catalog.domain.SampleCatalogItem +import com.android.ai.catalog.domain.SampleTags +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.uicomponent.Tag + +@Composable +fun CatalogRowCard(catalogItem: SampleCatalogItem, onClick: () -> Unit) { + ElevatedCard( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + ).widthIn(max = 646.dp), + onClick = onClick, + ) { + Row { + Image( + painter = painterResource(id = catalogItem.keyArt ?: R.drawable.img_keyart_multimodal), + contentDescription = null, + modifier = Modifier + .height(92.dp) + .width(92.dp) + .padding(top = 12.dp, start = 12.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) + Column { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 8.dp), + style = MaterialTheme.typography.titleMedium, + text = stringResource(catalogItem.title), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, bottom = 4.dp), + ) { + catalogItem.tags.forEach { + Tag(text = it.label, color = it.backgroundColor) + } + } + Text( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + style = MaterialTheme.typography.bodyMedium, + text = stringResource(catalogItem.description), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun CatalogRowCardPreview() { + AISampleCatalogTheme { + val sampleItem = SampleCatalogItem( + title = R.string.gemini_multimodal_sample_title, + description = R.string.gemini_multimodal_sample_description, + route = "GeminiMultimodalScreen", + sampleEntryScreen = { }, + tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE), + ) + + CatalogRowCard( + catalogItem = sampleItem, + onClick = { /* No-op for the preview */ }, + ) + } +} diff --git a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogWideCard.kt b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogWideCard.kt new file mode 100644 index 00000000..81c1f10e --- /dev/null +++ b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/CatalogWideCard.kt @@ -0,0 +1,110 @@ +/* + * 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.catalog.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.ai.catalog.R +import com.android.ai.catalog.domain.SampleCatalogItem +import com.android.ai.catalog.domain.SampleTags +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.uicomponent.Tag + +@Composable +fun CatalogWideCard(catalogItem: SampleCatalogItem, onClick: () -> Unit) { + ElevatedCard( + modifier = Modifier.padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + ).widthIn(max = 646.dp), + onClick = onClick, + shape = RoundedCornerShape( + topStart = 0.dp, + topEnd = 0.dp, + bottomEnd = 12.dp, + bottomStart = 12.dp, + ), + ) { + Column { + Image( + painter = painterResource(id = catalogItem.keyArt ?: R.drawable.img_keyart_multimodal), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(182.dp), + contentScale = ContentScale.FillWidth, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), + style = MaterialTheme.typography.headlineSmall, + text = stringResource(catalogItem.title), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + ) { + catalogItem.tags.forEach { + Tag(text = it.label, color = it.backgroundColor) + } + } + Text( + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + style = MaterialTheme.typography.bodyMedium, + text = stringResource(catalogItem.description), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun CatalogWideCardPreview() { + AISampleCatalogTheme { + val sampleItem = SampleCatalogItem( + title = R.string.gemini_multimodal_sample_title, + description = R.string.gemini_multimodal_sample_description, + route = "GeminiMultimodalScreen", + sampleEntryScreen = { }, + tags = listOf(SampleTags.GEMINI_FLASH, SampleTags.FIREBASE), + ) + + CatalogWideCard( + catalogItem = sampleItem, + onClick = {}, + ) + } +} diff --git a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/theme/Theme.kt b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/theme/Theme.kt deleted file mode 100644 index 17a2aac2..00000000 --- a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/theme/Theme.kt +++ /dev/null @@ -1,57 +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.catalog.ui.theme - -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext - -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80, -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40, -) - -@Composable -fun AISampleCatalogTheme(darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content, - ) -} diff --git a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/theme/Type.kt b/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/theme/Type.kt deleted file mode 100644 index f05a4403..00000000 --- a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/theme/Type.kt +++ /dev/null @@ -1,32 +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.catalog.ui.theme - -import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp - -val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp, - ), -) diff --git a/ai-catalog/app/src/main/res/drawable-hdpi/img_bg_landing.png b/ai-catalog/app/src/main/res/drawable-hdpi/img_bg_landing.png new file mode 100644 index 00000000..733503e0 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-hdpi/img_bg_landing.png differ diff --git a/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_chatbot.png b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_chatbot.png new file mode 100644 index 00000000..89e5fc1a Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_chatbot.png differ diff --git a/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_imagen.png b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_imagen.png new file mode 100644 index 00000000..a91f0f7a Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_imagen.png differ diff --git a/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_img_desc.png b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_img_desc.png new file mode 100644 index 00000000..1fef8f12 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_img_desc.png differ diff --git a/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_magic_selfie.png b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_magic_selfie.png new file mode 100644 index 00000000..aad425ef Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_magic_selfie.png differ diff --git a/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_metadata.png b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_metadata.png new file mode 100644 index 00000000..0d9db7cf Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_metadata.png differ diff --git a/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_multimodal.png b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_multimodal.png new file mode 100644 index 00000000..a9f28782 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_multimodal.png differ diff --git a/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_summary.png b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_summary.png new file mode 100644 index 00000000..816b59ec Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_summary.png differ diff --git a/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_text.png b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_text.png new file mode 100644 index 00000000..2e83764d Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_text.png differ diff --git a/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_todo.png b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_todo.png new file mode 100644 index 00000000..dfae9fa5 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_todo.png differ diff --git a/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_video_summary.png b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_video_summary.png new file mode 100644 index 00000000..d02ec750 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-hdpi/img_keyart_video_summary.png differ diff --git a/ai-catalog/app/src/main/res/drawable-mdpi/img_bg_landing.png b/ai-catalog/app/src/main/res/drawable-mdpi/img_bg_landing.png new file mode 100644 index 00000000..fc2acea5 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-mdpi/img_bg_landing.png differ diff --git a/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_chatbot.png b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_chatbot.png new file mode 100644 index 00000000..264b09fe Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_chatbot.png differ diff --git a/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_imagen.png b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_imagen.png new file mode 100644 index 00000000..02cd5093 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_imagen.png differ diff --git a/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_img_desc.png b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_img_desc.png new file mode 100644 index 00000000..05596514 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_img_desc.png differ diff --git a/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_magic_selfie.png b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_magic_selfie.png new file mode 100644 index 00000000..50387a3e Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_magic_selfie.png differ diff --git a/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_metadata.png b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_metadata.png new file mode 100644 index 00000000..fcf22360 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_metadata.png differ diff --git a/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_multimodal.png b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_multimodal.png new file mode 100644 index 00000000..328f62ff Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_multimodal.png differ diff --git a/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_summary.png b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_summary.png new file mode 100644 index 00000000..28afe6fb Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_summary.png differ diff --git a/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_text.png b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_text.png new file mode 100644 index 00000000..55b3f304 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_text.png differ diff --git a/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_todo.png b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_todo.png new file mode 100644 index 00000000..fee79383 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_todo.png differ diff --git a/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_video_summary.png b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_video_summary.png new file mode 100644 index 00000000..988a525c Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-mdpi/img_keyart_video_summary.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xhdpi/img_bg_landing.png b/ai-catalog/app/src/main/res/drawable-xhdpi/img_bg_landing.png new file mode 100644 index 00000000..309675c6 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xhdpi/img_bg_landing.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_chatbot.png b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_chatbot.png new file mode 100644 index 00000000..59020533 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_chatbot.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_imagen.png b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_imagen.png new file mode 100644 index 00000000..e90cd843 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_imagen.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_img_desc.png b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_img_desc.png new file mode 100644 index 00000000..c2d6556f Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_img_desc.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_magic_selfie.png b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_magic_selfie.png new file mode 100644 index 00000000..4cff91d5 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_magic_selfie.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_metadata.png b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_metadata.png new file mode 100644 index 00000000..573116e9 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_metadata.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_multimodal.png b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_multimodal.png new file mode 100644 index 00000000..54041de1 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_multimodal.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_summary.png b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_summary.png new file mode 100644 index 00000000..705e6505 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_summary.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_text.png b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_text.png new file mode 100644 index 00000000..84fa4915 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_text.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_todo.png b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_todo.png new file mode 100644 index 00000000..0c202568 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_todo.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_video_summary.png b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_video_summary.png new file mode 100644 index 00000000..92ed8524 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xhdpi/img_keyart_video_summary.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxhdpi/img_bg_landing.png b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_bg_landing.png new file mode 100644 index 00000000..e43eaeea Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_bg_landing.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_chatbot.png b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_chatbot.png new file mode 100644 index 00000000..cb8bc576 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_chatbot.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_imagen.png b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_imagen.png new file mode 100644 index 00000000..078d428b Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_imagen.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_img_desc.png b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_img_desc.png new file mode 100644 index 00000000..521081de Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_img_desc.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_magic_selfie.png b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_magic_selfie.png new file mode 100644 index 00000000..18c19bf8 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_magic_selfie.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_metadata.png b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_metadata.png new file mode 100644 index 00000000..fee2b17f Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_metadata.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_multimodal.png b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_multimodal.png new file mode 100644 index 00000000..d7fea5e1 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_multimodal.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_summary.png b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_summary.png new file mode 100644 index 00000000..95682be5 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_summary.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_text.png b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_text.png new file mode 100644 index 00000000..2fce2d93 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_text.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_todo.png b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_todo.png new file mode 100644 index 00000000..128da7a7 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_todo.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_video_summary.png b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_video_summary.png new file mode 100644 index 00000000..63b8a710 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxhdpi/img_keyart_video_summary.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_bg_landing.png b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_bg_landing.png new file mode 100644 index 00000000..136cf2ed Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_bg_landing.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_chatbot.png b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_chatbot.png new file mode 100644 index 00000000..8338c0c2 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_chatbot.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_imagen.png b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_imagen.png new file mode 100644 index 00000000..6aea79fa Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_imagen.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_img_desc.png b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_img_desc.png new file mode 100644 index 00000000..97a6c3b9 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_img_desc.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_magic_selfie.png b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_magic_selfie.png new file mode 100644 index 00000000..48c88b7f Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_magic_selfie.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_metadata.png b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_metadata.png new file mode 100644 index 00000000..4946bc94 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_metadata.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_multimodal.png b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_multimodal.png new file mode 100644 index 00000000..6ab881fe Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_multimodal.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_summary.png b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_summary.png new file mode 100644 index 00000000..2f00a76f Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_summary.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_text.png b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_text.png new file mode 100644 index 00000000..2c86dcb3 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_text.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_todo.png b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_todo.png new file mode 100644 index 00000000..4f59b3ff Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_todo.png differ diff --git a/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_video_summary.png b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_video_summary.png new file mode 100644 index 00000000..1cd96572 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable-xxxhdpi/img_keyart_video_summary.png differ diff --git a/ai-catalog/app/src/main/res/drawable/bg.png b/ai-catalog/app/src/main/res/drawable/bg.png new file mode 100644 index 00000000..d92dc328 Binary files /dev/null and b/ai-catalog/app/src/main/res/drawable/bg.png differ diff --git a/ai-catalog/app/src/main/res/drawable/ic_launcher_background.xml b/ai-catalog/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index 07d5da9c..00000000 --- a/ai-catalog/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ai-catalog/app/src/main/res/drawable/ic_launcher_foreground.xml b/ai-catalog/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d11..00000000 --- a/ai-catalog/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/ai-catalog/app/src/main/res/drawable/spark_android.xml b/ai-catalog/app/src/main/res/drawable/spark_android.xml new file mode 100644 index 00000000..4a3344de --- /dev/null +++ b/ai-catalog/app/src/main/res/drawable/spark_android.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/ai-catalog/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/ai-catalog/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 6f3b755b..00000000 --- a/ai-catalog/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/ai-catalog/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/ai-catalog/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 6f3b755b..00000000 --- a/ai-catalog/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/ai-catalog/app/src/main/res/mipmap-hdpi/ic_launcher.png b/ai-catalog/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..79b02422 Binary files /dev/null and b/ai-catalog/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/ai-catalog/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/ai-catalog/app/src/main/res/mipmap-hdpi/ic_launcher.webp deleted file mode 100644 index c209e78e..00000000 Binary files a/ai-catalog/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ diff --git a/ai-catalog/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/ai-catalog/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp deleted file mode 100644 index b2dfe3d1..00000000 Binary files a/ai-catalog/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ diff --git a/ai-catalog/app/src/main/res/mipmap-mdpi/ic_launcher.png b/ai-catalog/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..79b02422 Binary files /dev/null and b/ai-catalog/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/ai-catalog/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/ai-catalog/app/src/main/res/mipmap-mdpi/ic_launcher.webp deleted file mode 100644 index 4f0f1d64..00000000 Binary files a/ai-catalog/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ diff --git a/ai-catalog/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/ai-catalog/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp deleted file mode 100644 index 62b611da..00000000 Binary files a/ai-catalog/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ diff --git a/ai-catalog/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/ai-catalog/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..d8e24e22 Binary files /dev/null and b/ai-catalog/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/ai-catalog/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/ai-catalog/app/src/main/res/mipmap-xhdpi/ic_launcher.webp deleted file mode 100644 index 948a3070..00000000 Binary files a/ai-catalog/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ diff --git a/ai-catalog/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/ai-catalog/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp deleted file mode 100644 index 1b9a6956..00000000 Binary files a/ai-catalog/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/ai-catalog/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/ai-catalog/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..59fa6cc6 Binary files /dev/null and b/ai-catalog/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/ai-catalog/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/ai-catalog/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp deleted file mode 100644 index 28d4b77f..00000000 Binary files a/ai-catalog/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ diff --git a/ai-catalog/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/ai-catalog/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9287f508..00000000 Binary files a/ai-catalog/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/ai-catalog/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/ai-catalog/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..400e4ca2 Binary files /dev/null and b/ai-catalog/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/ai-catalog/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/ai-catalog/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp deleted file mode 100644 index aa7d6427..00000000 Binary files a/ai-catalog/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ diff --git a/ai-catalog/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/ai-catalog/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp deleted file mode 100644 index 9126ae37..00000000 Binary files a/ai-catalog/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ diff --git a/ai-catalog/app/src/main/res/values/strings.xml b/ai-catalog/app/src/main/res/values/strings.xml index 4e923dfc..ef6355ad 100644 --- a/ai-catalog/app/src/main/res/values/strings.xml +++ b/ai-catalog/app/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ Polish text with Gemini Nano Proofread and rewrite short content on-device with GenAI API powered by Gemini Nano Android AI Samples + Android\nAI Samples Open sample Image generation with Imagen Generate images with Imagen, Google image generation model diff --git a/ai-catalog/gradle/libs.versions.toml b/ai-catalog/gradle/libs.versions.toml index 11bb05ed..a7ff2247 100644 --- a/ai-catalog/gradle/libs.versions.toml +++ b/ai-catalog/gradle/libs.versions.toml @@ -21,8 +21,8 @@ googleGmsGoogleServices = "4.4.2" hilt = "2.56.2" hiltNavigationCompose = "1.2.0" ksp = "2.1.0-1.0.29" +mlkitSegmentation = "16.0.0-beta1" runtimeLivedata = "1.7.6" -material3Android = "1.3.1" media3 = "1.8.0" firebaseCommonKtx = "21.0.0" uiToolingPreviewAndroid = "1.8.1" @@ -30,8 +30,12 @@ spotless = "7.0.4" uiToolingPreview = "1.8.3" uiTooling = "1.8.3" lifecycleViewmodelAndroid = "2.8.7" -material3 = "1.3.2" +material3 = "1.5.0-alpha01" +uiTextGoogleFonts = "1.8.1" +runtime = "1.8.3" exifinterface = "1.4.1" +material3WindowSizeClass = "1.3.2" +richtext = "1.0.0-alpha02" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -56,8 +60,11 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } +androidx-ui-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts", version.ref = "uiTextGoogleFonts" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-material3-window = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "material3WindowSizeClass" } +richtext-material3 = { group = "com.halilibo.compose-richtext", name = "richtext-ui-material3", version.ref = "richtext" } +richtext-commonmark = { group = "com.halilibo.compose-richtext", name = "richtext-commonmark", version.ref = "richtext" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "navigationRuntimeKtx" } @@ -73,10 +80,10 @@ androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "medi androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "media3"} androidx-media3-transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" } androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiToolingPreviewAndroid" } +mlkit-segmentation = { module = "com.google.android.gms:play-services-mlkit-subject-segmentation", version.ref = "mlkitSegmentation" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "uiToolingPreview" } ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } androidx-lifecycle-viewmodel-android = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-android", version.ref = "lifecycleViewmodelAndroid" } -material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" } [plugins] diff --git a/ai-catalog/samples/gemini-chatbot/build.gradle.kts b/ai-catalog/samples/gemini-chatbot/build.gradle.kts index 066db14b..517f68c0 100644 --- a/ai-catalog/samples/gemini-chatbot/build.gradle.kts +++ b/ai-catalog/samples/gemini-chatbot/build.gradle.kts @@ -60,15 +60,19 @@ dependencies { implementation(libs.androidx.appcompat) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.material3) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.ai) implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) implementation(libs.androidx.runtime.livedata) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.material3.android) + implementation(libs.ui.tooling.preview) + debugImplementation(libs.ui.tooling) ksp(libs.hilt.compiler) + implementation(project(":ui-component")) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/ChatMessage.kt b/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/ChatMessage.kt deleted file mode 100644 index dc27f84a..00000000 --- a/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/ChatMessage.kt +++ /dev/null @@ -1,25 +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.geminichatbot - -import android.net.Uri - -data class ChatMessage( - val text: String, - val timestamp: Long, - val isIncoming: Boolean = false, - val senderIconUrl: Uri? = null, -) diff --git a/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotScreen.kt b/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotScreen.kt index baea8168..1e1b6613 100644 --- a/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotScreen.kt +++ b/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotScreen.kt @@ -15,103 +15,116 @@ */ package com.android.ai.samples.geminichatbot -import android.content.Intent -import androidx.compose.foundation.layout.Arrangement +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Code +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.material3.rememberTopAppBarState 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.uicomponent.ChatMessage +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.MessageList +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.TextInput @OptIn(ExperimentalMaterial3Api::class) @Composable fun GeminiChatbotScreen(viewModel: GeminiChatbotViewModel = hiltViewModel()) { - val topAppBarState = rememberTopAppBarState() - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var message by rememberSaveable { mutableStateOf("") } + + GeminiChatbotScreen( + uiState = uiState, + onSendMessage = { + viewModel.sendMessage(it) + }, + onDismissError = viewModel::dismissError, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +private fun GeminiChatbotScreen(uiState: GeminiChatbotUiState, onSendMessage: (String) -> Unit, onDismissError: () -> Unit) { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher Scaffold( modifier = Modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection) .imePadding(), + containerColor = MaterialTheme.colorScheme.surface, topBar = { - TopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - title = { - Text(text = stringResource(id = R.string.geminichatbot_title_bar)) - }, - actions = { - SeeCodeButton() - }, + SampleDetailTopAppBar( + sampleName = stringResource(R.string.geminichatbot_title), + sampleDescription = stringResource(R.string.geminichatbot_description), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-chatbot", + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + onBackClick = { backDispatcher?.onBackPressed() }, + topAppBarState = topAppBarState, + scrollBehavior = scrollBehavior, ) }, ) { innerPadding -> - Column { + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { MessageList( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .weight(1f), + .widthIn(max = 646.dp) + .fillMaxSize() + .padding(start = 16.dp, end = 16.dp), messages = uiState.messages, - contentPadding = innerPadding, ) when (val state = uiState.geminiMessageState) { is GeminiMessageState.Generating -> { - CircularProgressIndicator( - modifier = Modifier - .padding(vertical = 8.dp) - .align(Alignment.CenterHorizontally), + ContainedLoadingIndicator( + modifier = Modifier.size(60.dp) + .align(Alignment.Center), + indicatorColor = MaterialTheme.colorScheme.tertiary, ) } is GeminiMessageState.Error -> { AlertDialog( - onDismissRequest = { viewModel.dismissError() }, + onDismissRequest = onDismissError, title = { Text(text = stringResource(R.string.error)) }, text = { Text(text = state.errorMessage) }, confirmButton = { - Button(onClick = { viewModel.dismissError() }) { + Button(onClick = onDismissError) { Text(text = stringResource(R.string.dismiss_button)) } }, @@ -120,78 +133,56 @@ fun GeminiChatbotScreen(viewModel: GeminiChatbotViewModel = hiltViewModel()) { else -> { /* No additional UI for waiting state */ } } - InputBar( - value = message, + val textFieldState = rememberTextFieldState() + TextInput( + state = textFieldState, placeholder = stringResource(R.string.geminichatbot_input_placeholder), - onInputChanged = { - message = it - }, - onSendClick = { - viewModel.sendMessage(message) - message = "" + primaryButton = { + GenerateButton( + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_send), + modifier = Modifier + .width(72.dp) + .height(55.dp) + .padding(4.dp), + enabled = uiState.geminiMessageState !is GeminiMessageState.Generating, + onClick = { + onSendMessage(textFieldState.text.toString()) + textFieldState.setTextAndPlaceCursorAtEnd("") + }, + ) }, - sendEnabled = uiState.geminiMessageState !is GeminiMessageState.Generating, - ) - } - } -} - -@Composable -fun MessageList(messages: List, contentPadding: PaddingValues, modifier: Modifier = Modifier) { - LazyColumn( - modifier = modifier, - contentPadding = contentPadding, - reverseLayout = true, - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom), - ) { - items(items = messages) { message -> - MessageBubble( - message = message, - ) - } - } -} - -@Composable -fun MessageBubble(message: ChatMessage, modifier: Modifier = Modifier) { - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = if (message.isIncoming) Alignment.CenterStart else Alignment.CenterEnd, - ) { - Surface( - modifier = Modifier.widthIn(max = 300.dp), - color = if (message.isIncoming) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.primary - }, - shape = MaterialTheme.shapes.large, - ) { - Text( - modifier = Modifier.padding(16.dp), - text = message.text, + modifier = Modifier + .padding(10.dp) + .align(Alignment.BottomCenter) + .widthIn(max = 646.dp) + .fillMaxWidth(), ) } } } +@PreviewScreenSizes @Composable -fun SeeCodeButton() { - val context = LocalContext.current - val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-chatbot" - - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, githubLink.toUri()) - context.startActivity(intent) - }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon(Icons.Filled.Code, contentDescription = "See code") - Text( - modifier = Modifier.padding(start = 8.dp), - fontSize = 12.sp, - text = stringResource(R.string.see_code), +@OptIn(ExperimentalMaterial3Api::class) +private fun GeminiChatbotScreenPreview() { + AISampleCatalogTheme { + GeminiChatbotScreen( + uiState = GeminiChatbotUiState( + messages = listOf( + ChatMessage( + "Hi there!", + timestamp = 124, + isIncoming = true, + ), + ChatMessage( + "I’m super sleepy today, what coffee drink has the most caffeine, but not too much. Also something hot.", + timestamp = 123, + isIncoming = false, + ), + ), + ), + onSendMessage = {}, + onDismissError = {}, ) } } diff --git a/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotViewModel.kt b/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotViewModel.kt index 3d27b510..4f422422 100644 --- a/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotViewModel.kt +++ b/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/GeminiChatbotViewModel.kt @@ -17,6 +17,7 @@ package com.android.ai.samples.geminichatbot import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.ai.uicomponent.ChatMessage import com.google.firebase.Firebase import com.google.firebase.ai.ai import com.google.firebase.ai.type.GenerativeBackend @@ -91,7 +92,7 @@ class GeminiChatbotViewModel @Inject constructor() : ViewModel() { timestamp = System.currentTimeMillis(), isIncoming = true, ) - } ?: error("Model returned an empty response") // This error will be caught by the try/catch + } ?: error("Model returned an empty response") _uiState.update { it.copy(messages = listOf(newMessage) + it.messages, geminiMessageState = GeminiMessageState.WaitingForMessage) diff --git a/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/InputBar.kt b/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/InputBar.kt deleted file mode 100644 index af134f16..00000000 --- a/ai-catalog/samples/gemini-chatbot/src/main/java/com/android/ai/samples/geminichatbot/InputBar.kt +++ /dev/null @@ -1,100 +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.geminichatbot - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.unit.dp - -@Composable -internal fun InputBar( - value: String, - placeholder: String, - sendEnabled: Boolean, - onInputChanged: (String) -> Unit, - onSendClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Surface( - modifier = modifier, - tonalElevation = 3.dp, - ) { - Row( - modifier = Modifier - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - TextField( - value = value, - onValueChange = onInputChanged, - modifier = Modifier - .weight(1f) - .defaultMinSize(minHeight = 56.dp) - .wrapContentHeight(), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - imeAction = ImeAction.Send, - ), - keyboardActions = KeyboardActions(onSend = { onSendClick() }), - placeholder = { Text(placeholder) }, - shape = MaterialTheme.shapes.extraLarge, - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.background, - unfocusedContainerColor = MaterialTheme.colorScheme.background, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - ), - ) - Spacer(modifier = Modifier.width(4.dp)) - FilledIconButton( - onClick = onSendClick, - modifier = Modifier.size(56.dp), - enabled = sendEnabled, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.send), - ) - } - } - } -} diff --git a/ai-catalog/samples/gemini-chatbot/src/main/res/values/strings.xml b/ai-catalog/samples/gemini-chatbot/src/main/res/values/strings.xml index a599b1c3..45eb192f 100644 --- a/ai-catalog/samples/gemini-chatbot/src/main/res/values/strings.xml +++ b/ai-catalog/samples/gemini-chatbot/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ - Gemini Chatbot + Gemini Chatbot + A simple implementation of chatbot using Gemini Flash model. Type your message See code Dismiss diff --git a/ai-catalog/samples/gemini-image-chat/build.gradle.kts b/ai-catalog/samples/gemini-image-chat/build.gradle.kts index 85cc0769..dec2ae64 100644 --- a/ai-catalog/samples/gemini-image-chat/build.gradle.kts +++ b/ai-catalog/samples/gemini-image-chat/build.gradle.kts @@ -65,10 +65,13 @@ dependencies { implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.material3.android) + implementation(libs.androidx.material3) implementation(libs.coil.compose) implementation(libs.androidx.exifinterface) ksp(libs.hilt.compiler) + implementation(libs.ui.tooling.preview) + + implementation(project(":ui-component")) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/ChatMessage.kt b/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/ChatMessage.kt deleted file mode 100644 index e5a8e0e5..00000000 --- a/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/ChatMessage.kt +++ /dev/null @@ -1,27 +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.geminiimagechat - -import android.graphics.Bitmap -import android.net.Uri - -data class ChatMessage( - val text: String, - val timestamp: Long, - val isIncoming: Boolean = false, - val senderIconUrl: Uri? = null, - val image: Bitmap? = null, -) diff --git a/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatScreen.kt b/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatScreen.kt index 76813f36..78863a87 100644 --- a/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatScreen.kt +++ b/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatScreen.kt @@ -15,59 +15,63 @@ */ package com.android.ai.samples.geminiimagechat -import android.content.Intent -import android.graphics.Bitmap import android.net.Uri +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Code +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import com.android.ai.samples.util.loadBitmapWithCorrectOrientation +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.uicomponent.ChatMessage +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.MessageList +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.SecondaryButton +import com.android.ai.uicomponent.TextInput import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -75,67 +79,108 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable fun GeminiImageChatScreen(viewModel: GeminiImageChatViewModel = hiltViewModel()) { - val topAppBarState = rememberTopAppBarState() - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var message by rememberSaveable { mutableStateOf("") } - var imageUri by remember { mutableStateOf(null) } val photoPickerLauncher = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> uri?.let { imageUri = it } } - val context = LocalContext.current val coroutineScope = rememberCoroutineScope() + GeminiImageChatScreen( + uiState = uiState, + onSendMessage = { message -> + coroutineScope.launch { + val finalBitmap = imageUri?.let { + withContext(Dispatchers.IO) { + loadBitmapWithCorrectOrientation(context, it) + } + } + viewModel.sendMessage(message, finalBitmap) + imageUri = null + } + }, + onDismissError = viewModel::dismissError, + onImagePickerClick = { + photoPickerLauncher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) + }, + imageUri = imageUri, + ) { + imageUri = null + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +private fun GeminiImageChatScreen( + uiState: GeminiImageChatUiState, + onSendMessage: (String) -> Unit, + onDismissError: () -> Unit, + onImagePickerClick: () -> Unit, + imageUri: Uri? = null, + onImageClicked: () -> Unit, +) { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + val lazyListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(uiState.messages) { + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + } + Scaffold( modifier = Modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection) .imePadding(), topBar = { - TopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - title = { - Text(text = stringResource(id = R.string.gemini_image_chat_title_bar)) - }, - actions = { - SeeCodeButton() - }, + SampleDetailTopAppBar( + sampleName = stringResource(R.string.gemini_image_chat_title), + sampleDescription = stringResource(R.string.gemini_image_chat_description), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-image-chat", + onBackClick = { backDispatcher?.onBackPressed() }, + topAppBarState = topAppBarState, + scrollBehavior = scrollBehavior, ) }, ) { innerPadding -> - Column { + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + contentAlignment = Alignment.BottomCenter, + ) { MessageList( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .weight(1f), + .widthIn(max = 646.dp) + .fillMaxSize() + .padding(start = 16.dp, end = 16.dp), messages = uiState.messages, - contentPadding = innerPadding, + listState = lazyListState, ) when (val state = uiState.geminiMessageState) { is GeminiMessageState.Generating -> { - CircularProgressIndicator( - modifier = Modifier - .padding(vertical = 8.dp) - .align(Alignment.CenterHorizontally), + ContainedLoadingIndicator( + modifier = Modifier.size(60.dp) + .align(Alignment.Center), + indicatorColor = MaterialTheme.colorScheme.tertiary, ) } is GeminiMessageState.Error -> { AlertDialog( - onDismissRequest = { viewModel.dismissError() }, + onDismissRequest = onDismissError, title = { Text(text = stringResource(R.string.error)) }, text = { Text(text = state.errorMessage ?: stringResource(R.string.something_went_wrong)) }, confirmButton = { - Button(onClick = { viewModel.dismissError() }) { + Button(onClick = onDismissError) { Text(text = stringResource(R.string.dismiss_button)) } }, @@ -144,114 +189,74 @@ fun GeminiImageChatScreen(viewModel: GeminiImageChatViewModel = hiltViewModel()) else -> {} } - InputBar( - value = message, + val textFieldState = rememberTextFieldState() + TextInput( + state = textFieldState, placeholder = stringResource(R.string.gemini_image_chat_input_placeholder), - onInputChanged = { - message = it + primaryButton = { + GenerateButton( + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_send), + modifier = Modifier + .width(72.dp) + .height(55.dp) + .padding(4.dp), + enabled = uiState.geminiMessageState !is GeminiMessageState.Generating, + onClick = { + onSendMessage(textFieldState.text.toString()) + textFieldState.setTextAndPlaceCursorAtEnd("") + }, + ) }, - onSendClick = { - coroutineScope.launch { - val bitmap = imageUri?.let { - withContext(Dispatchers.IO) { - loadBitmapWithCorrectOrientation(context, it) - } - } - viewModel.sendMessage(message, bitmap) - imageUri = null - message = "" + secondaryButton = { + if (imageUri != null) { + AsyncImage( + model = imageUri, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.clickable( + onClick = onImageClicked, + ).width(50.dp) + .height(55.dp) + .padding(4.dp) + .clip(RoundedCornerShape(2.dp)), + ) + } else { + SecondaryButton( + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_img), + modifier = Modifier + .width(48.dp) + .height(55.dp) + .padding(4.dp), + onClick = onImagePickerClick, + ) } }, - sendEnabled = uiState.geminiMessageState !is GeminiMessageState.Generating, - addImage = { - photoPickerLauncher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) - }, - imageUri = imageUri, - ) - } - } -} - -@Composable -fun MessageList(messages: List, contentPadding: PaddingValues, modifier: Modifier = Modifier) { - if (messages.isEmpty()) { - Column( - modifier = modifier - .padding(contentPadding) - .fillMaxSize(), - ) { - Text( - stringResource(R.string.gemini_image_chat_guidance), - fontStyle = FontStyle.Italic, - fontSize = 20.sp, - modifier = Modifier.padding(14.dp), + modifier = Modifier + .padding(10.dp) + .align(Alignment.BottomCenter) + .widthIn(max = 646.dp) + .fillMaxWidth(), ) } - } else { - LazyColumn( - modifier = modifier, - contentPadding = contentPadding, - reverseLayout = true, - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom), - ) { - items(items = messages) { message -> - MessageBubble( - message = message, - ) - } - } } } +@PreviewScreenSizes @Composable -fun MessageBubble(message: ChatMessage, modifier: Modifier = Modifier) { - Box( - modifier = modifier.fillMaxWidth(), - contentAlignment = if (message.isIncoming) Alignment.CenterStart else Alignment.CenterEnd, - ) { - Surface( - modifier = Modifier.widthIn(max = 300.dp), - color = if (message.isIncoming) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.primary - }, - shape = MaterialTheme.shapes.large, - ) { - Column { - Text( - modifier = Modifier.padding(16.dp), - text = message.text, - ) - message.image?.let { it: Bitmap -> - Image( - modifier = Modifier.padding(16.dp), - bitmap = it.asImageBitmap(), - contentDescription = null, - ) - } - } - } - } -} - -@Composable -fun SeeCodeButton() { - val context = LocalContext.current - val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-image-chat" - - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, githubLink.toUri()) - context.startActivity(intent) - }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon(Icons.Filled.Code, contentDescription = "See code") - Text( - modifier = Modifier.padding(start = 8.dp), - fontSize = 12.sp, - text = stringResource(R.string.see_code), +@OptIn(ExperimentalMaterial3Api::class) +private fun GeminiImageChatScreenPreview() { + AISampleCatalogTheme { + GeminiImageChatScreen( + uiState = GeminiImageChatUiState( + messages = listOf( + ChatMessage("Hi there!", 124, true), + ChatMessage("I’m super sleepy today...", 123, false), + ), + ), + onSendMessage = { _ -> }, + onDismissError = {}, + onImagePickerClick = {}, + onImageClicked = {}, ) } } diff --git a/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatViewModel.kt b/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatViewModel.kt index e621ba8f..84b81dce 100644 --- a/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatViewModel.kt +++ b/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/GeminiImageChatViewModel.kt @@ -18,6 +18,7 @@ package com.android.ai.samples.geminiimagechat import android.graphics.Bitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.ai.uicomponent.ChatMessage import com.google.firebase.Firebase import com.google.firebase.ai.ai import com.google.firebase.ai.type.GenerativeBackend diff --git a/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/InputBar.kt b/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/InputBar.kt deleted file mode 100644 index 44034d3f..00000000 --- a/ai-catalog/samples/gemini-image-chat/src/main/java/com/android/ai/samples/geminiimagechat/InputBar.kt +++ /dev/null @@ -1,128 +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.geminiimagechat - -import android.net.Uri -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Send -import androidx.compose.material.icons.filled.Image -import androidx.compose.material3.FilledIconButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -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.layout.ContentScale -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage - -@Composable -internal fun InputBar( - value: String, - placeholder: String, - sendEnabled: Boolean, - onInputChanged: (String) -> Unit, - onSendClick: () -> Unit, - addImage: () -> Unit, - modifier: Modifier = Modifier, - imageUri: Uri? = null, -) { - Surface( - modifier = modifier, - tonalElevation = 3.dp, - ) { - Row( - modifier = Modifier - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - FilledIconButton( - onClick = addImage, - modifier = Modifier.size(56.dp), - enabled = sendEnabled, - ) { - Icon( - imageVector = Icons.Default.Image, - contentDescription = stringResource(R.string.add_photo), - ) - } - if (imageUri != null) { - AsyncImage( - model = imageUri, - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.width(50.dp).clip(RoundedCornerShape(5.dp)), - ) - } - Spacer(modifier = Modifier.width(4.dp)) - - TextField( - value = value, - onValueChange = onInputChanged, - modifier = Modifier - .weight(1f) - .defaultMinSize(minHeight = 56.dp) - .wrapContentHeight(), - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - imeAction = ImeAction.Send, - ), - keyboardActions = KeyboardActions(onSend = { onSendClick() }), - placeholder = { Text(placeholder) }, - shape = MaterialTheme.shapes.extraLarge, - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.background, - unfocusedContainerColor = MaterialTheme.colorScheme.background, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - ), - ) - Spacer(modifier = Modifier.width(4.dp)) - FilledIconButton( - onClick = onSendClick, - modifier = Modifier.size(56.dp), - enabled = sendEnabled, - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = stringResource(R.string.send), - ) - } - } - } -} diff --git a/ai-catalog/samples/gemini-image-chat/src/main/res/values/strings.xml b/ai-catalog/samples/gemini-image-chat/src/main/res/values/strings.xml index 0683bed7..54d6dfbf 100644 --- a/ai-catalog/samples/gemini-image-chat/src/main/res/values/strings.xml +++ b/ai-catalog/samples/gemini-image-chat/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ - Gemini Image Chat + Gemini Image Chat + A simple implementation of chatbot using Gemini Flash model that can understand both text and images. Type your message Start by describing an initial image. Then iterate on it by suggesting gemini to apply changes (background, colors, view angle, etc…). See code diff --git a/ai-catalog/samples/gemini-live-todo/build.gradle.kts b/ai-catalog/samples/gemini-live-todo/build.gradle.kts index 423a532d..2eef9bf4 100644 --- a/ai-catalog/samples/gemini-live-todo/build.gradle.kts +++ b/ai-catalog/samples/gemini-live-todo/build.gradle.kts @@ -58,12 +58,13 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.ai) implementation(libs.androidx.lifecycle.viewmodel.android) - implementation(libs.material3) + implementation(libs.androidx.material3) implementation(libs.hilt.android) + implementation(libs.ui.tooling.preview) ksp(libs.hilt.compiler) + implementation(project(":ui-component")) implementation(libs.hilt.navigation.compose) implementation(libs.androidx.activity.compose) - implementation(libs.androidx.material3.android) implementation(libs.kotlinx.serialization.json) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt index 1ca702ce..c10c2ce1 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreen.kt @@ -16,80 +16,63 @@ package com.android.ai.samples.geminilivetodo.ui import android.app.Activity -import android.content.Intent import androidx.activity.compose.LocalActivity -import androidx.compose.animation.Animatable -import androidx.compose.animation.animateColor -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Mic -import androidx.compose.material.icons.filled.MicNone -import androidx.compose.material.icons.filled.MicOff -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FabPosition -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.ai.samples.geminilivetodo.R import com.android.ai.samples.geminilivetodo.data.Todo -import kotlin.collections.reversed +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.SecondaryButton +import com.android.ai.uicomponent.TextInput -/** - * The main screen for the To-do list application. - * This composable is stateful, connecting to the ViewModel to manage UI state and events. - */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var text by remember { mutableStateOf("") } val activity = LocalActivity.current as Activity @@ -97,23 +80,20 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { viewModel.initializeGeminiLive(activity) } + val topAppBarState = rememberTopAppBarState() + val scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, topBar = { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - title = { Text(stringResource(R.string.gemini_live_title)) }, - actions = { - SeeCodeButton() - }, - ) - }, - floatingActionButton = { - MicButton( - uiState = uiState, - onToggle = { viewModel.toggleLiveSession(activity) }, + SampleDetailTopAppBar( + sampleName = stringResource(R.string.gemini_live_title), + sampleDescription = stringResource(R.string.gemini_live_subtitle), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-live-todo", + topAppBarState = topAppBarState, + scrollBehavior = scrollBehavior, + onBackClick = { backDispatcher?.onBackPressed() }, ) }, floatingActionButtonPosition = FabPosition.Center, @@ -126,15 +106,6 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { .imePadding() .fillMaxSize(), ) { - TodoInput( - text = text, - onTextChange = { text = it }, - onAddClick = { - viewModel.addTodo(text) - text = "" - }, - ) - when (uiState) { is TodoScreenUiState.Initial -> { Box( @@ -147,26 +118,26 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { } is TodoScreenUiState.Success -> { val todos = (uiState as TodoScreenUiState.Success).todos - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .widthIn(max = 646.dp) + .align(Alignment.CenterHorizontally) + .weight(1f), + ) { itemsIndexed(todos.reversed(), key = { index: Int, item: Todo -> item.id }) { index, todo -> TodoItem( - modifier = Modifier, task = todo, onToggle = { viewModel.toggleTodoStatus(todo.id) }, onDelete = { viewModel.removeTodo(todo.id) }, ) - if (index != todos.size - 1) { - HorizontalDivider() - } } } } is TodoScreenUiState.Error -> { val todos = (uiState as TodoScreenUiState.Error).todos - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn(modifier = Modifier.weight(1f)) { itemsIndexed(todos.reversed(), key = { index: Int, item: Todo -> item.id }) { index, todo -> TodoItem( - modifier = Modifier, task = todo, onToggle = { viewModel.toggleTodoStatus(todo.id) }, onDelete = { viewModel.removeTodo(todo.id) }, @@ -178,95 +149,71 @@ fun TodoScreen(viewModel: TodoScreenViewModel = hiltViewModel()) { } } } - } - } -} - -@Composable -fun TodoInput(text: String, onTextChange: (String) -> Unit, onAddClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = text, - onValueChange = onTextChange, - label = { Text(stringResource(R.string.new_task_placeholder)) }, - modifier = Modifier.weight(1f), - singleLine = true, - ) - Spacer(modifier = Modifier.width(8.dp)) - Button( - enabled = text.isNotBlank(), - onClick = onAddClick, - ) { - Text(stringResource(R.string.add_button)) - } - } -} - -@Composable -fun MicButton(uiState: TodoScreenUiState, onToggle: () -> Unit) { - if (uiState is TodoScreenUiState.Success) { - val micIcon = when { - uiState.liveSessionState is LiveSessionState.Ready -> Icons.Filled.MicOff - uiState.liveSessionState is LiveSessionState.Running -> Icons.Filled.Mic - uiState.liveSessionState is LiveSessionState.NotReady -> Icons.Filled.MicNone - uiState.liveSessionState is LiveSessionState.Error -> Icons.Filled.MicNone - else -> Icons.Filled.MicNone - } - val containerColor = if (uiState.liveSessionState is LiveSessionState.Running) { - val infiniteTransition = - rememberInfiniteTransition(label = "mic_color_transition") - infiniteTransition.animateColor( - initialValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), - targetValue = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f), - animationSpec = infiniteRepeatable( - animation = tween(1000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "mic_color", - ).value - } else { - MaterialTheme.colorScheme.primaryContainer - } + val textFieldState = rememberTextFieldState() + val textInputEnabled = remember { mutableStateOf(true) } + if (uiState is TodoScreenUiState.Success) { + when { + (uiState as TodoScreenUiState.Success).liveSessionState is LiveSessionState.Running -> { + val listeningMessage = stringResource(R.string.listening) + LaunchedEffect(Unit) { + textFieldState.setTextAndPlaceCursorAtEnd(listeningMessage) + textInputEnabled.value = false + } + } + else -> { + LaunchedEffect(Unit) { + textFieldState.clearText() + textInputEnabled.value = true + } + } + } + } - FloatingActionButton( - onClick = { if (uiState.liveSessionState !is LiveSessionState.NotReady) onToggle() }, - containerColor = containerColor, - ) { - Icon(micIcon, stringResource(R.string.interact_with_todolist_by_voice)) - } - } else if (uiState is TodoScreenUiState.Error) { - val isDialogDisplayed = remember { mutableStateOf(true) } - if (isDialogDisplayed.value) { - AlertDialog( - onDismissRequest = { isDialogDisplayed.value = false }, - title = { Text(text = stringResource(R.string.error_title)) }, - text = { Text(text = stringResource(R.string.error_message)) }, - confirmButton = { - Button(onClick = { isDialogDisplayed.value = false }) { - Text(text = stringResource(R.string.dismiss_button)) + TextInput( + state = textFieldState, + enabled = textInputEnabled.value, + placeholder = stringResource(R.string.new_task_placeholder), + primaryButton = { + GenerateButton( + text = "", + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_mic), + modifier = Modifier + .width(72.dp) + .height(55.dp) + .padding(4.dp), + onClick = { + viewModel.toggleLiveSession(activity) + }, + ) + }, + secondaryButton = { + SecondaryButton( + text = "", + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_add), + modifier = Modifier + .width(48.dp) + .height(55.dp) + .padding(4.dp), + ) { + viewModel.addTodo(textFieldState.text.toString()) + textFieldState.clearText() } }, + modifier = Modifier + .widthIn(max = 646.dp) + .align(Alignment.CenterHorizontally), ) } } } @Composable -fun TodoItem(modifier: Modifier, task: Todo, onToggle: () -> Unit, onDelete: () -> Unit) { - val defaultBackgroundColor = Color.Transparent - val backgroundColor = remember { Animatable(defaultBackgroundColor) } - +fun TodoItem(task: Todo, onToggle: () -> Unit, onDelete: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 12.dp, horizontal = 8.dp) - .background(backgroundColor.value), + .padding(vertical = 12.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Checkbox( @@ -276,38 +223,24 @@ fun TodoItem(modifier: Modifier, task: Todo, onToggle: () -> Unit, onDelete: () Text( text = task.task, style = if (task.isCompleted) { - TextStyle(fontSize = 16.sp, textDecoration = TextDecoration.LineThrough) + MaterialTheme.typography.bodyLarge.copy(textDecoration = TextDecoration.LineThrough) } else { - TextStyle(fontSize = 16.sp, textDecoration = TextDecoration.None) + MaterialTheme.typography.bodyLarge.copy(textDecoration = TextDecoration.None) }, modifier = Modifier.weight(1f), ) - IconButton(onClick = onDelete) { + IconButton( + onClick = onDelete, + modifier = Modifier.background( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(10.dp), + ).size(32.dp), + ) { Icon( - imageVector = Icons.Default.Delete, + painterResource(com.android.ai.uicomponent.R.drawable.ic_delete), + modifier = Modifier.size(20.dp), contentDescription = "Delete", ) } } } - -@Composable -fun SeeCodeButton() { - val context = LocalContext.current - val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-live-todo" - - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, githubLink.toUri()) - context.startActivity(intent) - }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon(Icons.Filled.Code, contentDescription = "See code") - Text( - modifier = Modifier.padding(start = 8.dp), - fontSize = 12.sp, - text = stringResource(R.string.see_code), - ) - } -} diff --git a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt index 1e0e83bd..a32d8f8d 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt +++ b/ai-catalog/samples/gemini-live-todo/src/main/java/com/android/ai/samples/geminilivetodo/ui/TodoScreenViewModel.kt @@ -143,13 +143,13 @@ class TodoScreenViewModel @Inject constructor(private val todoRepository: TodoRe val removeTodo = FunctionDeclaration( "removeTodo", "Remove a task from the todo list", - mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")), + mapOf("todoId" to Schema.integer("The id of the task to remove from the todo list")), ) val toggleTodoStatus = FunctionDeclaration( "toggleTodoStatus", "Change the status of the task", - mapOf("todoId" to Schema.string("The id of the task to remove from the todo list")), + mapOf("todoId" to Schema.integer("The id of the task to remove from the todo list")), ) val getTodoList = FunctionDeclaration( diff --git a/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml b/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml index bb31132a..44a131ff 100644 --- a/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml +++ b/ai-catalog/samples/gemini-live-todo/src/main/res/values/strings.xml @@ -17,6 +17,7 @@ Gemini Live Todo + Simple ToDo app using the Gemini Live API to interact with the items in the list. New Task Add Error @@ -24,4 +25,5 @@ Dismiss Button to start the live session and interact with the todo list by voice See code + I am listening... \ No newline at end of file diff --git a/ai-catalog/samples/gemini-multimodal/build.gradle.kts b/ai-catalog/samples/gemini-multimodal/build.gradle.kts index b8bf563f..f59af216 100644 --- a/ai-catalog/samples/gemini-multimodal/build.gradle.kts +++ b/ai-catalog/samples/gemini-multimodal/build.gradle.kts @@ -70,9 +70,13 @@ dependencies { implementation(libs.firebase.ai) implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) + implementation(libs.coil.compose) implementation(libs.androidx.runtime.livedata) + implementation(libs.androidx.runtime.livedata) + implementation(libs.androidx.material3.window) + debugImplementation(libs.ui.tooling) ksp(libs.hilt.compiler) - + implementation(project(":ui-component")) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/ai-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalScreen.kt b/ai-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalScreen.kt index 48159e88..e35c28e7 100644 --- a/ai-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalScreen.kt +++ b/ai-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalScreen.kt @@ -15,203 +15,301 @@ */ package com.android.ai.samples.geminimultimodal.ui -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent +import android.app.Activity import android.graphics.Bitmap +import android.graphics.ImageDecoder import android.net.Uri +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts.TakePicturePreview -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +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.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CameraAlt -import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.SmartToy -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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.tooling.preview.Devices.PHONE +import androidx.compose.ui.tooling.preview.Devices.TABLET +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.core.graphics.decodeBitmap import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.ai.samples.geminimultimodal.R +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.ImageInput +import com.android.ai.uicomponent.ImageInputType +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.SecondaryButton +import com.android.ai.uicomponent.TextInput -@OptIn(ExperimentalMaterial3Api::class) -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun GeminiMultimodalScreen(viewModel: GeminiMultimodalViewModel = hiltViewModel()) { - val context = LocalContext.current - var bitmap by remember { mutableStateOf(null) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var imageUri by rememberSaveable { mutableStateOf(null) } + val snackbarHostState = remember { SnackbarHostState() } - val promptPlaceHolder = stringResource(id = R.string.geminimultimodal_prompt_placeholder) - var editTextValue by remember { - mutableStateOf(promptPlaceHolder) + val photoPickerLauncher = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> + uri?.let { + imageUri = it + } } - // Get the picture taken by the camera - val cameraLauncher = rememberLauncherForActivityResult(TakePicturePreview()) { result -> - result?.let { - bitmap = it + if (uiState is GeminiMultimodalUiState.Error) { + val errorMessage = (uiState as GeminiMultimodalUiState.Error).errorMessage + ?: stringResource(R.string.unknown_error) + LaunchedEffect(uiState) { + snackbarHostState.showSnackbar(errorMessage) + viewModel.resetError() } } + val windowSizeClass = calculateWindowSizeClass(activity = LocalContext.current as Activity) + val isExpandedScreen = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Expanded + + GeminiMultimodalScreen( + isExpandedScreen = isExpandedScreen, + uiState = uiState, + imageUri = imageUri, + snackbarHostState = snackbarHostState, + onGenerateClick = viewModel::generate, + onImagePickerClick = { + photoPickerLauncher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GeminiMultimodalScreen( + isExpandedScreen: Boolean, + uiState: GeminiMultimodalUiState, + imageUri: Uri?, + snackbarHostState: SnackbarHostState, + onGenerateClick: (Bitmap, String) -> Unit, + onImagePickerClick: () -> Unit, +) { + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - TopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - ), - title = { - Text(text = stringResource(id = R.string.geminimultimodal_title_bar)) - }, - actions = { - SeeCodeButton(context) - }, + SampleDetailTopAppBar( + sampleName = stringResource(R.string.geminimultimodal_title), + sampleDescription = stringResource(R.string.geminimultimodal_subtitle), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-multimodal", + onBackClick = { backDispatcher?.onBackPressed() }, ) }, ) { innerPadding -> - Column( - Modifier - .padding(12.dp) + if (isExpandedScreen) { + ExpandedScreen( + innerPadding, + uiState, + imageUri, + onGenerateClick, + onImagePickerClick, + ) + } else { + CompactScreen( + innerPadding, + uiState, + imageUri, + onGenerateClick, + onImagePickerClick, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } +} + +@Composable +private fun CompactScreen( + innerPadding: PaddingValues, + uiState: GeminiMultimodalUiState, + imageUri: Uri?, + onGenerateClick: (Bitmap, String) -> Unit, + onTakePictureClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val type = when { + imageUri != null && uiState is GeminiMultimodalUiState.Success -> ImageInputType.WithImage.WithText(imageUri, uiState.generatedText) + imageUri != null && uiState is GeminiMultimodalUiState.Loading -> ImageInputType.WithImage.Analyzing(imageUri) + imageUri != null -> ImageInputType.WithImage.Image(imageUri) + else -> ImageInputType.Empty(onAddImage = onTakePictureClick) + } + ImageInput( + type = type, + modifier = modifier.padding(innerPadding), + ) { + val textFieldState = rememberTextFieldState() + val keyboardController = LocalSoftwareKeyboardController.current + PromptInput( + textFieldState, + uiState, + imageUri, + onGenerateClick, + keyboardController, + onTakePictureClick, + ) + } +} + +@Composable +private fun ExpandedScreen( + innerPadding: PaddingValues, + uiState: GeminiMultimodalUiState, + imageUri: Uri?, + onGenerateClick: (Bitmap, String) -> Unit, + onImagePickerClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val type = when { + imageUri != null && uiState is GeminiMultimodalUiState.Success -> ImageInputType.WithImage.WithText(imageUri, uiState.generatedText) + imageUri != null && uiState is GeminiMultimodalUiState.Loading -> ImageInputType.WithImage.Analyzing(imageUri) + imageUri != null -> ImageInputType.WithImage.Image(imageUri) + else -> ImageInputType.Empty(onAddImage = onImagePickerClick) + } + Row( + modifier = modifier + .padding(innerPadding) + .fillMaxSize(), + ) { + ImageInput( + type = type, + modifier = Modifier + .fillMaxHeight() + .weight(1f) + .padding(horizontal = 16.dp), + ) + + val textFieldState = rememberTextFieldState() + val keyboardController = LocalSoftwareKeyboardController.current + PromptInput( + textFieldState, + uiState, + imageUri, + onGenerateClick, + keyboardController, + onImagePickerClick, + modifier = Modifier + .weight(1f) + .align(Alignment.Bottom) .imePadding() - .verticalScroll(rememberScrollState()) - .padding(innerPadding), - ) { - Card( + .padding(horizontal = 16.dp), + ) + } +} + +@Composable +private fun PromptInput( + textFieldState: TextFieldState, + uiState: GeminiMultimodalUiState, + imageUri: Uri?, + onGenerateClick: (Bitmap, String) -> Unit, + keyboardController: SoftwareKeyboardController?, + onTakePictureClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + TextInput( + state = textFieldState, + placeholder = stringResource(R.string.geminimultimodal_prompt_placeholder), + primaryButton = { + GenerateButton( + text = "", + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_img), modifier = Modifier - .size( - width = 450.dp, - height = 450.dp, - ), - ) { - val currentBitmap = bitmap - if (currentBitmap != null) { - Image( - bitmap = currentBitmap.asImageBitmap(), - contentDescription = "Picture", - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(id = R.string.geminimultimodal_take_a_picture), - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - Spacer(modifier = Modifier.height(6.dp)) - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { - cameraLauncher.launch(null) - }, - ) { - Icon(Icons.Default.CameraAlt, contentDescription = "Camera") - } - } - Spacer(modifier = Modifier.height(24.dp)) - TextField( - value = editTextValue, - onValueChange = { editTextValue = it }, - label = { Text("Prompt") }, - ) - Spacer(modifier = Modifier.height(8.dp)) - Button( + .width(72.dp) + .height(55.dp) + .padding(4.dp), + enabled = uiState !is GeminiMultimodalUiState.Loading && imageUri != null, onClick = { - val currentBitmap = bitmap - if (currentBitmap != null) { - viewModel.generate(currentBitmap, editTextValue) + if (imageUri != null) { + val bitmap = ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, imageUri)) + onGenerateClick(bitmap, textFieldState.text.toString()) } + keyboardController?.hide() }, - enabled = uiState !is GeminiMultimodalUiState.Loading && bitmap != null, - ) { - Icon(Icons.Default.SmartToy, contentDescription = "Robot") - Text(modifier = Modifier.padding(start = 8.dp), text = "Generate") - } - Spacer( - modifier = Modifier - .height(24.dp), ) - - when (uiState) { - is GeminiMultimodalUiState.Initial -> { - Text( - text = stringResource(id = R.string.geminimultimodal_generation_placeholder), - style = MaterialTheme.typography.bodySmall, - ) - } - is GeminiMultimodalUiState.Loading -> { - CircularProgressIndicator() - } - is GeminiMultimodalUiState.Success -> { - Text( - text = (uiState as GeminiMultimodalUiState.Success).generatedText, - ) - } - is GeminiMultimodalUiState.Error -> { - Text( - text = (uiState as GeminiMultimodalUiState.Error).errorMessage ?: stringResource(R.string.unknown_error), - ) - } + }, + secondaryButton = { + if (imageUri != null) { + SecondaryButton( + text = "", + enabled = uiState !is GeminiMultimodalUiState.Loading, + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_add), + onClick = onTakePictureClick, + modifier = Modifier + .width(48.dp) + .height(56.dp) + .padding(4.dp), + ) } - } + }, + modifier = modifier + .padding(10.dp), + ) +} + +@Preview(name = "Phone", device = PHONE) +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun GeminiMultimodalScreenPreview() { + AISampleCatalogTheme { + GeminiMultimodalScreen( + isExpandedScreen = false, + uiState = GeminiMultimodalUiState.Initial, + imageUri = null, + snackbarHostState = remember { SnackbarHostState() }, + onGenerateClick = { _, _ -> }, + onImagePickerClick = {}, + ) } } +@Preview(name = "Tablet", device = TABLET) @Composable -fun SeeCodeButton(context: Context) { - val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-multimodal" - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubLink)) - context.startActivity(intent) - }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon(Icons.Filled.Code, contentDescription = "See code") - Text( - modifier = Modifier.padding(start = 8.dp), - fontSize = 12.sp, - text = stringResource(R.string.see_code), +@OptIn(ExperimentalMaterial3Api::class) +private fun GeminiMultimodalScreenTabletPreview() { + AISampleCatalogTheme { + GeminiMultimodalScreen( + isExpandedScreen = true, + uiState = GeminiMultimodalUiState.Initial, + imageUri = null, + snackbarHostState = remember { SnackbarHostState() }, + onGenerateClick = { _, _ -> }, + onImagePickerClick = {}, ) } } diff --git a/ai-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalViewModel.kt b/ai-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalViewModel.kt index c1ed28c9..e7ac9416 100644 --- a/ai-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalViewModel.kt +++ b/ai-catalog/samples/gemini-multimodal/src/main/java/com/android/ai/samples/geminimultimodal/ui/GeminiMultimodalViewModel.kt @@ -42,4 +42,8 @@ class GeminiMultimodalViewModel @Inject constructor(private val geminiDataSource } } } + + fun resetError() { + _uiState.value = GeminiMultimodalUiState.Initial + } } diff --git a/ai-catalog/samples/gemini-multimodal/src/main/res/values/strings.xml b/ai-catalog/samples/gemini-multimodal/src/main/res/values/strings.xml index 715bc808..63ad0639 100644 --- a/ai-catalog/samples/gemini-multimodal/src/main/res/values/strings.xml +++ b/ai-catalog/samples/gemini-multimodal/src/main/res/values/strings.xml @@ -1,9 +1,10 @@ - Gemini Multimodal - Describe this picture in a funny way with a lot of emojis + Gemini Multimodal + A very simple example of multimodal generation using the Gemini Flash model + Describe this picture (Take a picture and enter a prompt to start generating.) - (Take a picture.)" + Add image Generating… See code Unknown error diff --git a/ai-catalog/samples/gemini-video-metadata-creation/build.gradle.kts b/ai-catalog/samples/gemini-video-metadata-creation/build.gradle.kts index 63cd5bb3..5f25e744 100644 --- a/ai-catalog/samples/gemini-video-metadata-creation/build.gradle.kts +++ b/ai-catalog/samples/gemini-video-metadata-creation/build.gradle.kts @@ -53,7 +53,7 @@ android { } dependencies { - + implementation(project(":ui-component")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.compose) @@ -61,7 +61,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) - implementation(libs.androidx.material3.android) + implementation(libs.androidx.material3) implementation(libs.firebase.common.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.ui.tooling.preview.android) diff --git a/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/VideoMetadataCreationScreen.kt b/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/VideoMetadataCreationScreen.kt deleted file mode 100644 index d5e779b4..00000000 --- a/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/VideoMetadataCreationScreen.kt +++ /dev/null @@ -1,193 +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.geminivideometadatacreation - -import android.content.Intent -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Code -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -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.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.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.LifecycleStartEffect -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.android.ai.samples.geminivideometadatacreation.player.VideoPlayer -import com.android.ai.samples.geminivideometadatacreation.player.VideoSelectionDropdown -import com.android.ai.samples.geminivideometadatacreation.ui.ButtonGrid -import com.android.ai.samples.geminivideometadatacreation.util.sampleVideoList -import com.android.ai.samples.geminivideometadatacreation.viewmodel.MetadataCreationState -import com.android.ai.samples.geminivideometadatacreation.viewmodel.MetadataType -import com.android.ai.samples.geminivideometadatacreation.viewmodel.VideoMetadataCreationState -import com.android.ai.samples.geminivideometadatacreation.viewmodel.VideoMetadataCreationViewModel - -/** - * Composable function for the AI Video Metadata Creation screen. - * - * This screen allows users to select a video, play it, and generate metadata of its content - * using Firebase AI. It also provides text-to-speech functionality to read out - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun VideoMetadataCreationScreen(viewModel: VideoMetadataCreationViewModel = hiltViewModel()) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - var isDropdownExpanded by remember { mutableStateOf(false) } - - LifecycleStartEffect(viewModel) { - viewModel.createPlayer() - onStopOrDispose { viewModel.releasePlayer() } - } - - Scaffold( - topBar = { - TopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - ), - title = { - Text(text = stringResource(R.string.video_metadata_creation_title)) - }, - actions = { - SeeCodeButton() - }, - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .padding(16.dp) - .padding(innerPadding) - .fillMaxWidth() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - VideoSelectionDropdown( - selectedVideoUri = uiState.selectedVideoUri, - isDropdownExpanded = isDropdownExpanded, - videoOptions = sampleVideoList, - onVideoUriSelected = { uri -> - viewModel.onVideoSelected(uri) - viewModel.resetMetadataState() - }, - onDropdownExpanded = { isDropdownExpanded = it }, - ) - - VideoPlayer( - player = uiState.player, - modifier = Modifier.aspectRatio(16f / 9f), - ) - - MetadataCreationSection( - uiState = uiState, - onDismissError = { viewModel.dismissError() }, - onMetadataTypeClicked = { - viewModel.onMetadataTypeSelected(it) - viewModel.generateMetadata(it) - }, - ) - } - } -} - -@Composable -private fun MetadataCreationSection( - uiState: VideoMetadataCreationState, - onDismissError: () -> Unit, - onMetadataTypeClicked: (MetadataType) -> Unit, - modifier: Modifier = Modifier, -) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier, - ) { - ButtonGrid( - selectedMetadataType = uiState.selectedMetadataType, - onMetadataCreationClicked = onMetadataTypeClicked, - ) - - when (val metadataCreationState = uiState.metadataCreationState) { - is MetadataCreationState.InProgress -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) - } - - is MetadataCreationState.Error -> { - AlertDialog( - onDismissRequest = onDismissError, - title = { Text("Error") }, - text = { Text(metadataCreationState.message) }, - confirmButton = { - Button(onClick = onDismissError) { - Text("OK") - } - }, - ) - } - - is MetadataCreationState.Success -> metadataCreationState.generatedUi() - - MetadataCreationState.Idle -> { - // Default state - No button is selected unless explicitly selected - } - } - } -} - -@Composable -fun SeeCodeButton() { - val context = LocalContext.current - val githubLink = - "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-video-metadata-creation" - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, githubLink.toUri()) - context.startActivity(intent) - }, - ) { - Icon(Icons.Filled.Code, contentDescription = "See code") - Text( - modifier = Modifier.padding(start = 8.dp), - fontSize = 12.sp, - text = stringResource(R.string.see_code), - ) - } -} diff --git a/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/MetadataButtonGrid.kt b/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/MetadataButtonGrid.kt deleted file mode 100644 index fb9b315b..00000000 --- a/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/MetadataButtonGrid.kt +++ /dev/null @@ -1,63 +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.geminivideometadatacreation.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.android.ai.samples.geminivideometadatacreation.viewmodel.MetadataType - -/** - * A Composable that displays a grid of buttons for each [MetadataType]. - * - * This function dynamically creates a button for every entry in the [MetadataType] enum. - * It uses a [FlowRow] to arrange the buttons, allowing them to wrap to the next line - * if they exceed the available horizontal space. The currently selected button is - * highlighted with the primary color. - */ -@Composable -fun ButtonGrid(selectedMetadataType: MetadataType?, onMetadataCreationClicked: (MetadataType) -> Unit, modifier: Modifier = Modifier) { - val metadataTypes = MetadataType.entries - - FlowRow( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - metadataTypes.forEach { metadataType -> - val isSelected = selectedMetadataType == metadataType - Button( - onClick = { onMetadataCreationClicked(metadataType) }, - colors = ButtonDefaults.buttonColors( - containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, - contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) { - Text( - text = metadataType.name.replace('_', ' ').lowercase() - .replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }, - ) - } - } - } -} diff --git a/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/MetadataButtonRow.kt b/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/MetadataButtonRow.kt new file mode 100644 index 00000000..c82efca5 --- /dev/null +++ b/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/MetadataButtonRow.kt @@ -0,0 +1,73 @@ +/* + * 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.geminivideometadatacreation.ui + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedToggleButton +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.android.ai.samples.geminivideometadatacreation.viewmodel.MetadataType + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ButtonRow(selectedMetadataType: MetadataType?, onMetadataCreationClicked: (MetadataType) -> Unit, modifier: Modifier = Modifier) { + val metadataTypes = MetadataType.entries + + Row( + modifier = modifier.horizontalScroll(rememberScrollState()), + + ) { + Spacer(Modifier.width(10.dp)) + metadataTypes.forEach { metadataType -> + val isSelected = selectedMetadataType == metadataType + OutlinedToggleButton( + checked = isSelected, + onCheckedChange = { onMetadataCreationClicked(metadataType) }, + colors = ToggleButtonDefaults.outlinedToggleButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary, + ), + modifier = Modifier.padding(horizontal = 6.dp), + ) { + Icon( + painterResource(metadataType.iconRes), + contentDescription = null, + ) + Spacer(Modifier.size(8.dp)) + Text( + stringResource(metadataType.titleRes).uppercase(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + } + } + Spacer(Modifier.width(10.dp)) + } +} diff --git a/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/MetadataComponents.kt b/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/MetadataComponents.kt index 084dddcd..ac6780f0 100644 --- a/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/MetadataComponents.kt +++ b/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/MetadataComponents.kt @@ -21,6 +21,7 @@ import android.text.format.DateUtils import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow @@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Person import androidx.compose.material3.AssistChip @@ -135,8 +137,9 @@ fun LinksUi(links: List) { @Composable fun ThumbnailsUi(thumbnailTimestamps: List, thumbnailImages: List) { - FlowRow( + Row( horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally), + modifier = Modifier.horizontalScroll(rememberScrollState()), ) { thumbnailTimestamps.forEachIndexed { i, timestamp -> Column { diff --git a/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/VideoMetadataCreationScreen.kt b/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/VideoMetadataCreationScreen.kt new file mode 100644 index 00000000..91c36fc6 --- /dev/null +++ b/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/ui/VideoMetadataCreationScreen.kt @@ -0,0 +1,241 @@ +/* + * 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.geminivideometadatacreation.ui + +import android.net.Uri +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.Player +import com.android.ai.samples.geminivideometadatacreation.R +import com.android.ai.samples.geminivideometadatacreation.util.sampleVideoList +import com.android.ai.samples.geminivideometadatacreation.viewmodel.MetadataCreationState +import com.android.ai.samples.geminivideometadatacreation.viewmodel.MetadataType +import com.android.ai.samples.geminivideometadatacreation.viewmodel.VideoMetadataCreationViewModel +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.VideoPickerData +import com.android.ai.uicomponent.VideoPickerDropdown +import com.android.ai.uicomponent.VideoPlayer + +/** + * Composable function for the AI Video Metadata Creation screen. + * + * This screen allows users to select a video, play it, and generate metadata of its content + * using Firebase AI. It also provides text-to-speech functionality to read out + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VideoMetadataCreationScreen(viewModel: VideoMetadataCreationViewModel = hiltViewModel()) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LifecycleStartEffect(viewModel) { + viewModel.createPlayer() + onStopOrDispose { viewModel.releasePlayer() } + } + + VideoMetadataCreationScreen( + player = uiState.player, + selectedVideoUri = uiState.selectedVideoUri, + selectedMetadataType = uiState.selectedMetadataType, + metadataCreationState = uiState.metadataCreationState, + onVideoSelected = { uri: Uri -> + viewModel.onVideoSelected(uri) + viewModel.resetMetadataState() + }, + onDismissError = viewModel::dismissError, + onMetadataTypeClicked = { type: MetadataType -> + viewModel.onMetadataTypeSelected(type) + viewModel.generateMetadata(type) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VideoMetadataCreationScreen( + player: Player?, + selectedVideoUri: Uri?, + selectedMetadataType: MetadataType?, + metadataCreationState: MetadataCreationState, + onVideoSelected: (Uri) -> Unit, + onDismissError: () -> Unit, + onMetadataTypeClicked: (MetadataType) -> Unit, +) { + var isDropdownExpanded by remember { mutableStateOf(false) } + val context = LocalContext.current + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + Scaffold( + topBar = { + SampleDetailTopAppBar( + sampleName = stringResource(R.string.video_metadata_creation_title), + sampleDescription = stringResource(R.string.video_metadata_creation_title), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-video-metadata-creation", + onBackClick = { backDispatcher?.onBackPressed() }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + VideoPlayer( + player = player, + videoPicker = { + VideoPickerDropdown( + videoItems = sampleVideoList.map { VideoPickerData(context.getString(it.titleResId), it.uri) }, + selectedVideo = selectedVideoUri, + isExpanded = isDropdownExpanded, + onDropdownExpandedChanged = { isDropdownExpanded = it }, + onVideoSelected = { onVideoSelected(it.uri) }, + ) + }, + forceShowControls = isDropdownExpanded, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth() + .aspectRatio(16f / 9f), + ) + + Spacer(Modifier.height(16.dp)) + Text( + stringResource(R.string.create), style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + MetadataCreationSection( + selectedMetadataType = selectedMetadataType, + metadataCreationState = metadataCreationState, + onDismissError = onDismissError, + onMetadataTypeClicked = onMetadataTypeClicked, + ) + } + } +} + +@Composable +private fun MetadataCreationSection( + selectedMetadataType: MetadataType?, + metadataCreationState: MetadataCreationState, + onDismissError: () -> Unit, + onMetadataTypeClicked: (MetadataType) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + ButtonRow( + selectedMetadataType = selectedMetadataType, + onMetadataCreationClicked = onMetadataTypeClicked, + ) + Spacer(Modifier.height(16.dp)) + + when (val metadataCreationState = metadataCreationState) { + is MetadataCreationState.InProgress -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } + + is MetadataCreationState.Error -> { + AlertDialog( + onDismissRequest = onDismissError, + title = { Text("Error") }, + text = { Text(metadataCreationState.message) }, + confirmButton = { + Button(onClick = onDismissError) { + Text("OK") + } + }, + ) + } + + is MetadataCreationState.Success -> { + Box( + Modifier + .fillMaxWidth() + .padding(16.dp) + .background(MaterialTheme.colorScheme.surfaceContainer, MaterialTheme.shapes.large) + .padding(16.dp), + ) { + metadataCreationState.generatedUi() + } + } + + MetadataCreationState.Idle -> { + // Default state - No button is selected unless explicitly selected + } + } + } +} + +@Preview +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VideoMetadataCreationScreenPreview() { + VideoMetadataCreationScreen( + player = null, + selectedVideoUri = null, + selectedMetadataType = MetadataType.DESCRIPTION, + metadataCreationState = MetadataCreationState.Idle, + onVideoSelected = {}, + onDismissError = {}, + onMetadataTypeClicked = {}, + ) +} + +@Preview +@Composable +fun MetadataCreationSectionPreview() { + MetadataCreationSection( + selectedMetadataType = MetadataType.DESCRIPTION, + metadataCreationState = MetadataCreationState.Success( + { Box(Modifier.size(100.dp).background(Color.Red)) }, + ), + onDismissError = {}, + onMetadataTypeClicked = {}, + ) +} diff --git a/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/viewmodel/VideoMetadataCreationViewModel.kt b/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/viewmodel/VideoMetadataCreationViewModel.kt index 2d0fa168..09097d66 100644 --- a/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/viewmodel/VideoMetadataCreationViewModel.kt +++ b/ai-catalog/samples/gemini-video-metadata-creation/src/main/java/com/android/ai/samples/geminivideometadatacreation/viewmodel/VideoMetadataCreationViewModel.kt @@ -18,7 +18,9 @@ package com.android.ai.samples.geminivideometadatacreation.viewmodel import android.app.Application import android.graphics.Bitmap import android.net.Uri +import androidx.annotation.DrawableRes import androidx.annotation.OptIn +import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -26,6 +28,7 @@ import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import com.android.ai.samples.geminivideometadatacreation.R import com.android.ai.samples.geminivideometadatacreation.generateAccountTags import com.android.ai.samples.geminivideometadatacreation.generateChapters import com.android.ai.samples.geminivideometadatacreation.generateDescription @@ -140,13 +143,16 @@ class VideoMetadataCreationViewModel @Inject constructor(private val application } } -enum class MetadataType { - DESCRIPTION, - THUMBNAILS, - HASHTAGS, - ACCOUNT_TAGS, - CHAPTERS, - LINKS, +enum class MetadataType( + @DrawableRes val iconRes: Int, + @StringRes val titleRes: Int, +) { + THUMBNAILS(com.android.ai.uicomponent.R.drawable.ic_ai_img, R.string.thumbnails), + DESCRIPTION(com.android.ai.uicomponent.R.drawable.ic_ai_summary, R.string.description), + HASHTAGS(com.android.ai.uicomponent.R.drawable.ic_ai_hashtags, R.string.hashtags), + ACCOUNT_TAGS(com.android.ai.uicomponent.R.drawable.ic_ai_tags, R.string.account_tags), + CHAPTERS(com.android.ai.uicomponent.R.drawable.ic_ai_chapters, R.string.chapters), + LINKS(com.android.ai.uicomponent.R.drawable.ic_ai_hashtags, R.string.links), } sealed interface MetadataCreationState { diff --git a/ai-catalog/samples/gemini-video-metadata-creation/src/main/res/values/strings.xml b/ai-catalog/samples/gemini-video-metadata-creation/src/main/res/values/strings.xml index 8d218541..604fbda4 100644 --- a/ai-catalog/samples/gemini-video-metadata-creation/src/main/res/values/strings.xml +++ b/ai-catalog/samples/gemini-video-metadata-creation/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ Select Video Create Metadata Video Metadata Creation + Generate content metadata like description, hashtags, chapters, and account tags (from a cloud URL or Youtube) with Gemini API powered by Firebase. %s "Text generated with Gemini: " Listen to AI output @@ -27,5 +28,6 @@ play pause + Create \ No newline at end of file diff --git a/ai-catalog/samples/gemini-video-summarization/build.gradle.kts b/ai-catalog/samples/gemini-video-summarization/build.gradle.kts index 5808429f..8cd50c2f 100644 --- a/ai-catalog/samples/gemini-video-summarization/build.gradle.kts +++ b/ai-catalog/samples/gemini-video-summarization/build.gradle.kts @@ -52,15 +52,14 @@ android { } dependencies { - implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.material3) implementation(libs.androidx.material.icons.extended) implementation(platform(libs.androidx.compose.bom)) implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) - implementation(libs.androidx.material3.android) implementation(libs.firebase.common.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.ui.tooling.preview.android) @@ -72,6 +71,8 @@ dependencies { implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) + implementation(project(":ui-component")) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/VideoSummarizationScreen.kt b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/VideoSummarizationScreen.kt deleted file mode 100644 index ee70e948..00000000 --- a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/VideoSummarizationScreen.kt +++ /dev/null @@ -1,237 +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.geminivideosummary - -import android.content.Intent -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Code -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -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.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.media3.common.MediaItem -import androidx.media3.exoplayer.ExoPlayer -import com.android.ai.samples.geminivideosummary.player.VideoPlayer -import com.android.ai.samples.geminivideosummary.player.VideoSelectionDropdown -import com.android.ai.samples.geminivideosummary.ui.OutputTextDisplay -import com.android.ai.samples.geminivideosummary.ui.TextToSpeechControls -import com.android.ai.samples.geminivideosummary.util.sampleVideoList -import com.android.ai.samples.geminivideosummary.viewmodel.SummarizationState -import com.android.ai.samples.geminivideosummary.viewmodel.TtsState -import com.android.ai.samples.geminivideosummary.viewmodel.VideoSummarizationState -import com.android.ai.samples.geminivideosummary.viewmodel.VideoSummarizationViewModel -import com.google.com.android.ai.samples.geminivideosummary.R -import java.util.Locale - -/** - * Composable function for the AI Video Summarization screen. - * - * This screen allows users to select a video, play it, and generate a summary of its content - * using Firebase AI. It also provides text-to-speech functionality to read out - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun VideoSummarizationScreen(viewModel: VideoSummarizationViewModel = hiltViewModel()) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - var isDropdownExpanded by remember { mutableStateOf(false) } - - val exoPlayer = remember(context) { - ExoPlayer.Builder(context).build().apply { - playWhenReady = true - } - } - - LaunchedEffect(uiState.selectedVideoUri) { - uiState.selectedVideoUri?.let { - exoPlayer.setMediaItem(MediaItem.fromUri(it)) - exoPlayer.prepare() - } - } - - Scaffold( - topBar = { - TopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - ), - title = { - Text(text = stringResource(R.string.video_summarization_title)) - }, - actions = { - SeeCodeButton() - }, - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .padding(16.dp) - .padding(innerPadding), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - VideoSelectionDropdown( - selectedVideoUri = uiState.selectedVideoUri, - isDropdownExpanded = isDropdownExpanded, - videoOptions = sampleVideoList, - onVideoUriSelected = { uri -> - viewModel.onVideoSelected(uri) - }, - onDropdownExpanded = { isDropdownExpanded = it }, - ) - - VideoPlayer(exoPlayer = exoPlayer, modifier = Modifier.fillMaxWidth()) - - SummarizationSection( - uiState = uiState, - onSummarizeClick = { - viewModel.onTtsStateChanged(TtsState.Idle) - viewModel.summarize() - }, - onTtsStateChanged = { ttsState -> - viewModel.onTtsStateChanged(ttsState) - }, - onAccentSelected = { accent -> - viewModel.onAccentSelected(accent) - }, - onDismissError = { viewModel.dismissError() }, - onTtsInitializationResult = { isSuccess, errorMessage -> - viewModel.onTtsInitializationResult(isSuccess, errorMessage) - }, - ) - } - } - - DisposableEffect(key1 = exoPlayer) { - onDispose { - exoPlayer.release() - } - } -} - -@Composable -private fun SummarizationSection( - uiState: VideoSummarizationState, - onSummarizeClick: () -> Unit, - onTtsStateChanged: (TtsState) -> Unit, - onAccentSelected: (Locale) -> Unit, - onDismissError: () -> Unit, - onTtsInitializationResult: (Boolean, String?) -> Unit, -) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Button( - modifier = Modifier.fillMaxWidth(), - onClick = onSummarizeClick, - enabled = uiState.summarizationState != SummarizationState.InProgress, - ) { - Text(stringResource(R.string.summarize_video_button)) - } - - when (val summarizationState = uiState.summarizationState) { - is SummarizationState.InProgress -> { - CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) - } - - is SummarizationState.Error -> { - AlertDialog( - onDismissRequest = onDismissError, - title = { Text("Error") }, - text = { Text(summarizationState.message) }, - confirmButton = { - Button(onClick = onDismissError) { - Text("OK") - } - }, - ) - } - - is SummarizationState.Success -> { - TextToSpeechControls( - ttsState = summarizationState.ttsState, - speechText = summarizationState.summarizedText, - selectedAccent = uiState.selectedAccent, - accentOptions = accentOptions, - onTtsStateChange = onTtsStateChanged, - onAccentSelected = onAccentSelected, - onInitializationResult = onTtsInitializationResult, - ) - OutputTextDisplay(summarizationState.summarizedText, modifier = Modifier.weight(1f)) - } - is SummarizationState.Idle -> { - // Nothing to show - } - } - } -} - -private val accentOptions = listOf( - Locale.UK, - Locale.FRANCE, - Locale.GERMANY, - Locale.ITALY, - Locale.JAPAN, - Locale.KOREA, - Locale.US, -) - -@Composable -fun SeeCodeButton() { - val context = LocalContext.current - val githubLink = - "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-video-summarization" - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, githubLink.toUri()) - context.startActivity(intent) - }, - ) { - Icon(Icons.Filled.Code, contentDescription = "See code") - Text( - modifier = Modifier.padding(start = 8.dp), - fontSize = 12.sp, - text = stringResource(R.string.see_code), - ) - } -} diff --git a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/player/VideoSelectionDropdown.kt b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/player/VideoSelectionDropdown.kt deleted file mode 100644 index ac742a04..00000000 --- a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/player/VideoSelectionDropdown.kt +++ /dev/null @@ -1,77 +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.geminivideosummary.player - -import android.net.Uri -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import com.android.ai.samples.geminivideosummary.util.VideoItem -import com.google.com.android.ai.samples.geminivideosummary.R - -/** - * A composable function that displays a dropdown menu for selecting a video from a list of options. - */ -@Composable -fun VideoSelectionDropdown( - selectedVideoUri: Uri?, - isDropdownExpanded: Boolean, - videoOptions: List, - onVideoUriSelected: (Uri) -> Unit, - onDropdownExpanded: (Boolean) -> Unit, -) { - Box { - OutlinedTextField( - value = selectedVideoUri?.let { - videoOptions.firstOrNull { videoItem -> videoItem.uri == selectedVideoUri }?.let { stringResource(it.titleResId) } - } ?: stringResource(R.string.select_video_placeholder), - onValueChange = { }, - readOnly = true, - trailingIcon = { - Icon( - imageVector = Icons.Filled.ArrowDropDown, - contentDescription = stringResource(R.string.dropdown_content_description), - modifier = Modifier.clickable { onDropdownExpanded(!isDropdownExpanded) }, - ) - }, - modifier = Modifier.fillMaxWidth() - .clickable { onDropdownExpanded(!isDropdownExpanded) }, - ) - - DropdownMenu( - expanded = isDropdownExpanded, - onDismissRequest = { onDropdownExpanded(false) }, - modifier = Modifier.fillMaxWidth(), - ) { - videoOptions.forEach { videoItem -> - DropdownMenuItem(text = { Text(stringResource(videoItem.titleResId)) }, onClick = { - onVideoUriSelected(videoItem.uri) - onDropdownExpanded(false) - }) - } - } - } -} diff --git a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/OutputTextDisplay.kt b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/OutputTextDisplay.kt index e6863939..0038096d 100644 --- a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/OutputTextDisplay.kt +++ b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/OutputTextDisplay.kt @@ -23,14 +23,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle import com.google.com.android.ai.samples.geminivideosummary.R -/** - * Composable function that displays text. - * - * This function renders the generated text, providing a styled display within a scrollable container. - */ @Composable fun OutputTextDisplay(outputText: String, modifier: Modifier = Modifier) { Text( @@ -39,10 +33,9 @@ fun OutputTextDisplay(outputText: String, modifier: Modifier = Modifier) { stringResource(R.string.output_text_generated_placeholder), outputText, ), - fontStyle = FontStyle.Italic, + style = MaterialTheme.typography.bodyLarge, modifier = modifier .fillMaxWidth() .verticalScroll(rememberScrollState()), - style = MaterialTheme.typography.labelLarge, ) } diff --git a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/SummarizationSheet.kt b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/SummarizationSheet.kt new file mode 100644 index 00000000..f20bd192 --- /dev/null +++ b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/SummarizationSheet.kt @@ -0,0 +1,107 @@ +/* + * 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.geminivideosummary.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.ai.samples.geminivideosummary.viewmodel.SummarizationState +import com.android.ai.samples.geminivideosummary.viewmodel.TtsState +import com.android.ai.samples.geminivideosummary.viewmodel.VideoSummarizationState +import com.android.ai.theme.extendedColorScheme +import com.android.ai.uicomponent.UndoButton +import com.google.com.android.ai.samples.geminivideosummary.R +import java.util.Locale + +private val accentOptions = listOf( + Locale.UK, + Locale.US, + Locale.CANADA, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SummarizationSheet( + uiState: VideoSummarizationState, + modifier: Modifier = Modifier, + onTtsStateChanged: (TtsState) -> Unit, + onTtsInitializationResult: (Boolean, String?) -> Unit, + onRedo: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + val summarizationState = uiState.summarizationState as? SummarizationState.Success ?: return + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column(modifier = Modifier.padding(24.dp)) { + TextToSpeechControls( + title = uiState.selectedVideo?.titleResId, + ttsState = summarizationState.ttsState, + speechText = summarizationState.summarizedText, + onTtsStateChange = onTtsStateChanged, + onInitializationResult = onTtsInitializationResult, + ) + Spacer(modifier = Modifier.height(24.dp)) + OutputTextDisplay( + summarizationState.summarizedText, + ) + Spacer(modifier = Modifier.height(24.dp)) + Row( + Modifier + .wrapContentHeight(), + ) { + Text( + text = stringResource(R.string.text_generated_with_gemini), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.inverseOnSurface, + modifier = Modifier + .background( + color = extendedColorScheme.geminiProFlash, + shape = RoundedCornerShape(16.dp), + ) + .padding(vertical = 4.dp, horizontal = 8.dp), + ) + Spacer( + Modifier + .weight(1f) + .height(1.dp), + ) + UndoButton( + onClick = onRedo, + ) + } + } + } +} diff --git a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/TextToSpeechControls.kt b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/TextToSpeechControls.kt index fc88784a..2428772c 100644 --- a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/TextToSpeechControls.kt +++ b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/TextToSpeechControls.kt @@ -18,18 +18,14 @@ package com.android.ai.samples.geminivideosummary.ui import android.content.Context import android.speech.tts.TextToSpeech import android.util.Log -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedTextField +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -41,32 +37,23 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.android.ai.samples.geminivideosummary.viewmodel.TtsState +import com.android.ai.uicomponent.PrimaryButton import com.google.com.android.ai.samples.geminivideosummary.R import java.util.Locale -/** - * Composable function that provides controls for Text-to-Speech functionality. - * - * This function displays a UI that allows the user to: - * - Select a language accent for the Text-to-Speech engine. - * - Initiate speech synthesis for the provided text. - * - Pause the ongoing speech. - */ @Composable fun TextToSpeechControls( + @StringRes title: Int?, ttsState: TtsState, speechText: String, - selectedAccent: Locale, - accentOptions: List, onTtsStateChange: (TtsState) -> Unit, - onAccentSelected: (Locale) -> Unit, onInitializationResult: (Boolean, String?) -> Unit, ) { var textToSpeech by remember { mutableStateOf(null) } - var isAccentDropdownExpanded by remember { mutableStateOf(false) } val context = LocalContext.current DisposableEffect(key1 = true) { @@ -76,69 +63,53 @@ fun TextToSpeechControls( } } - LaunchedEffect(speechText, selectedAccent) { + LaunchedEffect(speechText) { textToSpeech?.stop() onTtsStateChange(TtsState.Idle) } Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - OutlinedTextField( - value = selectedAccent.displayLanguage, - onValueChange = { }, - readOnly = true, - trailingIcon = { - Icon( - imageVector = Icons.Filled.ArrowDropDown, - contentDescription = "Dropdown", - modifier = Modifier.clickable { isAccentDropdownExpanded = !isAccentDropdownExpanded }, - ) - }, - modifier = Modifier - .clickable { isAccentDropdownExpanded = !isAccentDropdownExpanded } - .padding(end = 8.dp) - .weight(1f), + Text( + text = stringResource(title ?: R.string.video_summary), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(end = 100.dp), + ) + Spacer( + Modifier + .weight(1f) + .height(1.dp), ) - - DropdownMenu( - expanded = isAccentDropdownExpanded, - onDismissRequest = { isAccentDropdownExpanded = false }, - ) { - accentOptions.forEach { accent -> - DropdownMenuItem( - text = { Text(accent.displayLanguage) }, - onClick = { - onAccentSelected(accent) - isAccentDropdownExpanded = false - }, - ) - } - } if (ttsState == TtsState.Idle || ttsState == TtsState.Paused) { - Button( + PrimaryButton( + text = "", + icon = painterResource(com.android.ai.uicomponent.R.drawable.ic_video_play), onClick = { handleSpeakButtonClick( - textToSpeech, speechText, selectedAccent, onTtsStateChange, + textToSpeech, speechText, Locale.US, onTtsStateChange, ) }, - ) { - Text(text = stringResource(R.string.text_listen_to_ai_output)) - } - } - - if (ttsState == TtsState.Playing) { - Button( + modifier = Modifier + .width(72.dp) + .height(56.dp) + .align(Alignment.Top), + ) + } else if (ttsState == TtsState.Playing) { + PrimaryButton( + text = "", + icon = painterResource(com.android.ai.uicomponent.R.drawable.ic_video_pause), onClick = { textToSpeech?.stop() onTtsStateChange(TtsState.Paused) }, - ) { - Text(text = stringResource(R.string.pause)) - } + modifier = Modifier + .width(72.dp) + .height(56.dp) + .align(Alignment.Top), + ) } } } diff --git a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/VideoSummarizationScreen.kt b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/VideoSummarizationScreen.kt new file mode 100644 index 00000000..0f0c4a45 --- /dev/null +++ b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/ui/VideoSummarizationScreen.kt @@ -0,0 +1,303 @@ +/* + * 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.geminivideosummary.ui + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.MediaItem +import androidx.media3.exoplayer.ExoPlayer +import com.android.ai.samples.geminivideosummary.util.VideoItem +import com.android.ai.samples.geminivideosummary.util.sampleVideoList +import com.android.ai.samples.geminivideosummary.viewmodel.SummarizationState +import com.android.ai.samples.geminivideosummary.viewmodel.TtsState +import com.android.ai.samples.geminivideosummary.viewmodel.VideoSummarizationState +import com.android.ai.samples.geminivideosummary.viewmodel.VideoSummarizationViewModel +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.SelectableItem +import com.android.ai.uicomponent.VideoPickerData +import com.android.ai.uicomponent.VideoPickerDropdown +import com.android.ai.uicomponent.VideoPlayer +import com.google.com.android.ai.samples.geminivideosummary.R + +/** + * Composable function for the AI Video Summarization screen. + * + * This screen allows users to select a video, play it, and generate a summary of its content + * using Firebase AI. It also provides text-to-speech functionality to read out + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VideoSummarizationScreen(viewModel: VideoSummarizationViewModel = hiltViewModel()) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + var isDropdownExpanded by remember { mutableStateOf(false) } + + val exoPlayer = remember(context) { + ExoPlayer.Builder(context).build().apply { + playWhenReady = true + } + } + + LaunchedEffect(uiState.selectedVideo) { + uiState.selectedVideo?.let { + exoPlayer.setMediaItem(MediaItem.fromUri(it.uri)) + exoPlayer.prepare() + } + } + + DisposableEffect(key1 = exoPlayer) { + onDispose { + exoPlayer.release() + } + } + + VideoSummarizationScreen( + uiState = uiState, + exoPlayer = exoPlayer, + isDropdownExpanded = isDropdownExpanded, + onDropdownExpandedChanged = { isDropdownExpanded = it }, + onVideoSelected = { viewModel.onVideoSelected(it) }, + onSummarizeClick = { + viewModel.onTtsStateChanged(TtsState.Idle) + viewModel.summarize() + }, + onTtsStateChanged = viewModel::onTtsStateChanged, + onDismissError = viewModel::dismissError, + onRedo = viewModel::redo, + onTtsInitializationResult = viewModel::onTtsInitializationResult, + ) +} + +class VideoSelectableItem( + override val itemLabel: String, + override val itemData: VideoItem, +) : SelectableItem + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VideoSummarizationScreen( + uiState: VideoSummarizationState, + exoPlayer: ExoPlayer?, + isDropdownExpanded: Boolean, + onDropdownExpandedChanged: (Boolean) -> Unit, + onVideoSelected: (VideoItem) -> Unit, + onSummarizeClick: () -> Unit, + onTtsStateChanged: (TtsState) -> Unit, + onDismissError: () -> Unit, + onRedo: () -> Unit, + onTtsInitializationResult: (Boolean, String?) -> Unit, +) { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + + val videoItemList = sampleVideoList.map { item -> VideoSelectableItem(stringResource(item.titleResId), item) } + + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + topBar = { + SampleDetailTopAppBar( + sampleName = stringResource(R.string.video_summarization_title), + sampleDescription = stringResource(R.string.video_summarization_description), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/gemini-video-summarization", + onBackClick = { backDispatcher?.onBackPressed() }, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(vertical = 16.dp, horizontal = 16.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + VideoPlayer( + player = exoPlayer, + videoPicker = { + VideoPickerDropdown( + videoItems = sampleVideoList.map { VideoPickerData(stringResource(it.titleResId), it.uri) }, + selectedVideo = uiState.selectedVideo?.uri, + isExpanded = isDropdownExpanded, + onDropdownExpandedChanged = onDropdownExpandedChanged, + onVideoSelected = { videoData -> + sampleVideoList.firstOrNull { it.uri == videoData.uri }?.let(onVideoSelected) + }, + ) + }, + forceShowControls = isDropdownExpanded, + modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), + ) + + SummarizationSection( + uiState = uiState, + onSummarizeClick = onSummarizeClick, + onTtsStateChanged = onTtsStateChanged, + onDismissError = onDismissError, + onTtsInitializationResult = onTtsInitializationResult, + onRedo = onRedo, + modifier = Modifier.weight(1f), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SummarizationSection( + uiState: VideoSummarizationState, + onSummarizeClick: () -> Unit, + onTtsStateChanged: (TtsState) -> Unit, + onDismissError: () -> Unit, + onTtsInitializationResult: (Boolean, String?) -> Unit, + onRedo: () -> Unit, + modifier: Modifier = Modifier, +) { + var showSummary by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(uiState.summarizationState) { + if (uiState.summarizationState is SummarizationState.Success) { + showSummary = true + } + } + + val onRedoClick = { + onRedo() + showSummary = false + } + + Box( + modifier = modifier, + ) { + Column { + when (val summarizationState = uiState.summarizationState) { + is SummarizationState.InProgress -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } + + is SummarizationState.Error -> { + AlertDialog( + onDismissRequest = onDismissError, + title = { Text("Error") }, + text = { Text(summarizationState.message) }, + confirmButton = { + Button(onClick = onDismissError) { + Text("OK") + } + }, + ) + } + + is SummarizationState.Success -> { + if (showSummary) { + SummarizationSheet( + uiState = uiState, + onTtsStateChanged = onTtsStateChanged, + onTtsInitializationResult = onTtsInitializationResult, + onRedo = onRedoClick, + onDismiss = { showSummary = false }, + ) + } + } + + is SummarizationState.Idle -> { + // Nothing to show + } + } + } + + val summarizationState = uiState.summarizationState + if (!(summarizationState is SummarizationState.Success && showSummary)) { + val buttonText = if (summarizationState is SummarizationState.Success) { + stringResource(R.string.show_summary) + } else { + stringResource(R.string.summarize_video_button) + } + val buttonOnClick = if (summarizationState is SummarizationState.Success) { + { showSummary = true } + } else { + onSummarizeClick + } + GenerateButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(bottom = 12.dp), + text = buttonText, + icon = painterResource(com.android.ai.uicomponent.R.drawable.ic_video_play), + onClick = buttonOnClick, + enabled = uiState.summarizationState != SummarizationState.InProgress, + ) + } + } +} + +@PreviewScreenSizes +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun VideoSummarizationScreenPreview() { + AISampleCatalogTheme { + VideoSummarizationScreen( + uiState = VideoSummarizationState(), + exoPlayer = null, + isDropdownExpanded = false, + onDropdownExpandedChanged = {}, + onVideoSelected = {}, + onSummarizeClick = {}, + onTtsStateChanged = {}, + onDismissError = {}, + onRedo = {}, + onTtsInitializationResult = { _, _ -> }, + ) + } +} diff --git a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/util/VideoList.kt b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/util/VideoList.kt index 3fb0a2aa..6cba302b 100644 --- a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/util/VideoList.kt +++ b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/util/VideoList.kt @@ -36,10 +36,6 @@ val sampleVideoList = listOf( R.string.video_title_android_spotlight_shorts, "https://storage.googleapis.com/exoplayer-test-media-0/shorts_android_developers/shorts_10.mp4".toUri(), ), - VideoItem( - R.string.video_title_rio_de_janeiro, - "gs://cloud-samples-data/generative-ai/video/rio_de_janeiro_beyond_the_map_rio.mp4".toUri(), - ), VideoItem( R.string.video_title_youtube_google_tv, "https://www.youtube.com/watch?v=QFMIP5GOo70".toUri(), diff --git a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/viewmodel/VideoSummarizationViewModel.kt b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/viewmodel/VideoSummarizationViewModel.kt index 8f1e5134..5faf39b5 100644 --- a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/viewmodel/VideoSummarizationViewModel.kt +++ b/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/viewmodel/VideoSummarizationViewModel.kt @@ -15,16 +15,15 @@ */ package com.android.ai.samples.geminivideosummary.viewmodel -import android.net.Uri import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.ai.samples.geminivideosummary.util.VideoItem import com.android.ai.samples.geminivideosummary.util.sampleVideoList import com.google.firebase.Firebase import com.google.firebase.ai.ai import com.google.firebase.ai.type.GenerativeBackend import com.google.firebase.ai.type.content -import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -45,12 +44,8 @@ class VideoSummarizationViewModel @Inject constructor() : ViewModel() { private val _uiState = MutableStateFlow(VideoSummarizationState()) val uiState: StateFlow = _uiState.asStateFlow() - fun onVideoSelected(uri: Uri) { - _uiState.update { it.copy(selectedVideoUri = uri, summarizationState = SummarizationState.Idle) } - } - - fun onAccentSelected(locale: Locale) { - _uiState.update { it.copy(selectedAccent = locale) } + fun onVideoSelected(videoItem: VideoItem) { + _uiState.update { it.copy(selectedVideo = videoItem, summarizationState = SummarizationState.Idle) } } fun onTtsStateChanged(newTtsState: TtsState) { @@ -71,7 +66,7 @@ class VideoSummarizationViewModel @Inject constructor() : ViewModel() { } fun summarize() { - val videoSource = _uiState.value.selectedVideoUri ?: return + val videoSource = _uiState.value.selectedVideo?.uri ?: return viewModelScope.launch { val promptData = "Summarize this video in the form of top 3-4 takeaways only. Write in the form of bullet points. Don't assume if you don't know" @@ -92,7 +87,7 @@ class VideoSummarizationViewModel @Inject constructor() : ViewModel() { } _uiState.update { it.copy( - summarizationState = SummarizationState.Success(outputStringBuilder.toString()), + summarizationState = SummarizationState.Success(summarizedText = outputStringBuilder.toString()), ) } } catch (error: Exception) { @@ -109,6 +104,10 @@ class VideoSummarizationViewModel @Inject constructor() : ViewModel() { fun dismissError() { _uiState.update { it.copy(summarizationState = SummarizationState.Idle) } } + + fun redo() { + _uiState.update { it.copy(summarizationState = SummarizationState.Idle) } + } } sealed interface SummarizationState { @@ -128,7 +127,6 @@ sealed interface TtsState { } data class VideoSummarizationState( - val selectedVideoUri: Uri? = sampleVideoList.first().uri, + val selectedVideo: VideoItem? = sampleVideoList[0], val summarizationState: SummarizationState = SummarizationState.Idle, - val selectedAccent: Locale = Locale.US, ) diff --git a/ai-catalog/samples/gemini-video-summarization/src/main/res/values/strings.xml b/ai-catalog/samples/gemini-video-summarization/src/main/res/values/strings.xml index 350b0cf1..75022c32 100644 --- a/ai-catalog/samples/gemini-video-summarization/src/main/res/values/strings.xml +++ b/ai-catalog/samples/gemini-video-summarization/src/main/res/values/strings.xml @@ -2,13 +2,15 @@ Gemini Video Summary Select Video Summarize Video - Video summarization + Video Summarization + Generate a summary of a video (from a cloud URL or Youtube) with Gemini API powered by Firebase. %s%s "Text generated with Gemini : " Listen to AI output Pause See Code Dropdown for selecting a video + Video Summary Big Buck Bunny @@ -29,5 +31,8 @@ the required voice data has not been installed. an unknown error. TTS Initialization failed: %s + TEXT GENERATED WITH GEMINI + Select option + Show summary \ No newline at end of file diff --git a/ai-catalog/samples/genai-image-description/build.gradle.kts b/ai-catalog/samples/genai-image-description/build.gradle.kts index e4fbffcd..246f9daa 100644 --- a/ai-catalog/samples/genai-image-description/build.gradle.kts +++ b/ai-catalog/samples/genai-image-description/build.gradle.kts @@ -73,5 +73,8 @@ dependencies { implementation(libs.coil.compose) implementation(libs.kotlinx.coroutines.guava) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.ui.tooling.preview) + debugImplementation(libs.ui.tooling) ksp(libs.hilt.compiler) + implementation(project(":ui-component")) } diff --git a/ai-catalog/samples/genai-image-description/src/main/java/com/android/ai/samples/genai_image_description/GenAIImageDescriptionScreen.kt b/ai-catalog/samples/genai-image-description/src/main/java/com/android/ai/samples/genai_image_description/GenAIImageDescriptionScreen.kt index f7d0caa6..a038051b 100644 --- a/ai-catalog/samples/genai-image-description/src/main/java/com/android/ai/samples/genai_image_description/GenAIImageDescriptionScreen.kt +++ b/ai-catalog/samples/genai-image-description/src/main/java/com/android/ai/samples/genai_image_description/GenAIImageDescriptionScreen.kt @@ -15,165 +15,233 @@ */ package com.android.ai.samples.genai_image_description -import android.content.Intent +import android.graphics.BitmapFactory import android.net.Uri +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Code -import androidx.compose.material3.Button -import androidx.compose.material3.Card +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon 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.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +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.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage import com.android.ai.samples.geminimultimodal.R +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.theme.extendedColorScheme +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.PrimaryButton +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.UndoButton @OptIn(ExperimentalMaterial3Api::class) @Composable fun GenAIImageDescriptionScreen(viewModel: GenAIImageDescriptionViewModel = hiltViewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - var imageUri by remember { mutableStateOf(null) } + var imageUri by rememberSaveable { mutableStateOf(null) } val photoPickerLauncher = rememberLauncherForActivityResult(PickVisualMedia()) { uri -> uri?.let { imageUri = it } } + GenAIImageDescriptionScreen( + uiState = uiState, + imageUri = imageUri, + onGenerateClick = viewModel::getImageDescription, + onImagePickerClick = { + photoPickerLauncher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) + }, + onClearClick = { + viewModel.clearGeneratedText() + imageUri = null + }, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun GenAIImageDescriptionScreen( + uiState: GenAIImageDescriptionUiState, + imageUri: Uri?, + onGenerateClick: (Uri?) -> Unit, + onImagePickerClick: () -> Unit, + onClearClick: () -> Unit, +) { + val context = LocalContext.current + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - TopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - ), - title = { - Text(text = stringResource(id = R.string.genai_image_description_title_bar)) - }, - actions = { - SeeCodeButton() - }, + SampleDetailTopAppBar( + sampleName = stringResource(R.string.genai_image_description_title), + sampleDescription = stringResource(R.string.genai_image_description_subtitle), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/genai-image-description", + onBackClick = { backDispatcher?.onBackPressed() }, ) }, ) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding), - ) { - // Displayed image - Card( - modifier = Modifier - .size(width = 450.dp, height = 450.dp) - .padding(20.dp), - ) { - AsyncImage( - model = imageUri, - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxSize(), - ) - } + 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, + ) + } - // Select image button - Button( - onClick = { - photoPickerLauncher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly)) - }, - modifier = Modifier - .padding(10.dp) - .align(Alignment.CenterHorizontally), - ) { - Text( - text = stringResource(id = R.string.genai_image_description_add_image), - ) - } + val roundedCornerShape = RoundedCornerShape(40.dp) - // Generate image description button - Button( - onClick = { - viewModel.getImageDescription(imageUri) - }, - modifier = Modifier - .padding(10.dp) - .align(Alignment.CenterHorizontally), + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .padding(16.dp) + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + shape = roundedCornerShape, + ) + .clip(roundedCornerShape) + .widthIn(max = 646.dp) + .fillMaxSize() + .background(ShaderBrush(imageShader)), + contentAlignment = Alignment.Center, ) { - Text( - text = stringResource(id = R.string.genai_image_description_run_inference), - ) - } - val outputText = when (val state = uiState) { - is GenAIImageDescriptionUiState.DownloadingFeature -> stringResource( - id = R.string.image_desc_downloading, - state.bytesDownloaded, - state.bytesToDownload, - ) + if (imageUri != null) { + AsyncImage( + model = imageUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) - is GenAIImageDescriptionUiState.Error -> stringResource(state.errorMessageStringRes) - is GenAIImageDescriptionUiState.Generating -> state.partialOutput - is GenAIImageDescriptionUiState.Success -> state.generatedOutput - GenAIImageDescriptionUiState.CheckingFeatureStatus -> stringResource(id = R.string.image_desc_checking_feature_status) - else -> "" // Show nothing for the Initial state - } + if (uiState is GenAIImageDescriptionUiState.Initial) { + GenerateButton( + text = stringResource(R.string.genai_image_description_run_inference), + icon = painterResource(com.android.ai.uicomponent.R.drawable.ic_ai_edit), + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 24.dp, bottom = 24.dp), + ) { + onGenerateClick(imageUri) + } + } + } else { + PrimaryButton( + text = stringResource(R.string.genai_image_description_add_image), + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_img), + modifier = Modifier + .height(96.dp) + .padding(start = 24.dp, end = 24.dp) + .align(Alignment.Center), + onClick = onImagePickerClick, + ) + } + if ( + uiState !is GenAIImageDescriptionUiState.Initial && + uiState !is GenAIImageDescriptionUiState.CheckingFeatureStatus + ) { + val outputText = when (val state = uiState) { + is GenAIImageDescriptionUiState.DownloadingFeature -> stringResource( + id = R.string.image_desc_downloading, + state.bytesDownloaded, + state.bytesToDownload, + ) - Card( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - text = outputText, - modifier = Modifier.padding(16.dp), - ) + is GenAIImageDescriptionUiState.Error -> stringResource(state.errorMessageStringRes) + is GenAIImageDescriptionUiState.Generating -> state.partialOutput + is GenAIImageDescriptionUiState.Success -> state.generatedOutput + else -> "" // Show nothing for the Initial state + } + + UndoButton( + modifier = Modifier.align(Alignment.TopEnd) + .padding( + top = 18.dp, + end = 18.dp, + ), + ) { + onClearClick() + } + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + extendedColorScheme.startGradient, + ), + ), + ), + ) { + Text( + text = outputText, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier + .padding(top = 8.dp, bottom = 24.dp, start = 24.dp, end = 24.dp) + .align(Alignment.BottomCenter), + ) + } + } } } } } +@PreviewScreenSizes @Composable -fun SeeCodeButton() { - val context = LocalContext.current - val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/genai-image-description" - - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, githubLink.toUri()) - context.startActivity(intent) - }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon(Icons.Filled.Code, contentDescription = "See code") - Text( - modifier = Modifier.padding(start = 8.dp), - fontSize = 12.sp, - text = stringResource(R.string.genai_image_see_code), +@OptIn(ExperimentalMaterial3Api::class) +private fun GenAIImageDescriptionScreenPreview() { + AISampleCatalogTheme { + GenAIImageDescriptionScreen( + uiState = GenAIImageDescriptionUiState.Initial, + imageUri = null, + onGenerateClick = {}, + onImagePickerClick = {}, + onClearClick = {}, ) } } diff --git a/ai-catalog/samples/genai-image-description/src/main/java/com/android/ai/samples/genai_image_description/GenAIImageDescriptionViewModel.kt b/ai-catalog/samples/genai-image-description/src/main/java/com/android/ai/samples/genai_image_description/GenAIImageDescriptionViewModel.kt index 22dbfcc1..6e5ef445 100644 --- a/ai-catalog/samples/genai-image-description/src/main/java/com/android/ai/samples/genai_image_description/GenAIImageDescriptionViewModel.kt +++ b/ai-catalog/samples/genai-image-description/src/main/java/com/android/ai/samples/genai_image_description/GenAIImageDescriptionViewModel.kt @@ -59,6 +59,10 @@ class GenAIImageDescriptionViewModel @Inject constructor(val context: Applicatio ImageDescriberOptions.builder(context).build(), ) + fun clearGeneratedText() { + _uiState.value = GenAIImageDescriptionUiState.Initial + } + fun getImageDescription(imageUri: Uri?) { if (imageUri == null) { _uiState.value = GenAIImageDescriptionUiState.Error(R.string.genai_image_description_no_image_selected) diff --git a/ai-catalog/samples/genai-image-description/src/main/res/values/strings.xml b/ai-catalog/samples/genai-image-description/src/main/res/values/strings.xml index 556f0a0e..7b292dcd 100644 --- a/ai-catalog/samples/genai-image-description/src/main/res/values/strings.xml +++ b/ai-catalog/samples/genai-image-description/src/main/res/values/strings.xml @@ -1,8 +1,9 @@ - Image Description with Nano + Image Description with Nano + Generate short descriptions of images on-device with GenAI API powered by Gemini Nano Add image - Generate image description + Image description No image selected Feature is not available on this device See code diff --git a/ai-catalog/samples/genai-summarization/build.gradle.kts b/ai-catalog/samples/genai-summarization/build.gradle.kts index 511b45ae..22d965c2 100644 --- a/ai-catalog/samples/genai-summarization/build.gradle.kts +++ b/ai-catalog/samples/genai-summarization/build.gradle.kts @@ -72,5 +72,7 @@ dependencies { implementation(libs.genai.summarization) implementation(libs.kotlinx.coroutines.guava) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(project(":ui-component")) + debugImplementation(libs.ui.tooling) ksp(libs.hilt.compiler) } diff --git a/ai-catalog/samples/genai-summarization/src/main/java/com/android/ai/samples/genai_summarization/GenAISummarizationScreen.kt b/ai-catalog/samples/genai-summarization/src/main/java/com/android/ai/samples/genai_summarization/GenAISummarizationScreen.kt index 3cd66dec..6613f010 100644 --- a/ai-catalog/samples/genai-summarization/src/main/java/com/android/ai/samples/genai_summarization/GenAISummarizationScreen.kt +++ b/ai-catalog/samples/genai-summarization/src/main/java/com/android/ai/samples/genai_summarization/GenAISummarizationScreen.kt @@ -15,171 +15,320 @@ */ package com.android.ai.samples.genai_summarization -import android.content.Intent +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +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.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Code -import androidx.compose.material3.Button +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults.topAppBarColors -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.ai.samples.geminimultimodal.R +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.theme.surfaceContainerHighestLight +import com.android.ai.uicomponent.BackButton +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.SecondaryButton @OptIn(ExperimentalMaterial3Api::class) @Composable fun GenAISummarizationScreen(viewModel: GenAISummarizationViewModel = hiltViewModel()) { val sampleTextOptions = stringArrayResource(R.array.summarization_sample_text) - - val sheetState = rememberModalBottomSheetState() - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - var textInput by remember { mutableStateOf("") } + var textInput by rememberSaveable { mutableStateOf("") } + GenAISummarizationContent( + uiState = uiState, + textInput = textInput, + onTextInputChanged = { textInput = it }, + onSummarizeClicked = { viewModel.summarize(textInput) }, + onClearClicked = { + viewModel.clearGeneratedSummary() + textInput = "" + }, + onAddSampleTextClicked = { textInput = sampleTextOptions.random() }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GenAISummarizationContent( + uiState: GenAISummarizationUiState, + textInput: String, + onTextInputChanged: (String) -> Unit, + onSummarizeClicked: () -> Unit, + onClearClicked: () -> Unit, + onAddSampleTextClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), topBar = { - TopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - title = { - Text(text = stringResource(id = R.string.genai_summarization_title_bar)) - }, - actions = { - SeeCodeButton() - }, + SampleDetailTopAppBar( + sampleName = stringResource(R.string.genai_summarization_title_bar), + sampleDescription = stringResource(R.string.genai_summarization_description), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/genai-summarization", + onBackClick = { backDispatcher?.onBackPressed() }, ) }, ) { innerPadding -> - - Column( - Modifier - .padding(12.dp) - .padding(innerPadding), + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .clip(RoundedCornerShape(40.dp)) + .background(color = surfaceContainerHighestLight) + .padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 32.dp), + contentAlignment = Alignment.Center, ) { - // Text input box - TextField( - value = textInput, - onValueChange = { textInput = it }, - label = { Text(stringResource(id = R.string.genai_summarization_text_input_label)) }, - modifier = Modifier - .fillMaxSize() - .weight(.8f), - ) - - // Summarize button - Button( - onClick = { - viewModel.summarize(textInput) - }, - enabled = textInput.isNotEmpty(), - modifier = Modifier - .padding(10.dp) - .align(Alignment.CenterHorizontally), + Column( + Modifier + .padding(top = 16.dp) + .imePadding() + .widthIn(max = 646.dp) + .fillMaxHeight(), ) { - Text( - text = stringResource(id = R.string.genai_summarization_summarize_btn), - ) - } + when (val state = uiState) { + GenAISummarizationUiState.CheckingFeatureStatus -> + // TODO: Replace with loading animation + DisplayedText( + textToDisplay = stringResource(id = R.string.summarization_checking_feature_status), + isStatusText = true, + modifier = Modifier.fillMaxWidth(), + ) - // Extra options buttons - Row(modifier = Modifier.align(Alignment.CenterHorizontally)) { - OutlinedButton( - onClick = { textInput = sampleTextOptions.random() }, - Modifier.padding(5.dp), - ) { - Text( - stringResource(id = R.string.genai_summarization_add_text_btn), - ) - } - OutlinedButton( - onClick = { textInput = "" }, - Modifier.padding(5.dp), - ) { - Text( - stringResource(id = R.string.genai_summarization_reset_btn), - ) - } - } - } + is GenAISummarizationUiState.DownloadingFeature -> + DisplayedText( + stringResource( + id = R.string.summarization_downloading, + state.bytesDownloaded, + state.bytesToDownload, + ), + isStatusText = true, + modifier = Modifier.fillMaxWidth(), + ) - if (uiState !is GenAISummarizationUiState.Initial) { - val bottomSheetText = when (val state = uiState) { - is GenAISummarizationUiState.DownloadingFeature -> stringResource( - id = R.string.summarization_downloading, - state.bytesDownloaded, - state.bytesToDownload, - ) - is GenAISummarizationUiState.Error -> state.errorMessage - is GenAISummarizationUiState.Generating -> state.generatedOutput - GenAISummarizationUiState.Initial -> "" - is GenAISummarizationUiState.Success -> state.generatedOutput - GenAISummarizationUiState.CheckingFeatureStatus -> stringResource(id = R.string.summarization_checking_feature_status) - } - ModalBottomSheet( - onDismissRequest = { - viewModel.clearGeneratedSummary() - }, - sheetState = sheetState, - ) { - Text( - text = bottomSheetText, - modifier = Modifier.padding( - top = 8.dp, - bottom = 24.dp, - start = 24.dp, - end = 24.dp, - ), - ) + is GenAISummarizationUiState.Error -> + DisplayedText( + state.errorMessage, + isStatusText = true, + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 646.dp) + .align(Alignment.CenterHorizontally), + ) + + GenAISummarizationUiState.Initial -> { + Column( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 646.dp) + .weight(1f) + .align(Alignment.CenterHorizontally), + ) { + TextField( + placeholder = { Text(stringResource(R.string.genai_summarization_text_input_label)) }, + value = textInput, onValueChange = onTextInputChanged, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .padding(4.dp) + .weight(1f), + ) + + if (textInput.isEmpty()) { + SecondaryButton( + text = stringResource(R.string.genai_summarization_add_text_btn), + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_add_text), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + onClick = onAddSampleTextClicked, + ) + } else { + GenerateButton( + text = stringResource(R.string.genai_summarization_summarize_btn), + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_text), + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + onClick = onSummarizeClicked, + ) + } + } + } + + is GenAISummarizationUiState.Generating -> + DisplayedText( + state.generatedOutput, + modifier = Modifier.fillMaxWidth(), + ) + + is GenAISummarizationUiState.Success -> { + DisplayedText(state.generatedOutput, modifier = modifier.weight(1f).fillMaxWidth()) + + BackButton( + modifier = Modifier + .padding(start = 8.dp, top = 8.dp), + imageVector = Icons.AutoMirrored.Filled.Undo, + onClick = onClearClicked, + ) + } + } } } } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun SeeCodeButton() { - val context = LocalContext.current - val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/genai-summarization" - - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, githubLink.toUri()) - context.startActivity(intent) +fun DisplayedText(textToDisplay: String, modifier: Modifier = Modifier, isStatusText: Boolean = false) { + Text( + textToDisplay, modifier = modifier.padding(8.dp), + fontSize = if (!isStatusText) { + 16.sp + } else { + 24.sp }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon(Icons.Filled.Code, contentDescription = "See code") - Text( - modifier = Modifier.padding(start = 8.dp), - fontSize = 12.sp, - text = stringResource(R.string.summarization_see_code), + ) +} + +@Preview +@Composable +fun GenAISummarizationContentPreview_Initial_EmptyText() { + AISampleCatalogTheme { + GenAISummarizationContent( + uiState = GenAISummarizationUiState.Initial, + textInput = "", + onTextInputChanged = {}, + onSummarizeClicked = {}, + onClearClicked = {}, + onAddSampleTextClicked = {}, + ) + } +} + +@Preview +@Composable +fun GenAISummarizationContentPreview_Initial_WithText() { + AISampleCatalogTheme { + GenAISummarizationContent( + uiState = GenAISummarizationUiState.Initial, + textInput = stringResource(R.string.summarization_sample_text_1), + onTextInputChanged = {}, + onSummarizeClicked = {}, + onClearClicked = {}, + onAddSampleTextClicked = {}, + ) + } +} + +@Preview +@Composable +fun GenAISummarizationContentPreview_CheckingFeatureStatus() { + AISampleCatalogTheme { + GenAISummarizationContent( + uiState = GenAISummarizationUiState.CheckingFeatureStatus, + textInput = "", + onTextInputChanged = {}, + onSummarizeClicked = {}, + onClearClicked = {}, + onAddSampleTextClicked = {}, + ) + } +} + +@Preview +@Composable +fun GenAISummarizationContentPreview_DownloadingFeature() { + AISampleCatalogTheme { + GenAISummarizationContent( + uiState = GenAISummarizationUiState.DownloadingFeature(100, 50), + textInput = "", + onTextInputChanged = {}, + onSummarizeClicked = {}, + onClearClicked = {}, + onAddSampleTextClicked = {}, + ) + } +} + +@Preview +@Composable +fun GenAISummarizationContentPreview_Error() { + AISampleCatalogTheme { + GenAISummarizationContent( + uiState = GenAISummarizationUiState.Error(stringResource(R.string.summarization_generation_error)), + textInput = "", + onTextInputChanged = {}, + onSummarizeClicked = {}, + onClearClicked = {}, + onAddSampleTextClicked = {}, + ) + } +} + +@Preview +@Composable +fun GenAISummarizationContentPreview_Generating() { + AISampleCatalogTheme { + GenAISummarizationContent( + uiState = GenAISummarizationUiState.Generating("Generating summary..."), + textInput = "This is a sample text to summarize.", + onTextInputChanged = {}, + onSummarizeClicked = {}, + onClearClicked = {}, + onAddSampleTextClicked = {}, + ) + } +} + +@Preview +@Composable +fun GenAISummarizationContentPreview_Success() { + AISampleCatalogTheme { + GenAISummarizationContent( + uiState = GenAISummarizationUiState.Success("This is the generated summary."), + textInput = "This is a sample text to summarize.", + onTextInputChanged = {}, + onSummarizeClicked = {}, + onClearClicked = {}, + onAddSampleTextClicked = {}, ) } } diff --git a/ai-catalog/samples/genai-summarization/src/main/res/values/strings.xml b/ai-catalog/samples/genai-summarization/src/main/res/values/strings.xml index 0eb2a603..d0d2c99f 100644 --- a/ai-catalog/samples/genai-summarization/src/main/res/values/strings.xml +++ b/ai-catalog/samples/genai-summarization/src/main/res/values/strings.xml @@ -1,9 +1,10 @@ - GenAI Summarization with Nano - Enter text to summarizer - Summarize - Add example text + Summarize + Summarize articles and conversations powered by Gemini Nano. + Enter text to summarize + SUMMARIZE + Add sample text Reset @string/summarization_sample_text_1 diff --git a/ai-catalog/samples/genai-writing-assistance/build.gradle.kts b/ai-catalog/samples/genai-writing-assistance/build.gradle.kts index b1a75fcc..aae39550 100644 --- a/ai-catalog/samples/genai-writing-assistance/build.gradle.kts +++ b/ai-catalog/samples/genai-writing-assistance/build.gradle.kts @@ -70,5 +70,7 @@ dependencies { implementation(libs.genai.rewrite) implementation(libs.kotlinx.coroutines.guava) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(project(":ui-component")) + debugImplementation(libs.ui.tooling) ksp(libs.hilt.compiler) } diff --git a/ai-catalog/samples/genai-writing-assistance/src/main/java/com/android/ai/samples/genai_writing_assistance/GenAIWritingAssistanceScreen.kt b/ai-catalog/samples/genai-writing-assistance/src/main/java/com/android/ai/samples/genai_writing_assistance/GenAIWritingAssistanceScreen.kt index 89bc8850..04d0dcda 100644 --- a/ai-catalog/samples/genai-writing-assistance/src/main/java/com/android/ai/samples/genai_writing_assistance/GenAIWritingAssistanceScreen.kt +++ b/ai-catalog/samples/genai-writing-assistance/src/main/java/com/android/ai/samples/genai_writing_assistance/GenAIWritingAssistanceScreen.kt @@ -15,212 +15,245 @@ */ package com.android.ai.samples.genai_writing_assistance -import android.content.Intent +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.background 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Code -import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog -import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.ai.samples.geminimultimodal.R +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.theme.surfaceContainerHighestLight +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.SecondaryButton +import com.android.ai.uicomponent.UndoButton import com.google.mlkit.genai.rewriting.RewriterOptions @OptIn(ExperimentalMaterial3Api::class) @Composable fun GenAIWritingAssistanceScreen(viewModel: GenAIWritingAssistanceViewModel = hiltViewModel()) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - - var showRewriteOptionsDialog by remember { mutableStateOf(false) } + var showRewriteOptionsDialog by rememberSaveable { mutableStateOf(false) } val context = LocalContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val proofreadSampleTextOptions = stringArrayResource(R.array.proofread_sample_text) val rewriteSampleTextOptions = stringArrayResource(R.array.rewrite_sample_text) - var textInput by remember { mutableStateOf("") } + var textInput by rememberSaveable { mutableStateOf("") } + + GenAIWritingAssistanceContent( + uiState = uiState, + textInput = textInput, + onTextInputChanged = { textInput = it }, + onProofreadClicked = { viewModel.proofread(textInput) }, + onRewriteClicked = { + showRewriteOptionsDialog = true + }, + onClearClicked = { + viewModel.clearGeneratedText() + textInput = "" + }, + onAddProofreadTextClicked = { textInput = proofreadSampleTextOptions.random() }, + onAddRewriteTextClicked = { textInput = rewriteSampleTextOptions.random() }, + ) + + if (showRewriteOptionsDialog) { + RewriteOptionsDialog( + onConfirm = { rewriteStyleSelected -> + showRewriteOptionsDialog = false + viewModel.rewrite( + textInput, + rewriteStyleSelected.rewriteStyle, + context, + ) + }, + onDismissRequest = { + showRewriteOptionsDialog = false + }, + ) + } +} +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GenAIWritingAssistanceContent( + uiState: GenAIWritingAssistanceUiState, + textInput: String, + onTextInputChanged: (String) -> Unit, + onProofreadClicked: () -> Unit, + onRewriteClicked: () -> Unit, + onClearClicked: () -> Unit, + onAddProofreadTextClicked: () -> Unit, + onAddRewriteTextClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), topBar = { - TopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - ), - title = { - Text(text = stringResource(id = R.string.genai_writing_assistance_title_bar)) - }, - actions = { - SeeCodeButton() - }, + SampleDetailTopAppBar( + sampleName = stringResource(R.string.genai_writing_assistance_title_bar), + sampleDescription = stringResource(R.string.genai_writing_assistance_description), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/genai-writing-assistance", + onBackClick = { backDispatcher?.onBackPressed() }, ) }, ) { innerPadding -> - - Column( + Box( Modifier - .padding(12.dp) - .padding(innerPadding), + .padding(innerPadding) + .fillMaxSize() + .clip(RoundedCornerShape(40.dp)) + .background(color = surfaceContainerHighestLight) + .padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 32.dp), + contentAlignment = Alignment.Center, ) { - // Text input box - TextField( - value = textInput, - onValueChange = { textInput = it }, - label = { Text(stringResource(id = R.string.genai_writing_assistance_text_input_label)) }, - modifier = Modifier - .fillMaxSize() - .weight(.8f), - ) + Column( + Modifier + .padding(top = 16.dp) + .imePadding() + .widthIn(max = 646.dp) + .fillMaxHeight(), + ) { + when (val state = uiState) { + GenAIWritingAssistanceUiState.CheckingFeatureStatus -> + // TODO: Replace with loading animation + DisplayedText( + textToDisplay = stringResource(id = R.string.checking_feature_status), + isStatusText = true, + ) - val outputText = when (val state = uiState) { - is GenAIWritingAssistanceUiState.DownloadingFeature -> stringResource( - id = R.string.genai_writing_assistance_downloading, - state.bytesDownloaded, - state.bytesToDownload, - ) + is GenAIWritingAssistanceUiState.DownloadingFeature -> + DisplayedText( + stringResource( + id = R.string.genai_writing_assistance_downloading, + state.bytesDownloaded, + state.bytesToDownload, + ), + isStatusText = true, + modifier = Modifier.fillMaxWidth(), + ) - is GenAIWritingAssistanceUiState.Error -> stringResource(state.errorMessageStringRes) - is GenAIWritingAssistanceUiState.Success -> state.generatedOutput - is GenAIWritingAssistanceUiState.Generating -> stringResource(id = R.string.generating) - GenAIWritingAssistanceUiState.CheckingFeatureStatus -> stringResource(id = R.string.checking_feature_status) - GenAIWritingAssistanceUiState.Initial -> "" // Show nothing for the Initial state - } + is GenAIWritingAssistanceUiState.Error -> + DisplayedText(stringResource(state.errorMessageStringRes), isStatusText = true) - // Output box - Card( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - text = outputText, - modifier = Modifier.padding(16.dp), - ) - } + GenAIWritingAssistanceUiState.Initial -> { + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - // Proofread button - Button( - onClick = { - viewModel.proofread(textInput, context) - }, - Modifier.padding(8.dp), - ) { - Text( - stringResource(id = R.string.genai_writing_assistance_proofread_btn), - ) - } - Button( - onClick = { - showRewriteOptionsDialog = true - }, - Modifier.padding(8.dp), - ) { - Text( - stringResource(id = R.string.genai_writing_assistance_rewrite_btn), - ) - } - } + TextField( + placeholder = { Text(stringResource(R.string.genai_writing_assistance_text_input_label)) }, + value = textInput, onValueChange = onTextInputChanged, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + modifier = Modifier + .padding(4.dp) + .weight(1f), + ) - // Extra options buttons - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - onClick = { textInput = proofreadSampleTextOptions.random() }, - Modifier - .weight(1f) - .padding(4.dp), - ) { - Text( - stringResource(id = R.string.genai_writing_assistance_proofread_sample_text_btn), - textAlign = TextAlign.Center, - ) - } + if (textInput.isEmpty()) { + SecondaryButton( + text = stringResource(R.string.genai_writing_assistance_proofread_sample_text_btn), + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_add_text), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + onClick = onAddProofreadTextClicked, + modifier = Modifier.padding(start = 8.dp, top = 12.dp), + ) + SecondaryButton( + text = stringResource(R.string.genai_writing_assistance_rewrite_sample_text_btn), + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_add_text), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + onClick = onAddRewriteTextClicked, + modifier = Modifier.padding(start = 8.dp, top = 12.dp), + ) + } else { + GenerateButton( + text = stringResource(R.string.genai_writing_assistance_proofread_btn), + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_text), + modifier = Modifier.padding(start = 8.dp, top = 12.dp), + onClick = onProofreadClicked, + ) + GenerateButton( + text = stringResource(R.string.genai_writing_assistance_rewrite_btn), + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_text), + modifier = Modifier.padding(start = 8.dp, top = 12.dp), + onClick = onRewriteClicked, + ) + } + } + } - OutlinedButton( - onClick = { textInput = rewriteSampleTextOptions.random() }, - Modifier - .weight(1f) - .padding(4.dp), - ) { - Text( - stringResource(id = R.string.genai_writing_assistance_rewrite_sample_text_btn), - textAlign = TextAlign.Center, - ) - } + is GenAIWritingAssistanceUiState.Generating -> + // TODO: Replace with loading animation + DisplayedText(stringResource(R.string.genai_writing_assistance_generating), modifier = Modifier.fillMaxWidth()) - OutlinedButton( - onClick = { - textInput = "" - viewModel.clearGeneratedText() - }, - Modifier - .weight(1f) - .padding(4.dp), - ) { - Text( - stringResource(id = R.string.genai_writing_assistance_reset_btn), - textAlign = TextAlign.Center, - ) + is GenAIWritingAssistanceUiState.Success -> { + DisplayedText(state.generatedOutput, modifier = modifier.weight(1f).fillMaxWidth()) + + UndoButton( + modifier = Modifier.padding(start = 8.dp, top = 8.dp), + onClick = onClearClicked, + ) + } } } } - - if (showRewriteOptionsDialog) { - RewriteOptionsDialog( - onConfirm = { rewriteStyleSelected -> - showRewriteOptionsDialog = false - viewModel.rewrite( - textInput, - rewriteStyleSelected.rewriteStyle, - context, - ) - }, - onDismissRequest = { - showRewriteOptionsDialog = false - }, - ) - } } } @@ -286,23 +319,102 @@ fun RewriteOptionsDialog(onConfirm: (rewriteStyle: RewriteStyle) -> Unit, onDism } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun SeeCodeButton() { - val context = LocalContext.current - val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/genai-writing-assistance" - - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, githubLink.toUri()) - context.startActivity(intent) +fun DisplayedText(textToDisplay: String, modifier: Modifier = Modifier, isStatusText: Boolean = false) { + Text( + textToDisplay, modifier = modifier.padding(8.dp), + fontSize = if (!isStatusText) { + 16.sp + } else { + 24.sp }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon(Icons.Filled.Code, contentDescription = "See code") - Text( - modifier = Modifier.padding(start = 8.dp), - fontSize = 12.sp, - text = stringResource(R.string.genai_writing_assistance_see_code), + ) +} + +@Preview +@Composable +fun GenAIWritingAssistanceContentPreview_Initial_EmptyText() { + AISampleCatalogTheme { + GenAIWritingAssistanceContent( + uiState = GenAIWritingAssistanceUiState.Initial, + textInput = "", + onTextInputChanged = {}, + onProofreadClicked = {}, + onRewriteClicked = {}, + onClearClicked = {}, + onAddProofreadTextClicked = {}, + onAddRewriteTextClicked = {}, + ) + } +} + +@Preview +@Composable +fun GenAIWritingAssistanceContentPreview_Initial_WithTextText() { + AISampleCatalogTheme { + GenAIWritingAssistanceContent( + uiState = GenAIWritingAssistanceUiState.Initial, + textInput = stringResource(R.string.genai_proofread_sample_text_1), + onTextInputChanged = {}, + onProofreadClicked = {}, + onRewriteClicked = {}, + onClearClicked = {}, + onAddProofreadTextClicked = {}, + onAddRewriteTextClicked = {}, + ) + } +} + +@Preview +@Composable +fun GenAIWritingAssistanceContentPreview_CheckingFeatureStatus() { + AISampleCatalogTheme { + GenAIWritingAssistanceContent( + uiState = GenAIWritingAssistanceUiState.CheckingFeatureStatus, + textInput = "", + onTextInputChanged = {}, + onProofreadClicked = {}, + onRewriteClicked = {}, + onClearClicked = {}, + onAddProofreadTextClicked = {}, + onAddRewriteTextClicked = {}, + ) + } +} + +@Preview +@Composable +fun GenAIWritingAssistanceContentPreview_Error() { + AISampleCatalogTheme { + GenAIWritingAssistanceContent( + uiState = GenAIWritingAssistanceUiState.Error(R.string.feature_check_fail), + textInput = "", + onTextInputChanged = {}, + onProofreadClicked = {}, + onRewriteClicked = {}, + onClearClicked = {}, + onAddProofreadTextClicked = {}, + onAddRewriteTextClicked = {}, + ) + } +} + +@Preview +@Composable +fun GenAIWritingAssistanceContentPreview_Success() { + AISampleCatalogTheme { + GenAIWritingAssistanceContent( + uiState = GenAIWritingAssistanceUiState.Success( + "A fluffy golden retriever, wearing tiny spectacles, diligently typed lines of code", + ), + textInput = "", + onTextInputChanged = {}, + onProofreadClicked = {}, + onRewriteClicked = {}, + onClearClicked = {}, + onAddProofreadTextClicked = {}, + onAddRewriteTextClicked = {}, ) } } diff --git a/ai-catalog/samples/genai-writing-assistance/src/main/java/com/android/ai/samples/genai_writing_assistance/GenAIWritingAssistanceViewModel.kt b/ai-catalog/samples/genai-writing-assistance/src/main/java/com/android/ai/samples/genai_writing_assistance/GenAIWritingAssistanceViewModel.kt index e54d422b..c4b17e0f 100644 --- a/ai-catalog/samples/genai-writing-assistance/src/main/java/com/android/ai/samples/genai_writing_assistance/GenAIWritingAssistanceViewModel.kt +++ b/ai-catalog/samples/genai-writing-assistance/src/main/java/com/android/ai/samples/genai_writing_assistance/GenAIWritingAssistanceViewModel.kt @@ -53,7 +53,7 @@ sealed class GenAIWritingAssistanceUiState { data class Error(@StringRes val errorMessageStringRes: Int) : GenAIWritingAssistanceUiState() } -class GenAIWritingAssistanceViewModel @Inject constructor(val context: Application) : AndroidViewModel(context) { +class GenAIWritingAssistanceViewModel @Inject constructor(context: Application) : AndroidViewModel(context) { private val _uiState = MutableStateFlow(GenAIWritingAssistanceUiState.Initial) val uiState: StateFlow = _uiState.asStateFlow() @@ -67,7 +67,7 @@ class GenAIWritingAssistanceViewModel @Inject constructor(val context: Applicati private var rewriter: Rewriter? = null - fun proofread(text: String, context: Context) { + fun proofread(text: String) { if (text.isEmpty()) { _uiState.value = GenAIWritingAssistanceUiState.Error(R.string.genai_writing_assistance_no_input) return @@ -81,7 +81,7 @@ class GenAIWritingAssistanceViewModel @Inject constructor(val context: Applicati proofreadFeatureStatus = proofreader.checkFeatureStatus().await() } catch (error: Exception) { _uiState.value = GenAIWritingAssistanceUiState.Error(R.string.feature_check_fail) - Log.e("GenAIImageDesc", "Error checking feature status", error) + Log.e("GenAIWriting", "Error checking feature status", error) } if (proofreadFeatureStatus == FeatureStatus.UNAVAILABLE) { diff --git a/ai-catalog/samples/genai-writing-assistance/src/main/res/values/strings.xml b/ai-catalog/samples/genai-writing-assistance/src/main/res/values/strings.xml index 3e6398a6..5d7d1720 100644 --- a/ai-catalog/samples/genai-writing-assistance/src/main/res/values/strings.xml +++ b/ai-catalog/samples/genai-writing-assistance/src/main/res/values/strings.xml @@ -1,13 +1,14 @@ - Writing Assistance with Nano - Enter text for writing assistance - Proofread - Rewrite + Polish text + Proofread and rewrite short content on-device with ML Kit GenAI APIs powered by Gemini Nano. + Enter text to polish. + PROOFREAD + REWRITE Dismiss Confirm - Add text to proofread - Add text to rewrite + ADD TEXT TO PROOFREAD + ADD TEXT TO REWRITE @string/genai_proofread_sample_text_1 @string/genai_proofread_sample_text_2 @@ -33,5 +34,6 @@ Checking feature status Feature download failed Downloading feature + Generating… See code - + \ No newline at end of file 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 cb7a4af4..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,174 +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)) - }, - actions = { - SeeCodeButton() - }, + 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 - Spacer(modifier = Modifier.height(16.dp)) + 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), + ) + + 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 9053f858..00000000 --- a/ai-catalog/samples/imagen-editing/src/main/java/com/android/ai/samples/imagenediting/ui/ImagenEditingSeeCodeButton.kt +++ /dev/null @@ -1,51 +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.content.Intent -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Code -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.core.net.toUri -import com.android.ai.samples.imagenediting.R - -@Composable -fun SeeCodeButton() { - val context = LocalContext.current - val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/imagen-editing" - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, githubLink.toUri()) - context.startActivity(intent) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - ) { - Icon(Icons.Filled.Code, contentDescription = null) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.editing_see_code), - ) - } -} 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..ce6f8620 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,6 +43,9 @@ 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 +73,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 +85,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/samples/imagen/build.gradle.kts b/ai-catalog/samples/imagen/build.gradle.kts index d0b380bd..b2ac122a 100644 --- a/ai-catalog/samples/imagen/build.gradle.kts +++ b/ai-catalog/samples/imagen/build.gradle.kts @@ -75,6 +75,8 @@ dependencies { debugImplementation(libs.ui.tooling) ksp(libs.hilt.compiler) + implementation(project(":ui-component")) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/ai-catalog/samples/imagen/src/main/java/com/android/ai/samples/imagen/ui/GeneratedContent.kt b/ai-catalog/samples/imagen/src/main/java/com/android/ai/samples/imagen/ui/GeneratedContent.kt index 71017f10..7248170d 100644 --- a/ai-catalog/samples/imagen/src/main/java/com/android/ai/samples/imagen/ui/GeneratedContent.kt +++ b/ai-catalog/samples/imagen/src/main/java/com/android/ai/samples/imagen/ui/GeneratedContent.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -37,24 +36,11 @@ fun GeneratedContent(uiState: ImagenUIState, modifier: Modifier = Modifier) { ) { when (uiState) { ImagenUIState.Initial -> { - Text( - text = stringResource(R.string.imagen_placeholder), - modifier = Modifier - .fillMaxSize() - .wrapContentSize(Alignment.Center), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodySmall, - ) + // } ImagenUIState.Loading -> { - Text( - text = stringResource(R.string.generating_label), - modifier = Modifier - .fillMaxSize() - .wrapContentSize(Alignment.Center), - textAlign = TextAlign.Center, - ) + // } is ImagenUIState.ImageGenerated -> { diff --git a/ai-catalog/samples/imagen/src/main/java/com/android/ai/samples/imagen/ui/ImagenScreen.kt b/ai-catalog/samples/imagen/src/main/java/com/android/ai/samples/imagen/ui/ImagenScreen.kt index df276ac6..757f3103 100644 --- a/ai-catalog/samples/imagen/src/main/java/com/android/ai/samples/imagen/ui/ImagenScreen.kt +++ b/ai-catalog/samples/imagen/src/main/java/com/android/ai/samples/imagen/ui/ImagenScreen.kt @@ -15,38 +15,63 @@ */ package com.android.ai.samples.imagen.ui -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 android.graphics.BitmapFactory +import android.widget.Toast +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.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.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.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.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.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.ai.samples.imagen.R +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.TextInput -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ImagenScreen(viewModel: ImagenViewModel = hiltViewModel()) { val uiState: ImagenUIState by viewModel.uiState.collectAsStateWithLifecycle() + if (uiState is ImagenUIState.Error) { + Toast.makeText(LocalContext.current, (uiState as ImagenUIState.Error).message, Toast.LENGTH_SHORT).show() + } + ImagenScreen( uiState = uiState, onGenerateClick = viewModel::generateImage, @@ -54,59 +79,114 @@ fun ImagenScreen(viewModel: ImagenViewModel = hiltViewModel()) { } @Composable -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) private fun ImagenScreen(uiState: ImagenUIState, onGenerateClick: (String) -> Unit) { val isGenerating = uiState is ImagenUIState.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.title_image_generation_screen)) - }, - actions = { - SeeCodeButton() - }, + SampleDetailTopAppBar( + sampleName = stringResource(R.string.title_image_generation_screen), + sampleDescription = stringResource(R.string.subtitle_image_generation_screen), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/imagen", + onBackClick = { backDispatcher?.onBackPressed() }, ) }, + modifier = Modifier.fillMaxWidth(), ) { innerPadding -> - Column( - Modifier - .verticalScroll(rememberScrollState()) - .padding(16.dp) - .padding(innerPadding), - ) { - GeneratedContent( - uiState = uiState, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f), - ) - Spacer(modifier = Modifier.height(16.dp)) + val context = LocalContext.current + val imageBitmap = remember { + val bitmap = BitmapFactory.decodeResource(context.resources, com.android.ai.uicomponent.R.drawable.img_fill) + bitmap.asImageBitmap() + } - GenerationInput( - onGenerateClick = onGenerateClick, - enabled = !isGenerating, - modifier = Modifier.fillMaxWidth(), + val imageShader = remember { + ImageShader( + image = imageBitmap, + tileModeX = TileMode.Repeated, + tileModeY = TileMode.Repeated, ) + } + + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + 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, + ) { + + when (uiState) { + is ImagenUIState.ImageGenerated -> Image( + bitmap = uiState.bitmap.asImageBitmap(), + contentDescription = uiState.contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + ImagenUIState.Loading -> { + ContainedLoadingIndicator( + modifier = Modifier.size(60.dp) + .align(Alignment.Center), + ) + } + else -> {} + } - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime)) + val textFieldState = rememberTextFieldState() + val keyboardController = LocalSoftwareKeyboardController.current + + TextInput( + state = textFieldState, + placeholder = stringResource(R.string.placeholder_prompt), + 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), + ) + } } } } -@Preview +@PreviewScreenSizes @Composable @OptIn(ExperimentalMaterial3Api::class) private fun ImagenScreenPreview() { - ImagenScreen( - uiState = ImagenUIState.Initial, - onGenerateClick = {}, - ) + AISampleCatalogTheme { + ImagenScreen( + uiState = ImagenUIState.Initial, + onGenerateClick = {}, + ) + } } diff --git a/ai-catalog/samples/imagen/src/main/java/com/android/ai/samples/imagen/ui/SeeCodeButton.kt b/ai-catalog/samples/imagen/src/main/java/com/android/ai/samples/imagen/ui/SeeCodeButton.kt deleted file mode 100644 index df3578f8..00000000 --- a/ai-catalog/samples/imagen/src/main/java/com/android/ai/samples/imagen/ui/SeeCodeButton.kt +++ /dev/null @@ -1,51 +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.imagen.ui - -import android.content.Intent -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Code -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.core.net.toUri -import com.android.ai.samples.imagen.R - -@Composable -fun SeeCodeButton() { - val context = LocalContext.current - val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/imagen" - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, githubLink.toUri()) - context.startActivity(intent) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - ) { - Icon(Icons.Filled.Code, contentDescription = null) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.see_code), - ) - } -} diff --git a/ai-catalog/samples/imagen/src/main/res/values/strings.xml b/ai-catalog/samples/imagen/src/main/res/values/strings.xml index f565168d..9edf57df 100644 --- a/ai-catalog/samples/imagen/src/main/res/values/strings.xml +++ b/ai-catalog/samples/imagen/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ See Code An oil painting of Alcatraz Imagen image generation + Generate images with Imagen, Google image generation model Generate Generating… Prompt diff --git a/ai-catalog/samples/magic-selfie/build.gradle.kts b/ai-catalog/samples/magic-selfie/build.gradle.kts index 3b71094f..0b5cd2d6 100644 --- a/ai-catalog/samples/magic-selfie/build.gradle.kts +++ b/ai-catalog/samples/magic-selfie/build.gradle.kts @@ -69,9 +69,14 @@ dependencies { implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) implementation(libs.androidx.runtime.livedata) - implementation("com.google.android.gms:play-services-mlkit-subject-segmentation:16.0.0-beta1") + implementation(libs.mlkit.segmentation) + implementation(libs.ui.tooling.preview) + debugImplementation(libs.ui.tooling) + ksp(libs.hilt.compiler) + implementation(project(":ui-component")) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieScreen.kt b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieScreen.kt index c7ae316a..3fff97e5 100644 --- a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieScreen.kt +++ b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieScreen.kt @@ -19,202 +19,255 @@ import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.provider.MediaStore +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CameraAlt -import androidx.compose.material.icons.filled.SmartToy -import androidx.compose.material3.Button -import androidx.compose.material3.Card +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TopAppBar +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.TopAppBarDefaults.topAppBarColors import androidx.compose.material3.rememberTopAppBarState 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.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.core.content.FileProvider import androidx.hilt.navigation.compose.hiltViewModel import com.android.ai.samples.magicselfie.R +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.uicomponent.GenerateButton +import com.android.ai.uicomponent.PrimaryButton +import com.android.ai.uicomponent.SampleDetailTopAppBar +import com.android.ai.uicomponent.SecondaryButton +import com.android.ai.uicomponent.TextInput import java.io.File @OptIn(ExperimentalMaterial3Api::class) @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable fun MagicSelfieScreen(viewModel: MagicSelfieViewModel = hiltViewModel()) { - val context = LocalContext.current val uiState by viewModel.uiState.collectAsState() - - val topAppBarState = rememberTopAppBarState() - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) - - val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) - cameraIntent.putExtra("android.intent.extras.CAMERA_FACING", android.hardware.camera2.CameraCharacteristics.LENS_FACING_FRONT) - cameraIntent.putExtra("android.intent.extras.LENS_FACING_FRONT", 1) - cameraIntent.putExtra("android.intent.extra.USE_FRONT_CAMERA", true) - val currentContext = LocalContext.current - val tempSelfiePhoto = File.createTempFile("tmp_selfie_picture", ".jpg", currentContext.cacheDir) - val tempSelfiePhotoUri = FileProvider.getUriForFile(currentContext, currentContext.packageName + ".provider", tempSelfiePhoto) - - cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, tempSelfiePhotoUri) - cameraIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current var selfieBitmap by remember { mutableStateOf(null) } - var editTextValue by remember { mutableStateOf("A very scenic view from the edge of the grand canyon") } + var tempSelfiePhotoFile by remember { mutableStateOf(null) } - val resultLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { + val cameraIntent = remember { + Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra("android.intent.extras.CAMERA_FACING", android.hardware.camera2.CameraCharacteristics.LENS_FACING_FRONT) + putExtra("android.intent.extras.LENS_FACING_FRONT", 1) + putExtra("android.intent.extra.USE_FRONT_CAMERA", true) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } + + val resultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + tempSelfiePhotoFile?.let { file -> + val uri = FileProvider.getUriForFile(context, context.packageName + ".provider", file) + val bitmap = MediaStore.Images.Media.getBitmap(context.contentResolver, uri) selfieBitmap = rotateImageIfRequired( - tempSelfiePhoto, - MediaStore.Images.Media.getBitmap(currentContext.contentResolver, tempSelfiePhotoUri), + file, + bitmap, ) } } + } + + if (uiState is MagicSelfieUiState.Error) { + val errorMessage = (uiState as MagicSelfieUiState.Error).message ?: context.getString(R.string.unknown_error) + LaunchedEffect(uiState) { + snackbarHostState.showSnackbar(errorMessage) + viewModel.resetError() + } + } + + MagicSelfieScreen( + uiState = uiState, + selfieBitmap = selfieBitmap, + snackbarHostState = snackbarHostState, + onGenerateClick = viewModel::createMagicSelfie, + onTakePictureClick = { + val tempFile = File.createTempFile("tmp_selfie_picture", ".jpg", context.cacheDir) + tempSelfiePhotoFile = tempFile + val tempSelfiePhotoUri = FileProvider.getUriForFile(context, context.packageName + ".provider", tempFile) + cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, tempSelfiePhotoUri) + resultLauncher.launch(cameraIntent) + }, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +private fun MagicSelfieScreen( + uiState: MagicSelfieUiState, + selfieBitmap: Bitmap?, + snackbarHostState: SnackbarHostState, + onGenerateClick: (Bitmap, String) -> Unit, + onTakePictureClick: () -> Unit, +) { + val context = LocalContext.current + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher Scaffold( modifier = Modifier .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), + containerColor = MaterialTheme.colorScheme.surface, + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { - TopAppBar( - colors = topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.primary, - ), - title = { - Text(text = stringResource(id = R.string.magic_selfie)) - }, - actions = { - SeeCodeButton(context) - }, + SampleDetailTopAppBar( + sampleName = stringResource(R.string.magic_selfie_title), + sampleDescription = stringResource(R.string.magic_selfie_subtitle), + sourceCodeUrl = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/magic-selfie", + onBackClick = { backDispatcher?.onBackPressed() }, ) }, ) { innerPadding -> - Column( + 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 - .padding(12.dp) + .fillMaxSize() .padding(innerPadding) + .padding(16.dp) .imePadding() - .verticalScroll(rememberScrollState()), + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(40.dp), + ) + .clip(RoundedCornerShape(40.dp)) + .background(ShaderBrush(imageShader)), ) { - Card( - modifier = Modifier - .size( - width = 450.dp, - height = 450.dp, - ), - ) { - if (uiState is MagicSelfieUiState.Success) { - val successState = uiState as MagicSelfieUiState.Success - Image( - bitmap = successState.bitmap.asImageBitmap(), - contentDescription = "Picture", - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } else if (selfieBitmap != null) { - Image( - bitmap = selfieBitmap!!.asImageBitmap(), - contentDescription = "Picture", - contentScale = ContentScale.Fit, - modifier = Modifier.fillMaxWidth(), - ) - } - } - Spacer(modifier = Modifier.height(6.dp)) - Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { - resultLauncher.launch(cameraIntent) - }, - ) { - Icon(Icons.Default.CameraAlt, contentDescription = "Camera") - } - } - Spacer(modifier = Modifier.height(24.dp)) - - TextField( - value = editTextValue, - onValueChange = { editTextValue = it }, - label = { Text("Prompt") }, - ) - - Button( - modifier = Modifier.padding(vertical = 8.dp), - onClick = { - if (selfieBitmap != null) { - viewModel.createMagicSelfie(selfieBitmap!!, editTextValue) - } - }, - enabled = (uiState !is MagicSelfieUiState.RemovingBackground) && - (uiState !is MagicSelfieUiState.GeneratingBackground), - ) { - Icon(Icons.Default.SmartToy, contentDescription = "Robot") - Text(modifier = Modifier.padding(start = 8.dp), text = "Generate") - } - if (uiState is MagicSelfieUiState.RemovingBackground) { - Spacer( + if (selfieBitmap == null) { + PrimaryButton( + text = stringResource(R.string.add_image), + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_img), modifier = Modifier - .height(30.dp) - .padding(12.dp), - ) - Text( - text = stringResource(R.string.removing_background), + .height(96.dp) + .padding(start = 24.dp, end = 24.dp) + .align(Alignment.Center), + onClick = onTakePictureClick, ) - } else if (uiState is MagicSelfieUiState.GeneratingBackground) { - Spacer( - modifier = Modifier - .height(30.dp) - .padding(12.dp), - ) - Text( - text = stringResource(R.string.generating_new_background), + } else { + val displayBitmap = if (uiState is MagicSelfieUiState.Success) { + uiState.bitmap + } else { + selfieBitmap + } + + Image( + bitmap = displayBitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), ) - } else if (uiState is MagicSelfieUiState.Error) { - val errorState = uiState as MagicSelfieUiState.Error - Spacer( + + val textFieldState = rememberTextFieldState() + val keyboardController = LocalSoftwareKeyboardController.current + + if (uiState is MagicSelfieUiState.GeneratingBackground) { + ContainedLoadingIndicator( + modifier = Modifier.size(60.dp) + .align(Alignment.Center), + ) + } + + TextInput( + state = textFieldState, + placeholder = stringResource(R.string.prompt_placeholder), modifier = Modifier - .height(30.dp) - .padding(12.dp), - ) - Text( - text = errorState.message ?: stringResource(R.string.unknown_error), - color = MaterialTheme.colorScheme.error, + .padding(10.dp) + .height(80.dp) + .align(Alignment.BottomCenter), + primaryButton = { + GenerateButton( + text = "", + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_bg), + enabled = textFieldState.text.isNotEmpty() && + (uiState !is MagicSelfieUiState.RemovingBackground) && + (uiState !is MagicSelfieUiState.GeneratingBackground), + ) { + onGenerateClick(selfieBitmap, textFieldState.text.toString()) + keyboardController?.hide() + } + }, + secondaryButton = { + SecondaryButton( + text = "", + icon = painterResource(id = com.android.ai.uicomponent.R.drawable.ic_ai_img), + enabled = (uiState !is MagicSelfieUiState.RemovingBackground) && + (uiState !is MagicSelfieUiState.GeneratingBackground), + onClick = onTakePictureClick, + ) + }, ) } } } } + +@PreviewScreenSizes +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun MagicSelfieScreenPreview() { + AISampleCatalogTheme { + MagicSelfieScreen( + uiState = MagicSelfieUiState.Initial, + selfieBitmap = null, + snackbarHostState = remember { SnackbarHostState() }, + onGenerateClick = { _, _ -> }, + onTakePictureClick = {}, + ) + } +} diff --git a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieViewModel.kt b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieViewModel.kt index 4ff3a59c..30f68adf 100644 --- a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieViewModel.kt +++ b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/MagicSelfieViewModel.kt @@ -45,4 +45,8 @@ class MagicSelfieViewModel @Inject constructor(private val magicSelfieRepository } } } + + fun resetError() { + _uiState.value = MagicSelfieUiState.Initial + } } diff --git a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/SeeCodeButton.kt b/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/SeeCodeButton.kt deleted file mode 100644 index 4c664bf0..00000000 --- a/ai-catalog/samples/magic-selfie/src/main/java/com/android/ai/samples/magicselfie/ui/SeeCodeButton.kt +++ /dev/null @@ -1,51 +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.magicselfie.ui - -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Code -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.android.ai.samples.magicselfie.R - -@Composable -fun SeeCodeButton(context: Context) { - val githubLink = "https://github.com/android/ai-samples/tree/main/ai-catalog/samples/magic-selfie" - Button( - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(githubLink)) - context.startActivity(intent) - }, - modifier = Modifier.padding(end = 8.dp), - ) { - Icon(Icons.Filled.Code, contentDescription = stringResource(R.string.see_code)) - Text( - modifier = Modifier.padding(start = 8.dp), - fontSize = 12.sp, - text = stringResource(R.string.see_code), - ) - } -} diff --git a/ai-catalog/samples/magic-selfie/src/main/res/values/strings.xml b/ai-catalog/samples/magic-selfie/src/main/res/values/strings.xml index 9f82fb6b..9a802fd5 100644 --- a/ai-catalog/samples/magic-selfie/src/main/res/values/strings.xml +++ b/ai-catalog/samples/magic-selfie/src/main/res/values/strings.xml @@ -1,9 +1,8 @@ - Magic Selfie - See code - Share image + Magic Selfie + Change the background of you selfies with Imagen and the ML Kit Segmentation API + Add image Unknown error - Removing background... - Generating new background... + A very scenic view of the grand canyon \ No newline at end of file diff --git a/ai-catalog/settings.gradle.kts b/ai-catalog/settings.gradle.kts index 16a9a66f..977e9376 100644 --- a/ai-catalog/settings.gradle.kts +++ b/ai-catalog/settings.gradle.kts @@ -50,3 +50,4 @@ include(":samples:gemini-video-summarization") include(":samples:gemini-live-todo") include(":samples:gemini-video-metadata-creation") include(":samples:gemini-image-chat") +include(":ui-component") diff --git a/ai-catalog/ui-component/.gitignore b/ai-catalog/ui-component/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/ai-catalog/ui-component/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ai-catalog/ui-component/build.gradle.kts b/ai-catalog/ui-component/build.gradle.kts new file mode 100644 index 00000000..f4f78fd6 --- /dev/null +++ b/ai-catalog/ui-component/build.gradle.kts @@ -0,0 +1,70 @@ +/* + * 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. + */ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.android.ai.uicomponent" + compileSdk = 35 + + buildFeatures { + compose = true + } + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.ui.google.fonts) + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.ui.compose) + implementation(libs.coil.compose) + implementation(libs.richtext.material3) + implementation(libs.richtext.commonmark) + implementation(libs.ui.tooling.preview) + debugImplementation(libs.ui.tooling) +} diff --git a/ai-catalog/ui-component/src/main/java/com/android/ai/theme/Color.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/theme/Color.kt new file mode 100644 index 00000000..6447fcb6 --- /dev/null +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/theme/Color.kt @@ -0,0 +1,243 @@ +/* + * 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.theme + +import androidx.compose.ui.graphics.Color + +val primaryLight = Color(0xFFC6FF00) +val onPrimaryLight = Color(0xFF283300) +val primaryContainerLight = Color(0xFF4F6600) +val onPrimaryContainerLight = Color(0xFFC6FF00) +val secondaryLight = Color(0xFF34A853) +val onSecondaryLight = Color(0xFF002108) +val secondaryContainerLight = Color(0xFF184E26) +val onSecondaryContainerLight = Color(0xFF3CC360) +val tertiaryLight = Color(0xFFFDADEE) +val onTertiaryLight = Color(0xFF6D2F67) +val tertiaryContainerLight = Color(0xFF7B3A73) +val onTertiaryContainerLight = Color(0xFFFFD7F4) +val errorLight = Color(0xFFFFB4AB) +val onErrorLight = Color(0xFF690005) +val errorContainerLight = Color(0xFFBA1A1A) +val onErrorContainerLight = Color(0xFFFFDAD6) +val backgroundLight = Color(0xFF12140D) +val onBackgroundLight = Color(0xFFE3E3D7) +val surfaceLight = Color(0xFF1D181E) +val onSurfaceLight = Color(0xFFF1F1F1) +val surfaceVariantLight = Color(0xFF3F4948) +val onSurfaceVariantLight = Color(0xFFE5E2E1) +val outlineLight = Color(0xFF7D7171) +val outlineVariantLight = Color(0xFF43454B) +val scrimLight = Color(0xFF000000) +val inverseSurfaceLight = Color(0xFFEFDFE1) +val inverseOnSurfaceLight = Color(0xFF372E30) +val inversePrimaryLight = Color(0xFF638000) +val surfaceDimLight = Color(0xFF1C1B1B) +val surfaceBrightLight = Color(0xFF3C3B3B) +val surfaceContainerLowestLight = Color(0xFF111111) +val surfaceContainerLowLight = Color(0xFF1C1B1B) +val surfaceContainerLight = Color(0xFF262625) +val surfaceContainerHighLight = Color(0xFF313030) +val surfaceContainerHighestLight = Color(0xFF3C3B3B) + +val primaryLightMediumContrast = Color(0xFFC6FF00) +val onPrimaryLightMediumContrast = Color(0xFF1E2A00) +val primaryContainerLightMediumContrast = Color(0xFF4F6600) +val onPrimaryContainerLightMediumContrast = Color(0xFF9ECC00) +val secondaryLightMediumContrast = Color(0xFF34A853) +val onSecondaryLightMediumContrast = Color(0xFF002D0E) +val secondaryContainerLightMediumContrast = Color(0xFF689D6D) +val onSecondaryContainerLightMediumContrast = Color(0xFF63CF80) +val tertiaryLightMediumContrast = Color(0xFFFFEBF7) +val onTertiaryLightMediumContrast = Color(0xFFA45F9A) +val tertiaryContainerLightMediumContrast = Color(0xFFB87EAD) +val onTertiaryContainerLightMediumContrast = Color(0xFF000000) +val errorLightMediumContrast = Color(0xFFFFD2CC) +val onErrorLightMediumContrast = Color(0xFF540003) +val errorContainerLightMediumContrast = Color(0xFFFF5449) +val onErrorContainerLightMediumContrast = Color(0xFF000000) +val backgroundLightMediumContrast = Color(0xFF12140D) +val onBackgroundLightMediumContrast = Color(0xFFE3E3D7) +val surfaceLightMediumContrast = Color(0xFF43454B) +val onSurfaceLightMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantLightMediumContrast = Color(0xFF3F4948) +val onSurfaceVariantLightMediumContrast = Color(0xFFD4DEDD) +val outlineLightMediumContrast = Color(0xFFAAB4B3) +val outlineVariantLightMediumContrast = Color(0xFF889291) +val scrimLightMediumContrast = Color(0xFF000000) +val inverseSurfaceLightMediumContrast = Color(0xFFEFDFE1) +val inverseOnSurfaceLightMediumContrast = Color(0xFF31282A) +val inversePrimaryLightMediumContrast = Color(0xFF3D4E0F) +val surfaceDimLightMediumContrast = Color(0xFF191113) +val surfaceBrightLightMediumContrast = Color(0xFF4C4244) +val surfaceContainerLowestLightMediumContrast = Color(0xFF0C0607) +val surfaceContainerLowLightMediumContrast = Color(0xFF241B1E) +val surfaceContainerLightMediumContrast = Color(0xFF2F2628) +val surfaceContainerHighLightMediumContrast = Color(0xFF3A3032) +val surfaceContainerHighestLightMediumContrast = Color(0xFF453B3D) + +val primaryLightHighContrast = Color(0xFFE3F9A8) +val onPrimaryLightHighContrast = Color(0xFF000000) +val primaryContainerLightHighContrast = Color(0xFF4F6600) +val onPrimaryContainerLightHighContrast = Color(0xFFFFFFFF) +val secondaryLightHighContrast = Color(0xFF63CF80) +val onSecondaryLightHighContrast = Color(0xFF000000) +val secondaryContainerLightHighContrast = Color(0xFF184E26) +val onSecondaryContainerLightHighContrast = Color(0xFFFFFFFF) +val tertiaryLightHighContrast = Color(0xFFFFD7F4) +val onTertiaryLightHighContrast = Color(0xFF260025) +val tertiaryContainerLightHighContrast = Color(0xFFEEAFE1) +val onTertiaryContainerLightHighContrast = Color(0xFF1D001B) +val errorLightHighContrast = Color(0xFFFFECE9) +val onErrorLightHighContrast = Color(0xFF000000) +val errorContainerLightHighContrast = Color(0xFFFFAEA4) +val onErrorContainerLightHighContrast = Color(0xFF220001) +val backgroundLightHighContrast = Color(0xFF12140D) +val onBackgroundLightHighContrast = Color(0xFFE3E3D7) +val surfaceLightHighContrast = Color(0xFF0F1013) +val onSurfaceLightHighContrast = Color(0xFFFFFFFF) +val surfaceVariantLightHighContrast = Color(0xFF3F4948) +val onSurfaceVariantLightHighContrast = Color(0xFFFFFFFF) +val outlineLightHighContrast = Color(0xFFE8F2F1) +val outlineVariantLightHighContrast = Color(0xFFBAC5C3) +val scrimLightHighContrast = Color(0xFF000000) +val inverseSurfaceLightHighContrast = Color(0xFFEFDFE1) +val inverseOnSurfaceLightHighContrast = Color(0xFF000000) +val inversePrimaryLightHighContrast = Color(0xFF3D4E0F) +val surfaceDimLightHighContrast = Color(0xFF191113) +val surfaceBrightLightHighContrast = Color(0xFF584D50) +val surfaceContainerLowestLightHighContrast = Color(0xFF000000) +val surfaceContainerLowLightHighContrast = Color(0xFF261D20) +val surfaceContainerLightHighContrast = Color(0xFF372E30) +val surfaceContainerHighLightHighContrast = Color(0xFF43393B) +val surfaceContainerHighestLightHighContrast = Color(0xFF4F4446) + +val primaryDark = Color(0xFFC6FF00) +val onPrimaryDark = Color(0xFF283300) +val primaryContainerDark = Color(0xFF4F6600) +val onPrimaryContainerDark = Color(0xFFC6FF00) +val secondaryDark = Color(0xFF34A853) +val onSecondaryDark = Color(0xFF002108) +val secondaryContainerDark = Color(0xFF184E26) +val onSecondaryContainerDark = Color(0xFF3CC360) +val tertiaryDark = Color(0xFFFDADEE) +val onTertiaryDark = Color(0xFF6D2F67) +val tertiaryContainerDark = Color(0xFF7B3A73) +val onTertiaryContainerDark = Color(0xFFFFD7F4) +val errorDark = Color(0xFFFFB4AB) +val onErrorDark = Color(0xFF690005) +val errorContainerDark = Color(0xFFBA1A1A) +val onErrorContainerDark = Color(0xFFFFDAD6) +val backgroundDark = Color(0xFF12140D) +val onBackgroundDark = Color(0xFFE3E3D7) +val surfaceDark = Color(0xFF1D181E) +val onSurfaceDark = Color(0xFFF1F1F1) +val surfaceVariantDark = Color(0xFF3F4948) +val onSurfaceVariantDark = Color(0xFFE5E2E1) +val outlineDark = Color(0xFF7D7171) +val outlineVariantDark = Color(0xFF43454B) +val scrimDark = Color(0xFF000000) +val inverseSurfaceDark = Color(0xFFEFDFE1) +val inverseOnSurfaceDark = Color(0xFF372E30) +val inversePrimaryDark = Color(0xFF638000) +val surfaceDimDark = Color(0xFF1C1B1B) +val surfaceBrightDark = Color(0xFF3C3B3B) +val surfaceContainerLowestDark = Color(0xFF111111) +val surfaceContainerLowDark = Color(0xFF1C1B1B) +val surfaceContainerDark = Color(0xFF262625) +val surfaceContainerHighDark = Color(0xFF313030) +val surfaceContainerHighestDark = Color(0xFF3C3B3B) + +val primaryDarkMediumContrast = Color(0xFFC6FF00) +val onPrimaryDarkMediumContrast = Color(0xFF1E2A00) +val primaryContainerDarkMediumContrast = Color(0xFF4F6600) +val onPrimaryContainerDarkMediumContrast = Color(0xFF9ECC00) +val secondaryDarkMediumContrast = Color(0xFF34A853) +val onSecondaryDarkMediumContrast = Color(0xFF002D0E) +val secondaryContainerDarkMediumContrast = Color(0xFF689D6D) +val onSecondaryContainerDarkMediumContrast = Color(0xFF63CF80) +val tertiaryDarkMediumContrast = Color(0xFFFFEBF7) +val onTertiaryDarkMediumContrast = Color(0xFFA45F9A) +val tertiaryContainerDarkMediumContrast = Color(0xFFB87EAD) +val onTertiaryContainerDarkMediumContrast = Color(0xFF000000) +val errorDarkMediumContrast = Color(0xFFFFD2CC) +val onErrorDarkMediumContrast = Color(0xFF540003) +val errorContainerDarkMediumContrast = Color(0xFFFF5449) +val onErrorContainerDarkMediumContrast = Color(0xFF000000) +val backgroundDarkMediumContrast = Color(0xFF12140D) +val onBackgroundDarkMediumContrast = Color(0xFFE3E3D7) +val surfaceDarkMediumContrast = Color(0xFF43454B) +val onSurfaceDarkMediumContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkMediumContrast = Color(0xFF3F4948) +val onSurfaceVariantDarkMediumContrast = Color(0xFFD4DEDD) +val outlineDarkMediumContrast = Color(0xFFAAB4B3) +val outlineVariantDarkMediumContrast = Color(0xFF889291) +val scrimDarkMediumContrast = Color(0xFF000000) +val inverseSurfaceDarkMediumContrast = Color(0xFFEFDFE1) +val inverseOnSurfaceDarkMediumContrast = Color(0xFF31282A) +val inversePrimaryDarkMediumContrast = Color(0xFF3D4E0F) +val surfaceDimDarkMediumContrast = Color(0xFF191113) +val surfaceBrightDarkMediumContrast = Color(0xFF4C4244) +val surfaceContainerLowestDarkMediumContrast = Color(0xFF0C0607) +val surfaceContainerLowDarkMediumContrast = Color(0xFF241B1E) +val surfaceContainerDarkMediumContrast = Color(0xFF2F2628) +val surfaceContainerHighDarkMediumContrast = Color(0xFF3A3032) +val surfaceContainerHighestDarkMediumContrast = Color(0xFF453B3D) + +val primaryDarkHighContrast = Color(0xFFE3F9A8) +val onPrimaryDarkHighContrast = Color(0xFF000000) +val primaryContainerDarkHighContrast = Color(0xFF4F6600) +val onPrimaryContainerDarkHighContrast = Color(0xFFFFFFFF) +val secondaryDarkHighContrast = Color(0xFF63CF80) +val onSecondaryDarkHighContrast = Color(0xFF000000) +val secondaryContainerDarkHighContrast = Color(0xFF184E26) +val onSecondaryContainerDarkHighContrast = Color(0xFFFFFFFF) +val tertiaryDarkHighContrast = Color(0xFFFFD7F4) +val onTertiaryDarkHighContrast = Color(0xFF260025) +val tertiaryContainerDarkHighContrast = Color(0xFFEEAFE1) +val onTertiaryContainerDarkHighContrast = Color(0xFF1D001B) +val errorDarkHighContrast = Color(0xFFFFECE9) +val onErrorDarkHighContrast = Color(0xFF000000) +val errorContainerDarkHighContrast = Color(0xFFFFAEA4) +val onErrorContainerDarkHighContrast = Color(0xFF220001) +val backgroundDarkHighContrast = Color(0xFF12140D) +val onBackgroundDarkHighContrast = Color(0xFFE3E3D7) +val surfaceDarkHighContrast = Color(0xFF0F1013) +val onSurfaceDarkHighContrast = Color(0xFFFFFFFF) +val surfaceVariantDarkHighContrast = Color(0xFF3F4948) +val onSurfaceVariantDarkHighContrast = Color(0xFFFFFFFF) +val outlineDarkHighContrast = Color(0xFFE8F2F1) +val outlineVariantDarkHighContrast = Color(0xFFBAC5C3) +val scrimDarkHighContrast = Color(0xFF000000) +val inverseSurfaceDarkHighContrast = Color(0xFFEFDFE1) +val inverseOnSurfaceDarkHighContrast = Color(0xFF000000) +val inversePrimaryDarkHighContrast = Color(0xFF3D4E0F) +val surfaceDimDarkHighContrast = Color(0xFF191113) +val surfaceBrightDarkHighContrast = Color(0xFF584D50) +val surfaceContainerLowestDarkHighContrast = Color(0xFF000000) +val surfaceContainerLowDarkHighContrast = Color(0xFF261D20) +val surfaceContainerDarkHighContrast = Color(0xFF372E30) +val surfaceContainerHighDarkHighContrast = Color(0xFF43393B) +val surfaceContainerHighestDarkHighContrast = Color(0xFF4F4446) + +val geminiNano = Color(0xFFDCE2FF) +val geminiProFlash = Color(0xFFC7E4FF) +val imagen = Color(0xFF2DAEB8) +val firebase = Color(0xFFFFC400) +val media3 = Color(0xFF80DA88) +val mlKit = Color(0xFFC2E7FF) + +val startGradient = Color(0x99000000) diff --git a/ai-catalog/ui-component/src/main/java/com/android/ai/theme/Theme.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/theme/Theme.kt new file mode 100644 index 00000000..ddf51e27 --- /dev/null +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/theme/Theme.kt @@ -0,0 +1,323 @@ +/* + * 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.theme + +import android.app.UiModeManager +import android.content.Context +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode + +private val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +private val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) + +private val mediumContrastLightColorScheme = lightColorScheme( + primary = primaryLightMediumContrast, + onPrimary = onPrimaryLightMediumContrast, + primaryContainer = primaryContainerLightMediumContrast, + onPrimaryContainer = onPrimaryContainerLightMediumContrast, + secondary = secondaryLightMediumContrast, + onSecondary = onSecondaryLightMediumContrast, + secondaryContainer = secondaryContainerLightMediumContrast, + onSecondaryContainer = onSecondaryContainerLightMediumContrast, + tertiary = tertiaryLightMediumContrast, + onTertiary = onTertiaryLightMediumContrast, + tertiaryContainer = tertiaryContainerLightMediumContrast, + onTertiaryContainer = onTertiaryContainerLightMediumContrast, + error = errorLightMediumContrast, + onError = onErrorLightMediumContrast, + errorContainer = errorContainerLightMediumContrast, + onErrorContainer = onErrorContainerLightMediumContrast, + background = backgroundLightMediumContrast, + onBackground = onBackgroundLightMediumContrast, + surface = surfaceLightMediumContrast, + onSurface = onSurfaceLightMediumContrast, + surfaceVariant = surfaceVariantLightMediumContrast, + onSurfaceVariant = onSurfaceVariantLightMediumContrast, + outline = outlineLightMediumContrast, + outlineVariant = outlineVariantLightMediumContrast, + scrim = scrimLightMediumContrast, + inverseSurface = inverseSurfaceLightMediumContrast, + inverseOnSurface = inverseOnSurfaceLightMediumContrast, + inversePrimary = inversePrimaryLightMediumContrast, + surfaceDim = surfaceDimLightMediumContrast, + surfaceBright = surfaceBrightLightMediumContrast, + surfaceContainerLowest = surfaceContainerLowestLightMediumContrast, + surfaceContainerLow = surfaceContainerLowLightMediumContrast, + surfaceContainer = surfaceContainerLightMediumContrast, + surfaceContainerHigh = surfaceContainerHighLightMediumContrast, + surfaceContainerHighest = surfaceContainerHighestLightMediumContrast, +) + +private val highContrastLightColorScheme = lightColorScheme( + primary = primaryLightHighContrast, + onPrimary = onPrimaryLightHighContrast, + primaryContainer = primaryContainerLightHighContrast, + onPrimaryContainer = onPrimaryContainerLightHighContrast, + secondary = secondaryLightHighContrast, + onSecondary = onSecondaryLightHighContrast, + secondaryContainer = secondaryContainerLightHighContrast, + onSecondaryContainer = onSecondaryContainerLightHighContrast, + tertiary = tertiaryLightHighContrast, + onTertiary = onTertiaryLightHighContrast, + tertiaryContainer = tertiaryContainerLightHighContrast, + onTertiaryContainer = onTertiaryContainerLightHighContrast, + error = errorLightHighContrast, + onError = onErrorLightHighContrast, + errorContainer = errorContainerLightHighContrast, + onErrorContainer = onErrorContainerLightHighContrast, + background = backgroundLightHighContrast, + onBackground = onBackgroundLightHighContrast, + surface = surfaceLightHighContrast, + onSurface = onSurfaceLightHighContrast, + surfaceVariant = surfaceVariantLightHighContrast, + onSurfaceVariant = onSurfaceVariantLightHighContrast, + outline = outlineLightHighContrast, + outlineVariant = outlineVariantLightHighContrast, + scrim = scrimLightHighContrast, + inverseSurface = inverseSurfaceLightHighContrast, + inverseOnSurface = inverseOnSurfaceLightHighContrast, + inversePrimary = inversePrimaryLightHighContrast, + surfaceDim = surfaceDimLightHighContrast, + surfaceBright = surfaceBrightLightHighContrast, + surfaceContainerLowest = surfaceContainerLowestLightHighContrast, + surfaceContainerLow = surfaceContainerLowLightHighContrast, + surfaceContainer = surfaceContainerLightHighContrast, + surfaceContainerHigh = surfaceContainerHighLightHighContrast, + surfaceContainerHighest = surfaceContainerHighestLightHighContrast, +) + +private val mediumContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkMediumContrast, + onPrimary = onPrimaryDarkMediumContrast, + primaryContainer = primaryContainerDarkMediumContrast, + onPrimaryContainer = onPrimaryContainerDarkMediumContrast, + secondary = secondaryDarkMediumContrast, + onSecondary = onSecondaryDarkMediumContrast, + secondaryContainer = secondaryContainerDarkMediumContrast, + onSecondaryContainer = onSecondaryContainerDarkMediumContrast, + tertiary = tertiaryDarkMediumContrast, + onTertiary = onTertiaryDarkMediumContrast, + tertiaryContainer = tertiaryContainerDarkMediumContrast, + onTertiaryContainer = onTertiaryContainerDarkMediumContrast, + error = errorDarkMediumContrast, + onError = onErrorDarkMediumContrast, + errorContainer = errorContainerDarkMediumContrast, + onErrorContainer = onErrorContainerDarkMediumContrast, + background = backgroundDarkMediumContrast, + onBackground = onBackgroundDarkMediumContrast, + surface = surfaceDarkMediumContrast, + onSurface = onSurfaceDarkMediumContrast, + surfaceVariant = surfaceVariantDarkMediumContrast, + onSurfaceVariant = onSurfaceVariantDarkMediumContrast, + outline = outlineDarkMediumContrast, + outlineVariant = outlineVariantDarkMediumContrast, + scrim = scrimDarkMediumContrast, + inverseSurface = inverseSurfaceDarkMediumContrast, + inverseOnSurface = inverseOnSurfaceDarkMediumContrast, + inversePrimary = inversePrimaryDarkMediumContrast, + surfaceDim = surfaceDimDarkMediumContrast, + surfaceBright = surfaceBrightDarkMediumContrast, + surfaceContainerLowest = surfaceContainerLowestDarkMediumContrast, + surfaceContainerLow = surfaceContainerLowDarkMediumContrast, + surfaceContainer = surfaceContainerDarkMediumContrast, + surfaceContainerHigh = surfaceContainerHighDarkMediumContrast, + surfaceContainerHighest = surfaceContainerHighestDarkMediumContrast, +) + +private val highContrastDarkColorScheme = darkColorScheme( + primary = primaryDarkHighContrast, + onPrimary = onPrimaryDarkHighContrast, + primaryContainer = primaryContainerDarkHighContrast, + onPrimaryContainer = onPrimaryContainerDarkHighContrast, + secondary = secondaryDarkHighContrast, + onSecondary = onSecondaryDarkHighContrast, + secondaryContainer = secondaryContainerDarkHighContrast, + onSecondaryContainer = onSecondaryContainerDarkHighContrast, + tertiary = tertiaryDarkHighContrast, + onTertiary = onTertiaryDarkHighContrast, + tertiaryContainer = tertiaryContainerDarkHighContrast, + onTertiaryContainer = onTertiaryContainerDarkHighContrast, + error = errorDarkHighContrast, + onError = onErrorDarkHighContrast, + errorContainer = errorContainerDarkHighContrast, + onErrorContainer = onErrorContainerDarkHighContrast, + background = backgroundDarkHighContrast, + onBackground = onBackgroundDarkHighContrast, + surface = surfaceDarkHighContrast, + onSurface = onSurfaceDarkHighContrast, + surfaceVariant = surfaceVariantDarkHighContrast, + onSurfaceVariant = onSurfaceVariantDarkHighContrast, + outline = outlineDarkHighContrast, + outlineVariant = outlineVariantDarkHighContrast, + scrim = scrimDarkHighContrast, + inverseSurface = inverseSurfaceDarkHighContrast, + inverseOnSurface = inverseOnSurfaceDarkHighContrast, + inversePrimary = inversePrimaryDarkHighContrast, + surfaceDim = surfaceDimDarkHighContrast, + surfaceBright = surfaceBrightDarkHighContrast, + surfaceContainerLowest = surfaceContainerLowestDarkHighContrast, + surfaceContainerLow = surfaceContainerLowDarkHighContrast, + surfaceContainer = surfaceContainerDarkHighContrast, + surfaceContainerHigh = surfaceContainerHighDarkHighContrast, + surfaceContainerHighest = surfaceContainerHighestDarkHighContrast, +) + +data class ExtendedColorScheme( + val geminiNano: Color, + val geminiProFlash: Color, + val imagen: Color, + val firebase: Color, + val media3: Color, + val mLKit: Color, + val startGradient: Color, +) + +val extendedColorScheme = ExtendedColorScheme( + geminiNano = geminiNano, + geminiProFlash = geminiProFlash, + imagen = imagen, + firebase = firebase, + media3 = media3, + mLKit = mlKit, + startGradient = startGradient, +) + +enum class Contrast { DEFAULT, MEDIUM, HIGH } + +@Composable +private fun systemContrast(): Contrast { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || + LocalInspectionMode.current + ) { + return Contrast.DEFAULT + } else { + val uiModeManager = LocalContext.current.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager + val contrastLevel = uiModeManager.contrast + + return when (contrastLevel) { + in 0.34f..0.66f -> Contrast.MEDIUM + in 0.67f..1.0f -> Contrast.HIGH + else -> Contrast.DEFAULT + } + } +} + +@Composable +fun AISampleCatalogTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + contrast: Contrast = systemContrast(), + content: @Composable () -> Unit, +) { + val colorScheme = if (darkTheme) { + when (contrast) { + Contrast.DEFAULT -> darkScheme + Contrast.MEDIUM -> mediumContrastDarkColorScheme + Contrast.HIGH -> highContrastDarkColorScheme + } + } else { + when (contrast) { + Contrast.DEFAULT -> lightScheme + Contrast.MEDIUM -> mediumContrastLightColorScheme + Contrast.HIGH -> highContrastLightColorScheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = AppTypography, + content = content, + ) +} diff --git a/ai-catalog/ui-component/src/main/java/com/android/ai/theme/Type.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/theme/Type.kt new file mode 100644 index 00000000..9855fe27 --- /dev/null +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/theme/Type.kt @@ -0,0 +1,64 @@ +/* + * 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.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.googlefonts.Font +import androidx.compose.ui.text.googlefonts.GoogleFont +import com.android.ai.uicomponent.R + +val provider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs, +) + +val bodyFontFamily = FontFamily( + Font( + googleFont = GoogleFont("Space Grotesk"), + fontProvider = provider, + ), +) + +val displayFontFamily = FontFamily( + Font( + googleFont = GoogleFont("Space Grotesk"), + fontProvider = provider, + ), +) + +// Default Material 3 typography values +val baseline = Typography() + +val AppTypography = Typography( + displayLarge = baseline.displayLarge.copy(fontFamily = displayFontFamily), + displayMedium = baseline.displayMedium.copy(fontFamily = displayFontFamily), + displaySmall = baseline.displaySmall.copy(fontFamily = displayFontFamily), + headlineLarge = baseline.headlineLarge.copy(fontFamily = displayFontFamily), + headlineMedium = baseline.headlineMedium.copy(fontFamily = displayFontFamily), + headlineSmall = baseline.headlineSmall.copy(fontFamily = displayFontFamily), + titleLarge = baseline.titleLarge.copy(fontFamily = displayFontFamily), + titleMedium = baseline.titleMedium.copy(fontFamily = displayFontFamily), + titleSmall = baseline.titleSmall.copy(fontFamily = displayFontFamily), + bodyLarge = baseline.bodyLarge.copy(fontFamily = bodyFontFamily), + bodyMedium = baseline.bodyMedium.copy(fontFamily = bodyFontFamily), + bodySmall = baseline.bodySmall.copy(fontFamily = bodyFontFamily), + labelLarge = baseline.labelLarge.copy(fontFamily = bodyFontFamily), + labelMedium = baseline.labelMedium.copy(fontFamily = bodyFontFamily), + labelSmall = baseline.labelSmall.copy(fontFamily = bodyFontFamily), +) diff --git a/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/Buttons.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/Buttons.kt new file mode 100644 index 00000000..20e2e7cd --- /dev/null +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/Buttons.kt @@ -0,0 +1,322 @@ +/* + * 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.uicomponent + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.Code +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedIconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.ai.theme.AISampleCatalogTheme +import com.android.ai.theme.Contrast + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun PrimaryButton( + modifier: Modifier = Modifier + .height(40.dp) + .requiredWidthIn(min = 40.dp), + text: String = "", + contentColor: Color = MaterialTheme.colorScheme.onPrimary, + containerColor: Color = MaterialTheme.colorScheme.primary, + icon: Painter? = null, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Button( + modifier = modifier, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = contentColor, + containerColor = containerColor, + ), + contentPadding = if (text.isEmpty()) + PaddingValues(0.dp) else + ButtonDefaults.TextButtonWithIconContentPadding, + onClick = { onClick() }, + enabled = enabled, + ) { + if (icon != null) { + Image( + painter = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(contentColor), + modifier = Modifier.size(width = 24.dp, height = 24.dp), + ) + } + AnimatedContent(text.isNotEmpty()) { hasText -> + if (hasText) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } +} + +@Preview +@Composable +fun PrimaryButtonLightPreview() { + AISampleCatalogTheme( + darkTheme = false, + contrast = Contrast.HIGH, + ) { + PrimaryButton( + text = "Primary button", + icon = rememberVectorPainter(Icons.Default.AccountBox), + onClick = {}, + ) + } +} + +@Preview +@Composable +fun PrimaryButtonDarkPreview() { + AISampleCatalogTheme( + darkTheme = true, + contrast = Contrast.DEFAULT, + ) { + PrimaryButton( + text = "Primary button", + icon = rememberVectorPainter(Icons.Default.AccountBox), + onClick = {}, + ) + } +} + +@Preview +@Composable +fun PrimaryButtonWithIconPreview() { + AISampleCatalogTheme { + PrimaryButton( + icon = rememberVectorPainter(Icons.Filled.Code), + onClick = {}, + ) + } +} + +@Composable +fun GenerateButton( + modifier: Modifier = Modifier, + text: String = "", + contentColor: Color = MaterialTheme.colorScheme.onTertiary, + containerColor: Color = MaterialTheme.colorScheme.tertiary, + enabled: Boolean = true, + icon: Painter? = painterResource(id = R.drawable.ic_ai_edit), + onClick: () -> Unit, +) { + Button( + modifier = modifier + .height(56.dp) + .border( + if (enabled) { + BorderStroke(0.dp, Color.Transparent) + } else { + BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + }, + shape = RoundedCornerShape(30.dp), + ), + enabled = enabled, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = contentColor, + containerColor = containerColor, + disabledContentColor = MaterialTheme.colorScheme.onSurface, + disabledContainerColor = Color.Transparent, + ), + onClick = { onClick() }, + ) { + if (icon != null) { + Image( + painter = icon, + contentDescription = null, + colorFilter = ColorFilter.tint( + if (enabled) contentColor else MaterialTheme.colorScheme.onSurface, + ), + modifier = Modifier.size(width = 24.dp, height = 24.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Preview +@Composable +fun GenerateButtonPreview() { + AISampleCatalogTheme { + GenerateButton( + text = "Generate", + onClick = {}, + ) + } +} + +@Preview +@Composable +fun GenerateButtonDisabledPreview() { + AISampleCatalogTheme { + GenerateButton( + text = "Generate", + enabled = false, + onClick = {}, + ) + } +} + +@Composable +fun SecondaryButton( + modifier: Modifier = Modifier, + text: String = "", + icon: Painter? = null, + enabled: Boolean = true, + colors: ButtonColors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + onClick: () -> Unit, +) { + OutlinedButton( + modifier = modifier.height(48.dp), + colors = colors, + enabled = enabled, + border = BorderStroke(width = 1.dp, color = MaterialTheme.colorScheme.outline), + contentPadding = if (text.isEmpty()) PaddingValues(0.dp) else ButtonDefaults.ContentPadding, + onClick = { onClick() }, + ) { + if (icon != null) { + Image( + painter = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(colors.contentColor), + modifier = Modifier.size(24.dp), + ) + if (text.isNotEmpty()) { + Spacer(modifier = Modifier.width(8.dp)) + } + } + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Preview +@Composable +fun SecondaryButtonPreview() { + AISampleCatalogTheme { + SecondaryButton( + text = "Outlined button", + icon = painterResource(id = R.drawable.ic_ai_img), + onClick = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun BackButton(modifier: Modifier = Modifier, imageVector: ImageVector = Icons.AutoMirrored.Filled.ArrowBack, onClick: () -> Unit) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.outline) { + OutlinedIconButton( + shape = IconButtonDefaults.smallSquareShape, + onClick = { onClick() }, + modifier = modifier, + ) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + Icon( + imageVector = imageVector, + contentDescription = null, + ) + } + } + } +} + +@Preview +@Composable +fun BackButtonPreview() { + AISampleCatalogTheme { + BackButton( + onClick = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun UndoButton(modifier: Modifier = Modifier, onClick: () -> Unit) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.outline) { + OutlinedIconButton( + shape = IconButtonDefaults.smallRoundShape, + onClick = { onClick() }, + modifier = modifier, + ) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + Icon( + painter = painterResource(id = R.drawable.ic_redo), + contentDescription = stringResource(R.string.undo), + ) + } + } + } +} + +@Preview +@Composable +fun UndoButtonPreview() { + AISampleCatalogTheme { + UndoButton( + onClick = {}, + ) + } +} diff --git a/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/ImageInput.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/ImageInput.kt new file mode 100644 index 00000000..a12d216f --- /dev/null +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/ImageInput.kt @@ -0,0 +1,480 @@ +/* + * 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.uicomponent + +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +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.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.android.ai.theme.AISampleCatalogTheme +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Represents the different states for the [ImageInput] composable. + * This sealed class defines whether the input area is empty or contains an image, + * and if it contains an image, what state it is in (e.g., being analyzed, displaying text). + */ +sealed class ImageInputType { + class Empty( + val hintText: String? = null, + val onAddImage: (() -> Unit)? = null, + ) : ImageInputType() + + sealed class WithImage(val imageUri: Uri) : ImageInputType() { + class Image(imageUri: Uri) : WithImage(imageUri) + class Analyzing(imageUri: Uri) : WithImage(imageUri) + class GeneratingText(imageUri: Uri) : WithImage(imageUri) + class WithText( + imageUri: Uri, + val text: String, + ) : WithImage(imageUri) + } +} + +/** + * A composable that serves as a container for displaying an image or a placeholder for one. + * It adapts its content based on the provided [ImageInputType], showing either an empty state + * with a prompt to add an image, or the image itself with various overlays depending on the + * current state (e.g., analyzing, showing generated text). + * + * This composable is styled with a rounded corner border and can optionally display + * additional content at the bottom. + * + * @param type The state of the image input, which determines the content to display. See + * [ImageInputType] for possible states. + * @param modifier The modifier to be applied to the container. + * @param bottomContent An optional composable lambda that will be displayed at the bottom of the + * container, inside the border. This will typically be used to display an input field. + */ +@Composable +fun ImageInput(type: ImageInputType, modifier: Modifier = Modifier, bottomContent: (@Composable () -> Unit)? = null) { + val cornerShape = RoundedCornerShape(40.dp) + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { + Box( + modifier = modifier + .fillMaxSize() + .clip(cornerShape) + .border(2.dp, MaterialTheme.colorScheme.outline, cornerShape), + ) { + + val bottomContainer = bottomContent?.let { + @Composable { + Box( + Modifier + .fillMaxWidth() + .padding(16.dp) + .align(Alignment.BottomCenter) + .imePadding(), + ) { it() } + } + } + + when (type) { + is ImageInputType.Empty -> EmptyContent(type.hintText, type.onAddImage, bottomContainer) + is ImageInputType.WithImage -> ImageContent(type, bottomContainer) + } + } + } +} + +private val FLOATY_SOFT_GLOW_SOFT_GLOW_PINK_1 = Color(0xFFFF7DD2) +private val FLOATY_SOFT_GLOW_SOFT_GLOW_BLUE_2 = Color(0xFF3271EA) +private val FLOATY_SOFT_GLOW_SOFT_GLOW_BLUE_1 = Color(0xFF4C8DF6) + +@Composable +private fun ImageContent(type: ImageInputType.WithImage, bottomContent: (@Composable () -> Unit)?) { + val bgColor = MaterialTheme.colorScheme.surfaceContainerHigh + Box(Modifier.fillMaxSize()) { + AsyncImage( + model = type.imageUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center) + .drawWithCache { + val gradientBrush = Brush.radialGradient( + listOf( + bgColor.copy(alpha = 0.0f), + bgColor.copy(0.7f), + ), + ) + onDrawWithContent { + drawContent() + if (type is ImageInputType.WithImage.GeneratingText || type is ImageInputType.WithImage.WithText) { + val aspectRatio = size.width / size.height + scale(maxOf(1f, aspectRatio), maxOf(1f, 1 / aspectRatio)) { + drawRect(gradientBrush) + } + } + } + }, + ) + if (type is ImageInputType.WithImage.Analyzing) { + val transition = rememberInfiniteTransition(label = "shimmer_transition") + val progressAnimated by transition.animateFloat( + initialValue = 0f, + targetValue = 3f, + animationSpec = infiniteRepeatable( + animation = tween(3000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "shimmer_progress", + ) + val drawAnalyzingModifier = Modifier.drawWithCache { + val angleRad = 200 / 180f * PI + val x = cos(angleRad).toFloat() // Fractional x + val y = sin(angleRad).toFloat() // Fractional y + + val radius = sqrt(size.width.pow(2) + size.height.pow(2)) / 2f + val offset = Offset(size.width / 2, size.height / 2) + Offset(x * radius, y * radius) + + val exactOffset = Offset( + x = min(offset.x.coerceAtLeast(0f), size.width), + y = size.height - min(offset.y.coerceAtLeast(0f), size.height), + ) + val floatySoftGlowGradient = Brush.linearGradient( + colorStops = arrayOf( + 0.31f to FLOATY_SOFT_GLOW_SOFT_GLOW_PINK_1, + 0.43f to Color.White, + 0.51f to FLOATY_SOFT_GLOW_SOFT_GLOW_BLUE_2, + 1.00f to FLOATY_SOFT_GLOW_SOFT_GLOW_BLUE_1, + ), + start = Offset(size.width, size.height) - exactOffset, + end = exactOffset, + tileMode = TileMode.Mirror, + ) + + onDrawBehind { + val translation = -progressAnimated * size.width + translate(left = translation) { + drawRect( + brush = floatySoftGlowGradient, + size = Size(size.width * 4f, size.height), + alpha = 0.4f, + ) + } + } + } + Box( + Modifier + .fillMaxSize() + .clipToBounds() + .then(drawAnalyzingModifier), + ) + } + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(Modifier.weight(1f)) { + if (type is ImageInputType.WithImage.GeneratingText) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + ) + } else if (type is ImageInputType.WithImage.WithText) { + MarkdownText( + text = type.text, + modifier = Modifier + .align(Alignment.Center) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) + } + } + bottomContent?.invoke() + } + } +} + +@Composable +private fun EmptyContent(hintText: String?, onAddImage: (() -> Unit)?, bottomContent: (@Composable () -> Unit)?) { + val context = LocalContext.current + val bgColor = MaterialTheme.colorScheme.surfaceContainerHigh + Box( + Modifier + .fillMaxSize() + .drawWithCache { + val imageBitmap = BitmapFactory + .decodeResource(context.resources, R.drawable.img_fill).asImageBitmap() + val patternBrush = ShaderBrush( + ImageShader(imageBitmap, TileMode.Repeated, TileMode.Repeated), + ) + onDrawBehind { drawRect(patternBrush, size = size) } + } + .drawWithCache { + val gradientBrush = Brush.radialGradient( + listOf(bgColor.copy(alpha = 0.0f), bgColor.copy(0.7f)), + ) + onDrawBehind { + val aspectRatio = size.width / size.height + scale(maxOf(1f, aspectRatio), maxOf(1f, 1 / aspectRatio)) { + drawRect(gradientBrush) + } + } + }, + ) { + + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize()) { + Box(Modifier.weight(1f)) { + if (onAddImage != null) { + GenerateButton( + modifier = Modifier.align(Alignment.Center), + text = stringResource(R.string.add_image), + onClick = onAddImage, + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.primary, + icon = painterResource(R.drawable.ic_ai_img), + ) + } + } + hintText?.let { + Text( + text = it, + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp), + ) + } + bottomContent?.invoke() + } + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_AddImageButton() { + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.Empty(onAddImage = {}), + ) + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_BottomContent() { + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.Empty(onAddImage = {}), + bottomContent = { + Box( + Modifier + .background(Color.Red) + .fillMaxWidth() + .height(100.dp), + ) + }, + ) + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_Empty() { + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.Empty(hintText = "Generate an image to edit"), + ) + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_Empty_WithBottomContent() { + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.Empty(hintText = "Generate an image to edit"), + bottomContent = { + Box( + Modifier + .background(Color.Red) + .fillMaxWidth() + .height(100.dp), + ) + }, + ) + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_WithImage_Image() { + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.WithImage.Image(Uri.EMPTY), + ) + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_WithImage_Image_WithBottomContent() { + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.WithImage.Image(Uri.EMPTY), + bottomContent = { + Box( + Modifier + .background(Color.Red) + .fillMaxWidth() + .height(100.dp), + ) + }, + ) + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_WithImage_Analyzing() { + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.WithImage.Analyzing(Uri.EMPTY), + ) + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_WithImage_Analyzing_WithBottomContent() { + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.WithImage.Analyzing(Uri.EMPTY), + bottomContent = { + Box( + Modifier + .background(Color.Red) + .fillMaxWidth() + .height(100.dp), + ) + }, + ) + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_WithImage_GeneratingText() { + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.WithImage.GeneratingText(Uri.EMPTY), + ) + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_WithImage_GeneratingText_WithBottomContent() { + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.WithImage.GeneratingText(Uri.EMPTY), + bottomContent = { + Box( + Modifier + .background(Color.Red) + .fillMaxWidth() + .height(100.dp), + ) + }, + ) + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_WithImage_WithText() { + val dummyText = "Painting of a duck\n" + + "Swimming in a dark blue pond\n" + + "wow look at that duck" + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.WithImage.WithText(Uri.EMPTY, dummyText), + ) + } +} + +@Preview(widthDp = 380, heightDp = 652) +@Composable +private fun ImageInputPreview_WithImage_WithText_WithBottomContent() { + val dummyText = "Painting of a **duck**\n" + + "Swimming in a dark blue pond\n" + + "wow look at that duck" + AISampleCatalogTheme { + ImageInput( + type = ImageInputType.WithImage.WithText(Uri.EMPTY, dummyText), + bottomContent = { + Box( + Modifier + .background(Color.Red) + .fillMaxWidth() + .height(100.dp), + ) + }, + ) + } +} diff --git a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/theme/Color.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/MarkdownText.kt similarity index 52% rename from ai-catalog/app/src/main/java/com/android/ai/catalog/ui/theme/Color.kt rename to ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/MarkdownText.kt index 663b111e..aa3f5217 100644 --- a/ai-catalog/app/src/main/java/com/android/ai/catalog/ui/theme/Color.kt +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/MarkdownText.kt @@ -13,14 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.ai.catalog.ui.theme +package com.android.ai.uicomponent -import androidx.compose.ui.graphics.Color +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.halilibo.richtext.commonmark.Markdown +import com.halilibo.richtext.ui.RichTextStyle +import com.halilibo.richtext.ui.material3.RichText +import com.halilibo.richtext.ui.string.RichTextStringStyle -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +@Composable +fun MarkdownText(text: String, modifier: Modifier = Modifier) { -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) + RichText( + modifier = modifier, + style = RichTextStyle( + stringStyle = RichTextStringStyle(), + ), + ) { + Markdown(text) + } +} diff --git a/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/Message.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/Message.kt new file mode 100644 index 00000000..d663ef8a --- /dev/null +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/Message.kt @@ -0,0 +1,188 @@ +/* + * 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.uicomponent + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +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.asImageBitmap +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.ai.theme.AISampleCatalogTheme + +data class ChatMessage( + val text: String, + val timestamp: Long, + val isIncoming: Boolean = false, + val image: Bitmap? = null, +) + +@Composable +fun MessageList(messages: List, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState()) { + LazyColumn( + state = listState, + modifier = modifier.padding(bottom = 68.dp), + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Bottom), + ) { + items(items = messages, key = { it.timestamp }) { message -> + MessageBubble( + message = message, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + } +} + +private val roundCornerShapeSend = RoundedCornerShape( + topStart = 40.dp, + topEnd = 4.dp, + bottomStart = 40.dp, + bottomEnd = 40.dp, +) + +private val roundCornerShapeReceive = RoundedCornerShape( + topStart = 4.dp, + topEnd = 40.dp, + bottomStart = 40.dp, + bottomEnd = 40.dp, +) + +@Composable +fun MessageBubble(message: ChatMessage, modifier: Modifier = Modifier) { + Row { + if (message.isIncoming) { + Icon( + painterResource(R.drawable.ic_spark), + contentDescription = null, + modifier = Modifier + .size(32.dp) + .padding(end = 8.dp), + ) + } + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = if (message.isIncoming) Alignment.Start else Alignment.End, + ) { + if (message.text.isNotEmpty()) { + Surface( + modifier = Modifier + .widthIn(max = 300.dp) + .border( + 2.dp, + if (message.isIncoming) Color.Transparent else MaterialTheme.colorScheme.outline, + shape = if (message.isIncoming) roundCornerShapeReceive else roundCornerShapeSend, + ) + .clip( + shape = if (message.isIncoming) roundCornerShapeReceive else roundCornerShapeSend, + ), + color = if (message.isIncoming) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.surface + }, + ) { + Column { + MarkdownText( + text = message.text, + modifier = Modifier.padding(16.dp), + ) + } + } + } + message.image?.let { it: Bitmap -> + Image( + modifier = Modifier + .widthIn(max = 300.dp) + .padding(16.dp) + .clip(shape = RoundedCornerShape(12.dp)), + bitmap = it.asImageBitmap(), + contentDescription = null, + ) + } + } + } +} + +@Preview +@Composable +private fun MessageBubbleIncomingPreview() { + AISampleCatalogTheme { + MessageBubble( + message = ChatMessage( + text = "Hi there!", + timestamp = 124, + isIncoming = true, + ), + ) + } +} + +@Preview +@Composable +private fun MessageBubbleOutgoingPreview() { + AISampleCatalogTheme { + MessageBubble( + message = ChatMessage( + text = "I’m super sleepy today, what coffee drink has the most caffeine, but not too much. Also something hot.", + timestamp = 123, + isIncoming = false, + ), + ) + } +} + +@Preview +@Composable +private fun MessageListPreview() { + AISampleCatalogTheme { + MessageList( + messages = listOf( + ChatMessage( + text = "Hi there!", + timestamp = 124, + isIncoming = true, + ), + ChatMessage( + text = "I’m super sleepy today, what coffee drink has the most caffeine, but not too much. Also something hot.", + timestamp = 123, + isIncoming = false, + ), + ), + ) + } +} diff --git a/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/SampleDetailTopAppBar.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/SampleDetailTopAppBar.kt new file mode 100644 index 00000000..12ed888d --- /dev/null +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/SampleDetailTopAppBar.kt @@ -0,0 +1,170 @@ +/* + * 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.uicomponent + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.TopAppBarDefaults +import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.TopAppBarState +import androidx.compose.material3.TwoRowsTopAppBar +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.ai.theme.AISampleCatalogTheme + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SampleDetailTopAppBar( + sampleName: String, + sampleDescription: String, + sourceCodeUrl: String, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + topAppBarState: TopAppBarState = rememberTopAppBarState(), + scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState), +) { + val hasScrolled by remember { derivedStateOf { scrollBehavior.state.heightOffset != 0F } } + TwoRowsTopAppBar( + colors = topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = MaterialTheme.colorScheme.primary, + scrolledContainerColor = Color.Transparent, + subtitleContentColor = MaterialTheme.colorScheme.primary, + ), + title = { expanded -> + Text( + text = sampleName, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (expanded) 2 else 1, + overflow = TextOverflow.Ellipsis, + ) + }, + subtitle = { expanded -> + Text( + text = sampleDescription, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (expanded) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { BackButton(onClick = onBackClick) }, + actions = { + SeeCodeButton( + sourceCodeUrl = sourceCodeUrl, + withText = !hasScrolled, + ) + }, + scrollBehavior = scrollBehavior, + modifier = modifier.padding(bottom = 12.dp), + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Preview(backgroundColor = 0XFF000000, showBackground = true) +@Composable +fun SampleDetailTopAppBarPreview() { + AISampleCatalogTheme { + SampleDetailTopAppBar( + sampleName = "Sample Name", + sampleDescription = "Sample Description", + sourceCodeUrl = "https://example.com/source-code", + onBackClick = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Preview +@Composable +fun SampleDetailTopAppBarPreview_CollapseWhenContentIsScrolled() { + AISampleCatalogTheme { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + SampleDetailTopAppBar( + sampleName = "Sample Name", + sampleDescription = "Sample Description", + sourceCodeUrl = "https://example.com/source-code", + topAppBarState = topAppBarState, + scrollBehavior = scrollBehavior, + onBackClick = {}, + ) + }, + ) { innerPadding -> + val gradient = Brush.verticalGradient(listOf(Color.LightGray, Color.DarkGray)) + Box( + Modifier + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .requiredHeight(1000.dp) + .background(brush = gradient), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Preview +@Composable +fun SampleDetailTopAppBarPreview_CollapseWhenToolbarIsScrolled() { + AISampleCatalogTheme { + Scaffold( + topBar = { + SampleDetailTopAppBar( + sampleName = "Sample Name", + sampleDescription = "Sample Description", + sourceCodeUrl = "https://example.com/source-code", + onBackClick = {}, + ) + }, + ) { innerPadding -> + val gradient = Brush.verticalGradient(listOf(Color.LightGray, Color.DarkGray)) + Box( + Modifier + .padding(innerPadding) + .fillMaxSize() + .background(brush = gradient), + ) + } + } +} diff --git a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/player/VideoPlayer.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/SeeCodeButton.kt similarity index 55% rename from ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/player/VideoPlayer.kt rename to ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/SeeCodeButton.kt index 90b33a79..8671113e 100644 --- a/ai-catalog/samples/gemini-video-summarization/src/main/java/com/android/ai/samples/geminivideosummary/player/VideoPlayer.kt +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/SeeCodeButton.kt @@ -13,28 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.ai.samples.geminivideosummary.player +package com.android.ai.uicomponent -import androidx.compose.foundation.layout.height +import android.content.Intent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Code import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.ui.PlayerView +import androidx.core.net.toUri -/* - * A Composable function that displays video using ExoPlayer within a PlayerView in Jetpack Compose. - */ @Composable -fun VideoPlayer(exoPlayer: ExoPlayer, modifier: Modifier = Modifier) { +fun SeeCodeButton(sourceCodeUrl: String, modifier: Modifier = Modifier, withText: Boolean = true) { val context = LocalContext.current - AndroidView( - factory = { - PlayerView(context).apply { - player = exoPlayer - } - }, modifier = modifier.height(200.dp), + PrimaryButton( + text = if (withText) "SOURCE" else "", + icon = rememberVectorPainter(Icons.Filled.Code), + onClick = { + val intent = Intent(Intent.ACTION_VIEW, sourceCodeUrl.toUri()) + context.startActivity(intent) + }, + modifier = modifier, ) } diff --git a/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/SelectionDropdown.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/SelectionDropdown.kt new file mode 100644 index 00000000..61c12053 --- /dev/null +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/SelectionDropdown.kt @@ -0,0 +1,177 @@ +/* + * 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.uicomponent + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.ai.theme.AISampleCatalogTheme + +interface SelectableItem { + val itemLabel: String + val itemData: T +} + +@Composable +fun SelectionDropdown( + selectedItem: SelectableItem?, + isDropdownExpanded: Boolean, + itemList: List>, + selectPlaceHolder: String = stringResource(R.string.select_placeholder), + onItemSelected: (SelectableItem) -> Unit, + onDropdownExpanded: (Boolean) -> Unit, +) { + val shape = RoundedCornerShape(20.dp) + + Box { + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(shape) + .height(40.dp) + .width(250.dp) + .clickable { onDropdownExpanded(!isDropdownExpanded) } + .background(color = MaterialTheme.colorScheme.onSurface) + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + shape = shape, + ), + ) { + Text( + text = selectedItem?.itemLabel ?: selectPlaceHolder, + style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.inverseOnSurface), + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 16.dp), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + + ) + + Icon( + imageVector = Icons.Filled.ArrowDropDown, + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = stringResource(R.string.dropdown_content_description), + modifier = Modifier + .clickable { onDropdownExpanded(!isDropdownExpanded) } + .background( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = CircleShape, + ) + .width(40.dp) + .height(40.dp), + ) + } + + DropdownMenu( + expanded = isDropdownExpanded, + onDismissRequest = { onDropdownExpanded(false) }, + modifier = Modifier + .wrapContentWidth(), + ) { + itemList.forEach { it -> + DropdownMenuItem( + text = { Text(it.itemLabel) }, + onClick = { + onItemSelected(it) + onDropdownExpanded(false) + }, +// colors = MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.surfaceContainerHighest) + ) + } + } + } +} + +class PreviewSelectableItem( + override val itemLabel: String, + override val itemData: String, +) : SelectableItem +val previewListOfItems = listOf>( + PreviewSelectableItem("Item 1", "item_1"), + PreviewSelectableItem("Item 2", "item_2"), + PreviewSelectableItem("Item 3", "item_3"), + PreviewSelectableItem("Item 4", "item_4"), +) + +@Preview +@Composable +private fun SelectionDropdownPreviewCollapsed() { + + var isExpanded by remember { mutableStateOf(false) } + var selectedItem by remember { mutableStateOf(previewListOfItems[0]) } + + AISampleCatalogTheme { + SelectionDropdown( + selectedItem = selectedItem, + isDropdownExpanded = isExpanded, + itemList = previewListOfItems, + selectPlaceHolder = "", + onItemSelected = { selectedItem = it }, + onDropdownExpanded = { isExpanded = it }, + ) + } +} + +@Preview +@Composable +private fun SelectionDropdownPreviewExpanded() { + + var isExpanded by remember { mutableStateOf(true) } + var selectedItem by remember { mutableStateOf(previewListOfItems[0]) } + + AISampleCatalogTheme { + SelectionDropdown( + selectedItem = selectedItem, + isDropdownExpanded = isExpanded, + itemList = previewListOfItems, + selectPlaceHolder = "", + onItemSelected = { selectedItem = it }, + onDropdownExpanded = { isExpanded = it }, + ) + } +} diff --git a/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/Tag.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/Tag.kt new file mode 100644 index 00000000..7eef7b47 --- /dev/null +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/Tag.kt @@ -0,0 +1,75 @@ +/* + * 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.uicomponent + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.ai.theme.AISampleCatalogTheme + +@Composable +fun Tag(text: String, color: Color, modifier: Modifier = Modifier) { + + Row( + modifier = modifier + .padding(start = 2.dp, end = 6.dp, bottom = 4.dp) + .border(width = 1.dp, color = color, shape = RoundedCornerShape(size = 16.dp)), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(20.dp) + .padding(2.dp) + .clip(CircleShape) + .background(color), + ) + Spacer(modifier.width(2.dp)) + Text( + text = text.uppercase(), + color = color, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 3.dp, bottom = 2.dp), + maxLines = 1, + ) + Spacer(modifier.width(6.dp)) + } +} + +@Preview(showBackground = true) +@Composable +fun TagPreview() { + AISampleCatalogTheme { + Tag(text = "Gemini Nano", color = Color.Gray) + } +} 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 new file mode 100644 index 00000000..32f29a9a --- /dev/null +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/TextInput.kt @@ -0,0 +1,127 @@ +/* + * 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.uicomponent + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +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.res.painterResource +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 + +@Composable +fun TextInput( + state: TextFieldState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + maxLines: Int = 2, + placeholder: String = "", + primaryButton: @Composable () -> Unit = {}, + secondaryButton: @Composable () -> Unit = {}, +) { + val roundCornerShape = RoundedCornerShape(30.dp) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .border( + 1.dp, + MaterialTheme.colorScheme.outline, + shape = roundCornerShape, + ) + .clip( + shape = roundCornerShape, + ) + .background(color = MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + TextField( + state = state, + enabled = enabled, + placeholder = { + Text( + text = placeholder, + maxLines = 2, + style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), + ) + }, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = maxLines), + textStyle = MaterialTheme.typography.bodyLarge + .copy(color = MaterialTheme.colorScheme.onSurface), + modifier = Modifier + .weight(1f) + .wrapContentHeight() + .align(Alignment.CenterVertically) + .padding(start = 12.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + ) + secondaryButton() + primaryButton() + } +} + +@Composable +@Preview +fun TextInputPreview() { + AISampleCatalogTheme { + TextInput( + state = TextFieldState("Message hint"), + placeholder = "Placeholder", + primaryButton = { + GenerateButton( + text = "", + icon = painterResource(id = R.drawable.ic_ai_send), + modifier = Modifier + .width(72.dp) + .padding(4.dp), + onClick = {}, + ) + }, + secondaryButton = { + SecondaryButton( + icon = painterResource(id = R.drawable.ic_add), + modifier = Modifier + .width(45.dp) + .height(56.dp), + onClick = {}, + ) + }, + ) + } +} diff --git a/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/VideoPlayer.kt b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/VideoPlayer.kt new file mode 100644 index 00000000..397da849 --- /dev/null +++ b/ai-catalog/ui-component/src/main/java/com/android/ai/uicomponent/VideoPlayer.kt @@ -0,0 +1,579 @@ +/* + * 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:kotlin.OptIn(ExperimentalMaterial3ExpressiveApi::class) + +package com.android.ai.uicomponent + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Replay10 +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType.Companion.Uri +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.modifiers.resizeWithContentScale +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState +import androidx.media3.ui.compose.state.rememberPresentationState +import androidx.media3.ui.compose.state.rememberSeekBackButtonState +import androidx.media3.ui.compose.state.rememberSeekForwardButtonState +import com.android.ai.theme.AISampleCatalogTheme +import kotlinx.coroutines.delay + +private const val CONTROLS_TIMEOUT_MS = 3000L + +data class VideoPickerData( + val title: String, + val uri: Uri, +) + +@OptIn(UnstableApi::class) // New Media3 Compose artifact is currently experimental +@Composable +fun VideoPlayer( + player: Player?, + modifier: Modifier = Modifier, + forceShowControls: Boolean = false, + videoPicker: (@Composable () -> Unit)? = null, // Optional video picker component +) { + Box( + modifier + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(40.dp)) + .clip(RoundedCornerShape(40.dp)), + ) { + val presentationState = rememberPresentationState(player) + PlayerScaffold( + cover = presentationState::coverSurface, + surface = { + PlayerSurface( + player, + modifier = Modifier.resizeWithContentScale( + contentScale = ContentScale.Fit, + presentationState.videoSizeDp, + ), + ) + }, + controls = { + player?.let { VideoControls(it, videoPicker = videoPicker) } + }, + forceShowControls = forceShowControls, + ) + } +} + +@Composable +fun BoxScope.PlayerScaffold( + cover: () -> Boolean, + surface: @Composable () -> Unit, + controls: @Composable () -> Unit, + modifier: Modifier = Modifier, + forceShowControls: Boolean = false, +) { + var showControls by remember { mutableStateOf(true) } + LaunchedEffect(showControls, forceShowControls) { + if (!forceShowControls && showControls) { + delay(CONTROLS_TIMEOUT_MS) + showControls = false + } + } + Box( + modifier + .matchParentSize() + .drawWithContent { + drawRect(Color.Black) + drawContent() + if (cover()) drawRect(Color.Black) + } + .clickable { + if (!forceShowControls) { + showControls = !showControls + } + }, + ) { surface() } + + AnimatedVisibility( + visible = forceShowControls || showControls, + modifier = Modifier.matchParentSize(), + enter = fadeIn(), + exit = fadeOut(), + ) { controls() } +} + +@Preview +@Composable +fun PlayerScaffoldPreview() { + AISampleCatalogTheme { + Box(modifier = Modifier.size(width = 400.dp, height = 200.dp)) { + PlayerScaffold( + cover = { false }, + surface = { + Box( + Modifier + .fillMaxSize() + .background(Color.Gray), + ) + }, + controls = { VideoControlsScaffoldPreview() }, + ) + } + } +} + +@Composable +fun VideoPickerDropdown( + videoItems: List, + selectedVideo: Uri?, + onVideoSelected: (VideoPickerData) -> Unit, + isExpanded: Boolean, + onDropdownExpandedChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + Row( + modifier + .background(MaterialTheme.colorScheme.onSurface, RoundedCornerShape(percent = 100)) + .height(24.dp) + .widthIn(max = 200.dp) + .clickable(onClick = { onDropdownExpandedChanged(!isExpanded) }), + ) { + Spacer(Modifier.width(8.dp)) + Box( + Modifier + .align(Alignment.CenterVertically) + .weight(1f, fill = false), + ) { + Text( + text = videoItems.find { it.uri == selectedVideo }?.title ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.inverseOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(4.dp)) + val iconBg = MaterialTheme.colorScheme.surfaceContainerHighest + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = context.getString(R.string.select_video_dropdown), + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.drawBehind { + drawCircle(iconBg) + }, + ) + } + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { onDropdownExpandedChanged(false) }, + modifier = Modifier.widthIn(max = 240.dp), + ) { + videoItems.forEach { video -> + DropdownMenuItem( + text = { Text(video.title, style = MaterialTheme.typography.bodyMedium) }, + onClick = { + onVideoSelected(video) + onDropdownExpandedChanged(false) + }, + ) + } + } +} + +// Sample data for the picker +private val sampleVideosForPicker = listOf( + VideoPickerData("Big Buck Bunny", "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4".toUri()), + VideoPickerData("Tears of Steel", "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4".toUri()), + VideoPickerData("For Bigger Blazes", "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4".toUri()), +) + +@Preview +@Composable +private fun VideoPickerDropdownPreview() { + var selectedVideo by remember { mutableStateOf(sampleVideosForPicker.first()) } + AISampleCatalogTheme(darkTheme = true) { + VideoPickerDropdown( + videoItems = sampleVideosForPicker, + selectedVideo = selectedVideo.uri, + onVideoSelected = { selectedVideo = it }, + isExpanded = false, + onDropdownExpandedChanged = {}, + ) + } +} + +@Preview +@Composable +private fun VideoPickerDropdownPreview_LongTitle() { + AISampleCatalogTheme(darkTheme = true) { + val sampleData = listOf( + VideoPickerData( + "A very long video title will be cut off", + "https://example.com".toUri(), + ), + ) + var selectedVideo by remember { mutableStateOf(sampleData.first()) } + VideoPickerDropdown( + videoItems = sampleData, + selectedVideo = selectedVideo.uri, + onVideoSelected = { selectedVideo = it }, + isExpanded = false, + onDropdownExpandedChanged = {}, + ) + } +} + +@OptIn(UnstableApi::class) +@Composable +private fun BoxScope.VideoControls(player: Player, modifier: Modifier = Modifier, videoPicker: (@Composable () -> Unit)? = null) { + Box(modifier.matchParentSize()) { + VideoControlsScaffold( + videoPickerDropdown = videoPicker, + centerControls = { + CenterControls( + startButton = { + val state = rememberSeekBackButtonState(player) + SeekBackButton( + isEnabled = state::isEnabled, + onClick = state::onClick, + ) + }, + centerButton = { + val state = rememberPlayPauseButtonState(player) + PlayPauseButton( + showPlay = state::showPlay, + isEnabled = state::isEnabled, + onClick = state::onClick, + ) + }, + endButton = { + val state = rememberSeekForwardButtonState(player) + SeekForwardButton( + isEnabled = state::isEnabled, + onClick = state::onClick, + ) + }, + ) + }, + ) + } +} + +@Composable +private fun BoxScope.VideoControlsScaffold( + videoPickerDropdown: (@Composable () -> Unit)?, + centerControls: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, +) { + AISampleCatalogTheme(darkTheme = true) { + val brush = Brush.radialGradient( + listOf( + Color.Black.copy(alpha = 0.0f), + Color.Black.copy(0.2f), + ), + ) + Box( + modifier + .matchParentSize() + .drawBehind { + val aspectRatio = size.width / size.height + scale(maxOf(1f, aspectRatio), maxOf(1f, 1 / aspectRatio)) { + drawRect(brush) + } + }, + ) { + videoPickerDropdown?.let { dropdown -> + Box( + Modifier + .align(Alignment.TopStart) + .padding(16.dp), + ) { + dropdown() + } + } + Row( + Modifier + .align(Alignment.Center) + .padding(16.dp), + ) { centerControls() } + } + } +} + +@OptIn(UnstableApi::class) +@Preview +@Composable +private fun VideoControlsScaffoldPreview() { + AISampleCatalogTheme { + Box(modifier = Modifier.size(width = 400.dp, height = 200.dp)) { + VideoControlsScaffold( + videoPickerDropdown = { + // Preview for the dropdown area + VideoPickerDropdownPreview() + }, + centerControls = { CenterControlsPreview() }, + ) + } + } +} + +@Composable +private fun RowScope.CenterControls( + startButton: @Composable () -> Unit, + centerButton: @Composable () -> Unit, + endButton: @Composable () -> Unit, +) { + Spacer( + Modifier + .weight(0.1f) + .widthIn(min = 4.dp, max = 24.dp), + ) + Box( + modifier = Modifier + .padding(vertical = 20.dp) + .weight(1f) + .align(Alignment.CenterVertically) + .wrapContentWidth(align = Alignment.End), + ) { startButton() } + Spacer( + Modifier + .weight(0.1f) + .widthIn(min = 4.dp, max = 24.dp), + ) + Box( + Modifier + .weight(1f) + .padding(vertical = 8.dp) + .align(Alignment.CenterVertically) + .wrapContentWidth(Alignment.CenterHorizontally), + ) { centerButton() } + Spacer( + Modifier + .weight(0.1f) + .widthIn(min = 4.dp, max = 24.dp), + ) + Box( + modifier = Modifier + .weight(1f) + .padding(vertical = 20.dp) + .align(Alignment.CenterVertically) + .wrapContentWidth(align = Alignment.Start), + ) { endButton() } + Spacer( + Modifier + .weight(0.1f) + .widthIn(min = 4.dp, max = 24.dp), + ) +} + +@Preview +@Composable +private fun CenterControlsPreview() { + AISampleCatalogTheme { + Row { + CenterControls( + startButton = { + SeekBackButtonPreview() + }, + centerButton = { + PlayPauseButtonPreview() + }, + endButton = { + SeekForwardButtonPreview() + }, + ) + } + } +} + +@Preview(widthDp = 320) +@Composable +private fun CenterControlsPreview_Widths() { + val widths = listOf(600.dp, 300.dp, 260.dp, 220.dp) + AISampleCatalogTheme { + Column { + widths.forEach { width -> + Row( + Modifier + .width(width) + .padding(8.dp) + .border(1.dp, Color.Red) + .align(Alignment.CenterHorizontally), + ) { + CenterControls( + startButton = { + SeekBackButtonPreview() + }, + centerButton = { + PlayPauseButtonPreview() + }, + endButton = { + SeekForwardButtonPreview() + }, + ) + } + } + } + } +} + +@OptIn(UnstableApi::class) +@Composable +private fun PlayPauseButton(showPlay: () -> Boolean, isEnabled: () -> Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + FilledIconButton( + onClick = { onClick() }, + modifier = modifier.size(width = 124.dp, height = 72.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.secondary, + ), + shape = MaterialTheme.shapes.large, + enabled = isEnabled(), + ) { + Icon( + imageVector = if (showPlay()) Icons.Default.PlayArrow else Icons.Default.Pause, + contentDescription = + if (showPlay()) stringResource(R.string.playpause_button_play) + else stringResource(R.string.playpause_button_pause), + ) + } +} + +@OptIn(UnstableApi::class) +@Composable +private fun SeekForwardButton(isEnabled: () -> Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + FilledIconButton( + onClick = onClick, + modifier = modifier, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + enabled = isEnabled(), + ) { + Icon( + imageVector = Icons.Default.Forward10, + contentDescription = stringResource( + R.string.seek_forward_button, + ), + ) + } +} + +@OptIn(UnstableApi::class) +@Composable +private fun SeekBackButton(isEnabled: () -> Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + FilledIconButton( + onClick = onClick, + modifier = modifier, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + enabled = isEnabled(), + ) { + Icon( + imageVector = Icons.Default.Replay10, + contentDescription = stringResource( + R.string.seek_back_button, + ), + ) + } +} + +@Preview +@Composable +private fun PlayPauseButtonPreview() { + var showPlay by remember { mutableStateOf(true) } + AISampleCatalogTheme { + PlayPauseButton( + showPlay = { showPlay }, + isEnabled = { true }, + onClick = { showPlay = !showPlay }, + ) + } +} + +@Preview +@Composable +private fun SeekForwardButtonPreview() { + AISampleCatalogTheme { + SeekForwardButton( + isEnabled = { true }, + onClick = { /* Handle click */ }, + ) + } +} + +@Preview +@Composable +private fun SeekBackButtonPreview() { + AISampleCatalogTheme { + SeekBackButton( + isEnabled = { true }, + onClick = { /* Handle click */ }, + ) + } +} diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_add.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_add.png new file mode 100644 index 00000000..90d7c2b2 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_add.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_add_text.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_add_text.png new file mode 100644 index 00000000..a4f1263c Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_add_text.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_bg.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_bg.png new file mode 100644 index 00000000..698bc76d Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_bg.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_chapters.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_chapters.png new file mode 100644 index 00000000..e1564469 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_chapters.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_edit.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_edit.png new file mode 100644 index 00000000..6fec99e5 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_edit.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_hashtags.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_hashtags.png new file mode 100644 index 00000000..4abad68c Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_hashtags.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_img.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_img.png new file mode 100644 index 00000000..39414686 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_img.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_mic.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_mic.png new file mode 100644 index 00000000..90c54707 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_mic.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_send.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_send.png new file mode 100644 index 00000000..5c38678e Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_send.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_summary.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_summary.png new file mode 100644 index 00000000..b46a3de3 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_summary.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_tags.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_tags.png new file mode 100644 index 00000000..4cd21e46 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_tags.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_text.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_text.png new file mode 100644 index 00000000..8316f184 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_ai_text.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_code.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_code.png new file mode 100644 index 00000000..c18cd92d Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_code.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_delete.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_delete.png new file mode 100644 index 00000000..88cc5487 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_delete.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_dev_guide.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_dev_guide.png new file mode 100644 index 00000000..147350be Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_dev_guide.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_redo.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_redo.png new file mode 100644 index 00000000..a1978c5e Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_redo.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_forward_10.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_forward_10.png new file mode 100644 index 00000000..6a41914e Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_forward_10.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_pause.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_pause.png new file mode 100644 index 00000000..b4436b0e Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_pause.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_play.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_play.png new file mode 100644 index 00000000..7e990bd5 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_play.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_rewind_10.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_rewind_10.png new file mode 100644 index 00000000..ba1864cf Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/ic_video_rewind_10.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/img_bg_general.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/img_bg_general.png new file mode 100644 index 00000000..076cfe67 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/img_bg_general.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-hdpi/img_fill.png b/ai-catalog/ui-component/src/main/res/drawable-hdpi/img_fill.png new file mode 100644 index 00000000..c226762c Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-hdpi/img_fill.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_add.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_add.png new file mode 100644 index 00000000..1ab5df27 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_add.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_add_text.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_add_text.png new file mode 100644 index 00000000..478848ea Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_add_text.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_bg.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_bg.png new file mode 100644 index 00000000..a7b699d5 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_bg.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_chapters.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_chapters.png new file mode 100644 index 00000000..1bd283ac Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_chapters.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_edit.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_edit.png new file mode 100644 index 00000000..910dd0fe Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_edit.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_hashtags.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_hashtags.png new file mode 100644 index 00000000..33c4a3e7 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_hashtags.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_img.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_img.png new file mode 100644 index 00000000..abbe7d5f Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_img.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_mic.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_mic.png new file mode 100644 index 00000000..0be04987 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_mic.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_send.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_send.png new file mode 100644 index 00000000..d895b6b2 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_send.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_summary.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_summary.png new file mode 100644 index 00000000..c3b38f10 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_summary.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_tags.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_tags.png new file mode 100644 index 00000000..2ede51ef Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_tags.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_text.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_text.png new file mode 100644 index 00000000..2804d8f5 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_ai_text.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_code.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_code.png new file mode 100644 index 00000000..6341abbf Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_code.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_delete.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_delete.png new file mode 100644 index 00000000..3d7117aa Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_delete.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_dev_guide.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_dev_guide.png new file mode 100644 index 00000000..0e59ab6b Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_dev_guide.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_redo.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_redo.png new file mode 100644 index 00000000..5a2c7736 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_redo.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_forward_10.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_forward_10.png new file mode 100644 index 00000000..d9d434af Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_forward_10.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_pause.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_pause.png new file mode 100644 index 00000000..eeac574a Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_pause.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_play.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_play.png new file mode 100644 index 00000000..618f94b2 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_play.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_rewind_10.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_rewind_10.png new file mode 100644 index 00000000..e180c17a Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/ic_video_rewind_10.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/img_bg_general.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/img_bg_general.png new file mode 100644 index 00000000..1aa42496 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/img_bg_general.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-mdpi/img_fill.png b/ai-catalog/ui-component/src/main/res/drawable-mdpi/img_fill.png new file mode 100644 index 00000000..24950f04 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-mdpi/img_fill.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_add.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_add.png new file mode 100644 index 00000000..762e1874 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_add.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_add_text.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_add_text.png new file mode 100644 index 00000000..37cbc16a Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_add_text.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_bg.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_bg.png new file mode 100644 index 00000000..746e08d9 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_bg.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_chapters.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_chapters.png new file mode 100644 index 00000000..1e00c5f3 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_chapters.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_edit.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_edit.png new file mode 100644 index 00000000..47b8996d Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_edit.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_hashtags.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_hashtags.png new file mode 100644 index 00000000..b23a8a51 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_hashtags.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_img.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_img.png new file mode 100644 index 00000000..7e2a61d3 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_img.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_mic.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_mic.png new file mode 100644 index 00000000..296bee14 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_mic.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_send.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_send.png new file mode 100644 index 00000000..be39afa4 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_send.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_summary.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_summary.png new file mode 100644 index 00000000..df2ff128 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_summary.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_tags.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_tags.png new file mode 100644 index 00000000..324628a5 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_tags.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_text.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_text.png new file mode 100644 index 00000000..8882758b Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_ai_text.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_code.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_code.png new file mode 100644 index 00000000..4543f9c0 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_code.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_delete.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_delete.png new file mode 100644 index 00000000..bc20a96c Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_delete.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_dev_guide.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_dev_guide.png new file mode 100644 index 00000000..9dbd2ae5 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_dev_guide.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_redo.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_redo.png new file mode 100644 index 00000000..bcc01230 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_redo.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_forward_10.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_forward_10.png new file mode 100644 index 00000000..5c733246 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_forward_10.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_pause.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_pause.png new file mode 100644 index 00000000..6e5a633c Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_pause.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_play.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_play.png new file mode 100644 index 00000000..86073cde Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_play.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_rewind_10.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_rewind_10.png new file mode 100644 index 00000000..cba29231 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/ic_video_rewind_10.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/img_bg_general.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/img_bg_general.png new file mode 100644 index 00000000..b8633db3 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/img_bg_general.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xhdpi/img_fill.png b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/img_fill.png new file mode 100644 index 00000000..b2362be0 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xhdpi/img_fill.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_add.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_add.png new file mode 100644 index 00000000..bb5e45b4 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_add.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_add_text.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_add_text.png new file mode 100644 index 00000000..5d7e0876 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_add_text.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_bg.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_bg.png new file mode 100644 index 00000000..9892b45d Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_bg.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_chapters.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_chapters.png new file mode 100644 index 00000000..b2696f2d Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_chapters.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_edit.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_edit.png new file mode 100644 index 00000000..259d5e84 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_edit.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_hashtags.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_hashtags.png new file mode 100644 index 00000000..4d127ef6 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_hashtags.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_img.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_img.png new file mode 100644 index 00000000..5076cf06 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_img.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_mic.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_mic.png new file mode 100644 index 00000000..f46eca9d Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_mic.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_send.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_send.png new file mode 100644 index 00000000..3bb47ce7 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_send.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_summary.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_summary.png new file mode 100644 index 00000000..0ffd406e Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_summary.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_tags.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_tags.png new file mode 100644 index 00000000..a74f3f2f Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_tags.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_text.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_text.png new file mode 100644 index 00000000..e7b546a4 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_ai_text.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_code.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_code.png new file mode 100644 index 00000000..1a617cf3 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_code.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_delete.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_delete.png new file mode 100644 index 00000000..b69aaa7c Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_delete.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_dev_guide.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_dev_guide.png new file mode 100644 index 00000000..cfc7531c Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_dev_guide.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_redo.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_redo.png new file mode 100644 index 00000000..84e473e9 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_redo.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_forward_10.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_forward_10.png new file mode 100644 index 00000000..e7545901 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_forward_10.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_pause.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_pause.png new file mode 100644 index 00000000..07091da9 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_pause.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_play.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_play.png new file mode 100644 index 00000000..2a3d6e1c Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_play.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_rewind_10.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_rewind_10.png new file mode 100644 index 00000000..bd074569 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/ic_video_rewind_10.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/img_bg_general.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/img_bg_general.png new file mode 100644 index 00000000..7e584a45 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/img_bg_general.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/img_fill.png b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/img_fill.png new file mode 100644 index 00000000..09d1568f Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxhdpi/img_fill.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_add.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_add.png new file mode 100644 index 00000000..00885ef0 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_add.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_add_text.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_add_text.png new file mode 100644 index 00000000..2f5a085a Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_add_text.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_bg.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_bg.png new file mode 100644 index 00000000..8c99c184 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_bg.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_chapters.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_chapters.png new file mode 100644 index 00000000..dd73c6bd Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_chapters.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_edit.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_edit.png new file mode 100644 index 00000000..529f5ac3 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_edit.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_hashtags.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_hashtags.png new file mode 100644 index 00000000..029bc876 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_hashtags.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_img.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_img.png new file mode 100644 index 00000000..9bda5fb3 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_img.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_mic.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_mic.png new file mode 100644 index 00000000..b1629045 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_mic.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_send.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_send.png new file mode 100644 index 00000000..be202ec9 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_send.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_summary.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_summary.png new file mode 100644 index 00000000..9096e21a Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_summary.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_tags.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_tags.png new file mode 100644 index 00000000..cc5f2cba Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_tags.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_text.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_text.png new file mode 100644 index 00000000..2287308d Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_ai_text.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_code.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_code.png new file mode 100644 index 00000000..ea141a8a Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_code.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_delete.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_delete.png new file mode 100644 index 00000000..af10ee97 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_delete.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_dev_guide.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_dev_guide.png new file mode 100644 index 00000000..2d5c515c Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_dev_guide.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_redo.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_redo.png new file mode 100644 index 00000000..5252dcb1 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_redo.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_forward_10.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_forward_10.png new file mode 100644 index 00000000..abcbb111 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_forward_10.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_pause.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_pause.png new file mode 100644 index 00000000..19599701 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_pause.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_play.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_play.png new file mode 100644 index 00000000..b195775d Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_play.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_rewind_10.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_rewind_10.png new file mode 100644 index 00000000..1abbe317 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/ic_video_rewind_10.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/img_bg_general.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/img_bg_general.png new file mode 100644 index 00000000..4d4f231c Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/img_bg_general.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/img_fill.png b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/img_fill.png new file mode 100644 index 00000000..e7c87329 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable-xxxhdpi/img_fill.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable/bg.png b/ai-catalog/ui-component/src/main/res/drawable/bg.png new file mode 100644 index 00000000..d92dc328 Binary files /dev/null and b/ai-catalog/ui-component/src/main/res/drawable/bg.png differ diff --git a/ai-catalog/ui-component/src/main/res/drawable/ic_spark.xml b/ai-catalog/ui-component/src/main/res/drawable/ic_spark.xml new file mode 100644 index 00000000..32439ce8 --- /dev/null +++ b/ai-catalog/ui-component/src/main/res/drawable/ic_spark.xml @@ -0,0 +1,9 @@ + + + diff --git a/ai-catalog/ui-component/src/main/res/values-v23/font_certs.xml b/ai-catalog/ui-component/src/main/res/values-v23/font_certs.xml new file mode 100644 index 00000000..207b62f1 --- /dev/null +++ b/ai-catalog/ui-component/src/main/res/values-v23/font_certs.xml @@ -0,0 +1,32 @@ + + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + + + diff --git a/ai-catalog/ui-component/src/main/res/values/strings.xml b/ai-catalog/ui-component/src/main/res/values/strings.xml new file mode 100644 index 00000000..29f41851 --- /dev/null +++ b/ai-catalog/ui-component/src/main/res/values/strings.xml @@ -0,0 +1,12 @@ + + + Play + Pause + Skip 10 seconds + Rewind 10 seconds + Select video + undo + Select an option + Option selection + Add image + \ No newline at end of file