diff --git a/firebase-ai/app/build.gradle.kts b/firebase-ai/app/build.gradle.kts index a3e1a5508..b070d8906 100644 --- a/firebase-ai/app/build.gradle.kts +++ b/firebase-ai/app/build.gradle.kts @@ -81,6 +81,11 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.ai) + // Image loading + implementation(libs.coil3.coil.compose) + implementation(libs.coil.network.okhttp) + implementation(libs.coil.svg) + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/FirebaseAISamples.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/FirebaseAISamples.kt index 6923b359d..c58ae508f 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/FirebaseAISamples.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/FirebaseAISamples.kt @@ -408,5 +408,28 @@ val FIREBASE_AI_SAMPLES = listOf( thinkingBudget = -1 // Dynamic Thinking } } - ) + ), + Sample( + title = "SVG Generator", + description = "Use Gemini 3 Flash preview to create SVG illustrations", + navRoute = "svg", + categories = listOf(Category.IMAGE, Category.TEXT), + initialPrompt = content { + text( + "a kitten" + ) + }, + generationConfig = generationConfig { + thinkingConfig { + thinkingBudget = -1 + } + }, + systemInstructions = content { text(""" + You are an expert at turning image prompts into SVG code. When given a prompt, + use your creativity to code a 800x600 SVG rendering of it. + Always add viewBox="0 0 800 600" to the root svg tag. Do + not import external assets, they won't work. Return ONLY the SVG code, nothing else, + no commentary. + """.trimIndent()) } + ), ) diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt index 6848d0829..e3e37064b 100644 --- a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/MainActivity.kt @@ -36,6 +36,8 @@ import com.google.firebase.quickstart.ai.feature.live.StreamRealtimeVideoRoute import com.google.firebase.quickstart.ai.feature.live.StreamRealtimeVideoScreen import com.google.firebase.quickstart.ai.feature.media.imagen.ImagenRoute import com.google.firebase.quickstart.ai.feature.media.imagen.ImagenScreen +import com.google.firebase.quickstart.ai.feature.svg.SvgRoute +import com.google.firebase.quickstart.ai.feature.svg.SvgScreen import com.google.firebase.quickstart.ai.feature.text.ChatRoute import com.google.firebase.quickstart.ai.feature.text.ChatScreen import com.google.firebase.quickstart.ai.feature.text.TextGenRoute @@ -47,7 +49,6 @@ class MainActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() catImage = BitmapFactory.decodeResource(applicationContext.resources, R.drawable.cat) setContent { @@ -98,6 +99,9 @@ class MainActivity : ComponentActivity() { "text" -> { navController.navigate(TextGenRoute(it.id)) } + "svg" -> { + navController.navigate(SvgRoute(it.id)) + } } } ) @@ -125,6 +129,9 @@ class MainActivity : ComponentActivity() { composable { TextGenScreen() } + composable { + SvgScreen() + } } } } diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/svg/SvgScreen.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/svg/SvgScreen.kt new file mode 100644 index 000000000..be745faec --- /dev/null +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/svg/SvgScreen.kt @@ -0,0 +1,131 @@ +package com.google.firebase.quickstart.ai.feature.svg + +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.SubcomposeAsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.svg.SvgDecoder +import kotlinx.coroutines.Dispatchers +import kotlinx.serialization.Serializable +import java.nio.ByteBuffer + +@Serializable +class SvgRoute(val sampleId: String) + +@Composable +fun SvgScreen( + svgViewModel: SvgViewModel = viewModel() +) { + var prompt by rememberSaveable { mutableStateOf(svgViewModel.initialPrompt) } + val errorMessage by svgViewModel.errorMessage.collectAsStateWithLifecycle() + val isLoading by svgViewModel.isLoading.collectAsStateWithLifecycle() + val generatedSvgs by svgViewModel.generatedSvgs.collectAsStateWithLifecycle() + + Column { + ElevatedCard( + modifier = Modifier + .padding(all = 16.dp) + .fillMaxWidth(), + shape = MaterialTheme.shapes.large + ) { + OutlinedTextField( + value = prompt, + label = { Text("Generate a SVG of") }, + placeholder = { Text("Enter text to generate image") }, + onValueChange = { prompt = it }, + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) + TextButton( + onClick = { + svgViewModel.generateSVG(prompt) + }, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 16.dp) + .align(Alignment.End) + ) { + Text("Generate") + } + } + if (isLoading) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .padding(all = 8.dp) + .align(Alignment.CenterHorizontally) + ) { + CircularProgressIndicator() + } + } + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(generatedSvgs) { svg -> + Card( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.onSecondaryContainer + ) + ) { + SubcomposeAsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(ByteBuffer.wrap(svg.toByteArray())) + .decoderFactory(SvgDecoder.Factory()) + .decoderCoroutineContext(Dispatchers.Main) + .crossfade(true) + .build(), + contentDescription = "Generated SVG", + modifier = Modifier + .fillMaxWidth() + ) + } + } + } + errorMessage?.let { + Card( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(all = 16.dp) + ) + } + } + } +} \ No newline at end of file diff --git a/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/svg/SvgViewModel.kt b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/svg/SvgViewModel.kt new file mode 100644 index 000000000..4ad04b3bd --- /dev/null +++ b/firebase-ai/app/src/main/java/com/google/firebase/quickstart/ai/feature/svg/SvgViewModel.kt @@ -0,0 +1,72 @@ +package com.google.firebase.quickstart.ai.feature.svg + +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.toRoute +import com.google.firebase.Firebase +import com.google.firebase.ai.GenerativeModel +import com.google.firebase.ai.ai +import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.TextPart +import com.google.firebase.ai.type.asTextOrNull +import com.google.firebase.quickstart.ai.FIREBASE_AI_SAMPLES +import com.google.firebase.quickstart.ai.feature.text.ChatRoute +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class SvgViewModel( + savedStateHandle: SavedStateHandle +) : ViewModel() { + private val sampleId = savedStateHandle.toRoute().sampleId + private val sample = FIREBASE_AI_SAMPLES.first { it.id == sampleId } + val initialPrompt: String = + sample.initialPrompt?.parts + ?.filterIsInstance() + ?.first() + ?.asTextOrNull().orEmpty() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage + + private val _generatedSvgList = mutableStateListOf() + val generatedSvgs: StateFlow> = + MutableStateFlow>(_generatedSvgList) + + private val generativeModel: GenerativeModel + + init { + generativeModel = Firebase.ai( + backend = sample.backend + ).generativeModel( + modelName = sample.modelName ?: "gemini-3-flash-preview", + systemInstruction = sample.systemInstructions, + generationConfig = sample.generationConfig, + tools = sample.tools + ) + } + + fun generateSVG(prompt: String) { + _isLoading.value = true + viewModelScope.launch(Dispatchers.IO) { + try { + val response = generativeModel.generateContent(prompt) + response.text?.let { + _generatedSvgList.add(0, it) + } + _errorMessage.value = null + } catch (e: Exception) { + _errorMessage.value = e.localizedMessage + } finally { + _isLoading.value = false + } + } + + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4037bf73e..775ae3fed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ activityCompose = "1.12.1" agp = "8.13.2" camerax = "1.5.2" coilCompose = "2.7.0" +coil3Compose = "3.3.0" composeBom = "2025.12.00" composeNavigation = "2.9.6" coreKtx = "1.17.0" @@ -44,6 +45,9 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil3Compose" } +coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil3Compose" } +coil3-coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3Compose" } compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation"} firebase-ai = { module = "com.google.firebase:firebase-ai" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }