From a15901e8b1ad4de5bab3eea32a3844e14e79d79d Mon Sep 17 00:00:00 2001 From: Marina Coelho Date: Wed, 4 Feb 2026 14:59:24 +0000 Subject: [PATCH 1/6] Use Server Prompt Templates (#2) * Create BitmapExt to convert bitmap to base64, use Server Prompt Templates to store prompts, model name and configuration * Delete unused remote config parameters, improve Bitmap extension, isolate FirebaseAI creation on HiltModule * Update remote config default values --- .../data/datasource/AIRemoteDataSource.kt | 174 ++++++------------ .../data/injection/FirebaseHiltModule.kt | 16 +- .../data/repository/AIRepository.kt | 8 +- .../ui/generate/GenerateViewModel.kt | 3 +- .../ui/scanMeal/ScanMealViewModel.kt | 3 +- .../friendlymeals/ui/shared/BitmapExt.kt | 13 ++ .../main/res/xml/remote_config_defaults.xml | 32 ++-- 7 files changed, 113 insertions(+), 136 deletions(-) create mode 100644 app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/BitmapExt.kt diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt index 4d2e73f..1878b28 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt @@ -1,119 +1,46 @@ package com.google.firebase.example.friendlymeals.data.datasource import android.graphics.Bitmap -import com.google.firebase.ai.FirebaseAI -import com.google.firebase.ai.GenerativeModel -import com.google.firebase.ai.ImagenModel +import com.google.firebase.ai.TemplateGenerativeModel +import com.google.firebase.ai.TemplateImagenModel import com.google.firebase.ai.type.ImagePart -import com.google.firebase.ai.type.ImagenAspectRatio -import com.google.firebase.ai.type.ImagenImageFormat -import com.google.firebase.ai.type.ImagenPersonFilterLevel -import com.google.firebase.ai.type.ImagenSafetyFilterLevel -import com.google.firebase.ai.type.ImagenSafetySettings -import com.google.firebase.ai.type.ResponseModality -import com.google.firebase.ai.type.Schema -import com.google.firebase.ai.type.content -import com.google.firebase.ai.type.generationConfig -import com.google.firebase.ai.type.imagenGenerationConfig +import com.google.firebase.ai.type.PublicPreviewAPI import com.google.firebase.example.friendlymeals.data.schema.MealSchema import com.google.firebase.example.friendlymeals.data.schema.RecipeSchema import com.google.firebase.remoteconfig.FirebaseRemoteConfig import kotlinx.serialization.json.Json import javax.inject.Inject +@OptIn(PublicPreviewAPI::class) class AIRemoteDataSource @Inject constructor( - private val firebaseAI: FirebaseAI, + private val generativeModel: TemplateGenerativeModel, + private val imagenModel: TemplateImagenModel, private val remoteConfig: FirebaseRemoteConfig ) { private val json = Json { ignoreUnknownKeys = true } - private val generativeModel: GenerativeModel get() = - firebaseAI.generativeModel( - modelName = remoteConfig.getString("model_name"), - generationConfig = generationConfig { - responseModalities = listOf(ResponseModality.TEXT, ResponseModality.IMAGE) - } - ) - - private val imagenModel: ImagenModel get() = - firebaseAI.imagenModel( - modelName = remoteConfig.getString("imagen_name"), - generationConfig = imagenGenerationConfig { - numberOfImages = 1 - aspectRatio = ImagenAspectRatio.SQUARE_1x1 - imageFormat = ImagenImageFormat.png() - }, - safetySettings = ImagenSafetySettings( - safetyFilterLevel = ImagenSafetyFilterLevel.BLOCK_LOW_AND_ABOVE, - personFilterLevel = ImagenPersonFilterLevel.BLOCK_ALL + suspend fun generateIngredients(imageData: String): String { + val response = generativeModel.generateContent( + templateId = remoteConfig.getString(GENERATE_INGREDIENTS_KEY), + inputs = mapOf( + MIME_TYPE_FIELD to MIME_TYPE_VALUE, + IMAGE_DATA_FIELD to imageData ) ) - private val mealSchemaModel: GenerativeModel get() = - firebaseAI.generativeModel( - modelName = remoteConfig.getString("schema_model_name"), - generationConfig = generationConfig { - responseMimeType = "application/json" - responseSchema = Schema.obj( - mapOf( - "protein" to Schema.string(), - "fat" to Schema.string(), - "carbs" to Schema.string(), - "sugar" to Schema.string(), - "ingredients" to Schema.array(Schema.string()) - ) - ) - } - ) - - private val recipeSchemaModel: GenerativeModel get() = - firebaseAI.generativeModel( - modelName = remoteConfig.getString("schema_model_name"), - generationConfig = generationConfig { - responseMimeType = "application/json" - responseSchema = Schema.obj( - mapOf( - "title" to Schema.string(), - "instructions" to Schema.string(), - "ingredients" to Schema.array(Schema.string()), - "prepTime" to Schema.string(), - "cookTime" to Schema.string(), - "servings" to Schema.string(), - "tags" to Schema.array(Schema.string()) - ) - ) - } - ) - - suspend fun generateIngredients(image: Bitmap): String { - val prompt = content { - image(image) - text("Please analyze this image and list all visible food ingredients. " + - "Format the response as a comma-separated list of ingredients. " + - "Be specific with measurements where possible, " + - "but focus on identifying the ingredients accurately.") - } - - val response = generativeModel.generateContent(prompt) return response.text.orEmpty() } suspend fun generateRecipe(ingredients: String, notes: String): RecipeSchema? { - var prompt = """ - Create a detailed recipe based on these ingredients: $ingredients. - - Format requirements: - - 'instructions': Provide the cooking steps as a clear list of instructions separated by newlines. Use bold formatting on the step numbers. Use Markdown. - - 'ingredients': List all necessary items, including quantities. - - 'prepTime', 'cookTime', 'servings': Short strings (e.g., "15 mins"). - - 'tags': Generate a list of 3-5 relevant category tags (e.g., "Healthy", "Vegan", "Gluten-Free", "Dessert", "Quick"). - """.trimIndent() - - if (notes.isNotBlank()) { - prompt += "\n\nIMPORTANT CUISINE AND DIETARY NOTES: $notes" - } - - val response = recipeSchemaModel.generateContent(prompt) + val response = generativeModel.generateContent( + templateId = remoteConfig.getString(GENERATE_RECIPE_KEY), + inputs = buildMap { + put(INGREDIENTS_FIELD, ingredients) + if (notes.isNotBlank()) { + put(NOTES_FIELD, notes) + } + } + ) return response.text?.let { json.decodeFromString(it) @@ -121,41 +48,54 @@ class AIRemoteDataSource @Inject constructor( } suspend fun generateRecipePhoto(recipeTitle: String): Bitmap? { - val prompt = "A professional food photography shot of this recipe: $recipeTitle. " + - "Style: High-end food photography, restaurant-quality plating, soft natural " + - "lighting, on a clean background, showing the complete plated dish." + val response = generativeModel.generateContent( + templateId = remoteConfig.getString(GENERATE_RECIPE_PHOTO_GEMINI_KEY), + inputs = mapOf(RECIPE_TITLE_FIELD to recipeTitle) + ) - return generativeModel.generateContent(prompt) - .candidates.firstOrNull()?.content?.parts + return response.candidates.firstOrNull()?.content?.parts ?.filterIsInstance()?.firstOrNull()?.image } suspend fun generateRecipePhotoImagen(recipeTitle: String): Bitmap? { - val prompt = "A professional food photography shot of this recipe: $recipeTitle. " + - "Style: High-end food photography, restaurant-quality plating, soft natural " + - "lighting, on a clean background, showing the complete plated dish." + val response = imagenModel.generateImages( + templateId = remoteConfig.getString(GENERATE_RECIPE_PHOTO_IMAGEN_KEY), + inputs = mapOf(RECIPE_TITLE_FIELD to recipeTitle) + ) - val imageResponse = imagenModel.generateImages(prompt) - return imageResponse.images.firstOrNull()?.asBitmap() + return response.images.firstOrNull()?.asBitmap() } - suspend fun scanMeal(image: Bitmap): MealSchema? { - val prompt = content { - image(image) - text( - """ - Analyze this image of a meal and estimate the nutritional content. - Return the result in JSON format matching the schema: - - protein, fat, carbs, sugar (strings with units, e.g., '20g') - - ingredients (list of strings) - """.trimIndent() + suspend fun scanMeal(imageData: String): MealSchema? { + val response = generativeModel.generateContent( + templateId = remoteConfig.getString(SCAN_MEAL_KEY), + inputs = mapOf( + MIME_TYPE_FIELD to MIME_TYPE_VALUE, + IMAGE_DATA_FIELD to imageData ) - } - - val response = mealSchemaModel.generateContent(prompt) + ) return response.text?.let { json.decodeFromString(it) } } + + companion object { + //Remote Config Keys + private const val GENERATE_INGREDIENTS_KEY = "generate_ingredients" + private const val GENERATE_RECIPE_KEY = "generate_recipe" + private const val GENERATE_RECIPE_PHOTO_GEMINI_KEY = "generate_recipe_photo_gemini" + private const val GENERATE_RECIPE_PHOTO_IMAGEN_KEY = "generate_recipe_photo_imagen" + private const val SCAN_MEAL_KEY = "scan_meal" + + //Template input fields + private const val IMAGE_DATA_FIELD = "imageData" + private const val MIME_TYPE_FIELD = "mimeType" + private const val INGREDIENTS_FIELD = "ingredients" + private const val NOTES_FIELD = "notes" + private const val RECIPE_TITLE_FIELD = "recipeTitle" + + //Template input values + private const val MIME_TYPE_VALUE = "image/jpeg" + } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/injection/FirebaseHiltModule.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/injection/FirebaseHiltModule.kt index 39196d1..a934497 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/injection/FirebaseHiltModule.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/injection/FirebaseHiltModule.kt @@ -3,8 +3,11 @@ package com.google.firebase.example.friendlymeals.data.injection import android.util.Log import com.google.firebase.Firebase import com.google.firebase.ai.FirebaseAI +import com.google.firebase.ai.TemplateGenerativeModel +import com.google.firebase.ai.TemplateImagenModel import com.google.firebase.ai.ai import com.google.firebase.ai.type.GenerativeBackend +import com.google.firebase.ai.type.PublicPreviewAPI import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.auth import com.google.firebase.example.friendlymeals.R @@ -29,10 +32,21 @@ object FirebaseHiltModule { @Provides fun auth(): FirebaseAuth = Firebase.auth - @Provides fun firebaseAI(): FirebaseAI { + @Provides + fun firebaseAI(): FirebaseAI { return Firebase.ai(backend = GenerativeBackend.googleAI()) } + @OptIn(PublicPreviewAPI::class) + @Provides fun generativeModel(ai: FirebaseAI): TemplateGenerativeModel { + return ai.templateGenerativeModel() + } + + @OptIn(PublicPreviewAPI::class) + @Provides fun imagenModel(ai: FirebaseAI): TemplateImagenModel { + return ai.templateImagenModel() + } + @Provides fun storage(): StorageReference { return Firebase.storage.reference } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt index 73a2eae..a85113e 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt @@ -9,8 +9,8 @@ import javax.inject.Inject class AIRepository @Inject constructor( private val aiRemoteDataSource: AIRemoteDataSource ) { - suspend fun generateIngredients(image: Bitmap): String { - return aiRemoteDataSource.generateIngredients(image) + suspend fun generateIngredients(imageData: String): String { + return aiRemoteDataSource.generateIngredients(imageData) } suspend fun generateRecipe(ingredients: String, notes: String): RecipeSchema? { @@ -25,7 +25,7 @@ class AIRepository @Inject constructor( return aiRemoteDataSource.generateRecipePhotoImagen(recipeTitle) } - suspend fun scanMeal(image: Bitmap): MealSchema? { - return aiRemoteDataSource.scanMeal(image) + suspend fun scanMeal(imageData: String): MealSchema? { + return aiRemoteDataSource.scanMeal(imageData) } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt index 5dc87b6..e8f8777 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt @@ -8,6 +8,7 @@ import com.google.firebase.example.friendlymeals.data.repository.AuthRepository import com.google.firebase.example.friendlymeals.data.repository.DatabaseRepository import com.google.firebase.example.friendlymeals.data.repository.StorageRepository import com.google.firebase.example.friendlymeals.data.schema.RecipeSchema +import com.google.firebase.example.friendlymeals.ui.shared.toBase64 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -61,7 +62,7 @@ class GenerateViewModel @Inject constructor( ingredientsLoading = true ) - val ingredients = aiRepository.generateIngredients(image) + val ingredients = aiRepository.generateIngredients(image.toBase64()) _viewState.value = _viewState.value.copy( ingredientsLoading = false, diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/scanMeal/ScanMealViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/scanMeal/ScanMealViewModel.kt index 8da5ee9..3174c7d 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/scanMeal/ScanMealViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/scanMeal/ScanMealViewModel.kt @@ -3,6 +3,7 @@ package com.google.firebase.example.friendlymeals.ui.scanMeal import android.graphics.Bitmap import com.google.firebase.example.friendlymeals.MainViewModel import com.google.firebase.example.friendlymeals.data.repository.AIRepository +import com.google.firebase.example.friendlymeals.ui.shared.toBase64 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -32,7 +33,7 @@ class ScanMealViewModel @Inject constructor( image = image, ) - val mealSchema = aiRepository.scanMeal(image) + val mealSchema = aiRepository.scanMeal(image.toBase64()) if (mealSchema == null) { _viewState.value = _viewState.value.copy( diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/BitmapExt.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/BitmapExt.kt new file mode 100644 index 0000000..1afa239 --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/BitmapExt.kt @@ -0,0 +1,13 @@ +package com.google.firebase.example.friendlymeals.ui.shared + +import android.graphics.Bitmap +import android.util.Base64 +import java.io.ByteArrayOutputStream + +fun Bitmap.toBase64(): String { + val byteArray = ByteArrayOutputStream().use { outputStream -> + compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + outputStream.toByteArray() + } + return Base64.encodeToString(byteArray, Base64.NO_WRAP) +} \ No newline at end of file diff --git a/app/src/main/res/xml/remote_config_defaults.xml b/app/src/main/res/xml/remote_config_defaults.xml index 7120688..cf28b0b 100644 --- a/app/src/main/res/xml/remote_config_defaults.xml +++ b/app/src/main/res/xml/remote_config_defaults.xml @@ -1,14 +1,22 @@ - - schema_model_name - gemini-2.5-flash - - - model_name - gemini-2.5-flash-image - - - imagen_name - imagen-4.0-fast-generate-001 - + + generate_ingredients + generate-ingredients-template-v1-0-0 + + + generate_recipe + generate-recipe-template-v1-0-0 + + + generate_recipe_photo_gemini + generate-recipe-photo-gemini-template-v1-0-0 + + + generate_recipe_photo_imagen + generate-recipe-photo-imagen-template-v1-0-0 + + + scan_meal + scan-meal-template-v1-0-0 + \ No newline at end of file From c65b7048e8ebb9ef2d6873dacf0c335857860064 Mon Sep 17 00:00:00 2001 From: Marina Coelho Date: Wed, 1 Apr 2026 15:11:14 +0100 Subject: [PATCH 2/6] Add Hybrid Inference (#3) * Add Hybrid inference to generate ingredients * Add Performance Monitoring custom trace to monitor on performance on device and in cloud * Add Remote Config keys * Work on comments on PR --- app/build.gradle.kts | 3 + .../data/datasource/AIRemoteDataSource.kt | 84 +++++++++++++++++-- .../data/repository/AIRepository.kt | 8 +- .../ui/generate/GenerateViewModel.kt | 10 ++- .../main/res/xml/remote_config_defaults.xml | 16 ++-- build.gradle.kts | 1 + gradle/libs.versions.toml | 10 ++- 7 files changed, 111 insertions(+), 21 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a07fc2a..2189d6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.google.ksp) alias(libs.plugins.google.hilt) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.firebase.perf) } android { @@ -63,10 +64,12 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.ai) + implementation(libs.firebase.ai.ondevice) implementation(libs.firebase.config) implementation(libs.firebase.auth) implementation(libs.firebase.storage) implementation(libs.firebase.firestore) + implementation(libs.firebase.perf) //Library to handle Markdown in Compose implementation(libs.richtext.commonmark) diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt index 1878b28..ac3e97a 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt @@ -1,34 +1,65 @@ package com.google.firebase.example.friendlymeals.data.datasource import android.graphics.Bitmap +import android.util.Log +import com.google.firebase.Firebase +import com.google.firebase.ai.FirebaseAI +import com.google.firebase.ai.InferenceMode +import com.google.firebase.ai.InferenceSource +import com.google.firebase.ai.OnDeviceConfig import com.google.firebase.ai.TemplateGenerativeModel import com.google.firebase.ai.TemplateImagenModel +import com.google.firebase.ai.ondevice.DownloadStatus +import com.google.firebase.ai.ondevice.FirebaseAIOnDevice +import com.google.firebase.ai.ondevice.OnDeviceModelStatus import com.google.firebase.ai.type.ImagePart import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.content import com.google.firebase.example.friendlymeals.data.schema.MealSchema import com.google.firebase.example.friendlymeals.data.schema.RecipeSchema +import com.google.firebase.perf.performance +import com.google.firebase.perf.trace import com.google.firebase.remoteconfig.FirebaseRemoteConfig import kotlinx.serialization.json.Json import javax.inject.Inject @OptIn(PublicPreviewAPI::class) class AIRemoteDataSource @Inject constructor( + private val aiModel: FirebaseAI, private val generativeModel: TemplateGenerativeModel, private val imagenModel: TemplateImagenModel, private val remoteConfig: FirebaseRemoteConfig ) { private val json = Json { ignoreUnknownKeys = true } + private val hybridGenerativeModel = aiModel.generativeModel( + modelName = remoteConfig.getString(HYBRID_CLOUD_MODEL_KEY), + onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_IN_CLOUD) + ) - suspend fun generateIngredients(imageData: String): String { - val response = generativeModel.generateContent( - templateId = remoteConfig.getString(GENERATE_INGREDIENTS_KEY), - inputs = mapOf( - MIME_TYPE_FIELD to MIME_TYPE_VALUE, - IMAGE_DATA_FIELD to imageData + @OptIn(PublicPreviewAPI::class) + suspend fun generateIngredients(image: Bitmap): String { + // Adding a Performance Monitoring trace is completely optional. Traces can help you + // measure how long it takes to generate ingredients on device and in cloud. + Firebase.performance.newTrace("hybrid-inference").trace { + val prompt = content { + image(image) + text(remoteConfig.getString(HYBRID_INGREDIENTS_PROMPT_KEY)) + } + + val response = hybridGenerativeModel.generateContent(prompt) + + // This is an optional function that adds an attribute to the Performance Monitoring + // trace. It helps you identify the source of the inference. + putAttribute( + "inferenceSource", + when (response.inferenceSource) { + InferenceSource.ON_DEVICE -> "On device" + else -> "In cloud" + } ) - ) - return response.text.orEmpty() + return response.text.orEmpty() + } } suspend fun generateRecipe(ingredients: String, notes: String): RecipeSchema? { @@ -80,13 +111,45 @@ class AIRemoteDataSource @Inject constructor( } } + suspend fun loadOnDeviceModel() { + when (FirebaseAIOnDevice.checkStatus()) { + OnDeviceModelStatus.UNAVAILABLE -> { + Log.i(TAG, "On-device model is unavailable") + } + OnDeviceModelStatus.DOWNLOADABLE -> { + FirebaseAIOnDevice.download().collect { status -> + when (status) { + is DownloadStatus.DownloadStarted -> + Log.i(TAG, "Starting download - ${status.bytesToDownload}") + + is DownloadStatus.DownloadInProgress -> + Log.i(TAG, "Download in progress ${status.totalBytesDownloaded} bytes downloaded") + + is DownloadStatus.DownloadCompleted -> + Log.i(TAG, "On-device model download complete") + + is DownloadStatus.DownloadFailed -> + Log.e(TAG, "Download failed $status") + } + } + } + OnDeviceModelStatus.DOWNLOADING -> { + Log.i(TAG, "On-device model is being downloaded") + } + OnDeviceModelStatus.AVAILABLE -> { + Log.i(TAG, "On-device model is available") + } + } + } + companion object { //Remote Config Keys - private const val GENERATE_INGREDIENTS_KEY = "generate_ingredients" private const val GENERATE_RECIPE_KEY = "generate_recipe" private const val GENERATE_RECIPE_PHOTO_GEMINI_KEY = "generate_recipe_photo_gemini" private const val GENERATE_RECIPE_PHOTO_IMAGEN_KEY = "generate_recipe_photo_imagen" private const val SCAN_MEAL_KEY = "scan_meal" + private const val HYBRID_CLOUD_MODEL_KEY = "hybrid_cloud_model" + private const val HYBRID_INGREDIENTS_PROMPT_KEY = "hybrid_ingredients_prompt" //Template input fields private const val IMAGE_DATA_FIELD = "imageData" @@ -97,5 +160,8 @@ class AIRemoteDataSource @Inject constructor( //Template input values private const val MIME_TYPE_VALUE = "image/jpeg" + + //Class TAG + private const val TAG = "AIRemoteDataSource" } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt index a85113e..c1a6253 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt @@ -9,8 +9,8 @@ import javax.inject.Inject class AIRepository @Inject constructor( private val aiRemoteDataSource: AIRemoteDataSource ) { - suspend fun generateIngredients(imageData: String): String { - return aiRemoteDataSource.generateIngredients(imageData) + suspend fun generateIngredients(image: Bitmap): String { + return aiRemoteDataSource.generateIngredients(image) } suspend fun generateRecipe(ingredients: String, notes: String): RecipeSchema? { @@ -28,4 +28,8 @@ class AIRepository @Inject constructor( suspend fun scanMeal(imageData: String): MealSchema? { return aiRemoteDataSource.scanMeal(imageData) } + + suspend fun loadOnDeviceModel() { + aiRemoteDataSource.loadOnDeviceModel() + } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt index e8f8777..178333b 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt @@ -8,7 +8,6 @@ import com.google.firebase.example.friendlymeals.data.repository.AuthRepository import com.google.firebase.example.friendlymeals.data.repository.DatabaseRepository import com.google.firebase.example.friendlymeals.data.repository.StorageRepository import com.google.firebase.example.friendlymeals.data.schema.RecipeSchema -import com.google.firebase.example.friendlymeals.ui.shared.toBase64 import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -28,6 +27,7 @@ class GenerateViewModel @Inject constructor( init { loadCurrentUser() + loadOnDeviceModel() } fun loadCurrentUser() { @@ -39,6 +39,12 @@ class GenerateViewModel @Inject constructor( } } + fun loadOnDeviceModel() { + launchCatching { + aiRepository.loadOnDeviceModel() + } + } + fun onIngredientsUpdated(ingredients: String) { _viewState.value = _viewState.value.copy( ingredients = ingredients @@ -62,7 +68,7 @@ class GenerateViewModel @Inject constructor( ingredientsLoading = true ) - val ingredients = aiRepository.generateIngredients(image.toBase64()) + val ingredients = aiRepository.generateIngredients(image) _viewState.value = _viewState.value.copy( ingredientsLoading = false, diff --git a/app/src/main/res/xml/remote_config_defaults.xml b/app/src/main/res/xml/remote_config_defaults.xml index cf28b0b..1175602 100644 --- a/app/src/main/res/xml/remote_config_defaults.xml +++ b/app/src/main/res/xml/remote_config_defaults.xml @@ -1,11 +1,15 @@ - generate_ingredients - generate-ingredients-template-v1-0-0 + hybrid_cloud_model + gemini-3.1-flash-lite-preview - generate_recipe - generate-recipe-template-v1-0-0 + hybrid_ingredients_prompt + Please analyze this image and list all visible food ingredients. Output ONLY a comma-separated list of ingredients. Do not include any introductory text, headers, or concluding remarks. Provide the raw list only. Be specific with measurements where possible. + + + scan_meal + scan-meal-template-v1-0-0 generate_recipe_photo_gemini @@ -16,7 +20,7 @@ generate-recipe-photo-imagen-template-v1-0-0 - scan_meal - scan-meal-template-v1-0-0 + generate_recipe + generate-recipe-template-v1-0-0 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 7db73c4..074ea6b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,4 +6,5 @@ plugins { alias(libs.plugins.google.services) apply false alias(libs.plugins.google.hilt) apply false alias(libs.plugins.google.ksp) apply false + alias(libs.plugins.firebase.perf) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b115e5a..f3d74d2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,9 @@ agp = "8.13.1" coilCompose = "2.7.0" exifinterface = "1.4.2" -firebaseBom = "34.8.0" +firebaseAi = "17.10.1" +firebaseAiOndevice = "16.0.0-beta01" +firebaseBom = "34.11.0" kotlin = "2.2.21" coreKtx = "1.17.0" junit = "4.13.2" @@ -21,16 +23,19 @@ navigationCompose = "2.9.6" constraintlayoutCompose = "1.1.1" kotlinxSerializationJson = "1.9.0" richtextCommonmark = "1.0.0-alpha03" +firebasePerf = "2.0.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } +firebase-ai = { module = "com.google.firebase:firebase-ai", version.ref = "firebaseAi" } +firebase-ai-ondevice = { module = "com.google.firebase:firebase-ai-ondevice", version.ref = "firebaseAiOndevice" } firebase-auth = { module = "com.google.firebase:firebase-auth" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } -firebase-ai = { module = "com.google.firebase:firebase-ai" } firebase-config = { module = "com.google.firebase:firebase-config" } firebase-firestore = { module = "com.google.firebase:firebase-firestore" } +firebase-perf = { module = "com.google.firebase:firebase-perf" } firebase-storage = { module = "com.google.firebase:firebase-storage" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } @@ -62,4 +67,5 @@ google-services = { id = "com.google.gms.google-services", version.ref = "google google-hilt = { id = "com.google.dagger.hilt.android", version.ref = "googleHilt" } google-ksp = { id = "com.google.devtools.ksp", version.ref ="googleKotlinKsp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerf" } From 6cb5415c371a26ff3b067c2ea19f9e5b8b6561f7 Mon Sep 17 00:00:00 2001 From: Morgan Chen Date: Wed, 13 May 2026 05:37:56 -0700 Subject: [PATCH 3/6] Add Next features to Android sample (#4) * update deps to latest firestore * initial implementation for ga features * remove likes and simplify aggregate * address code review * add default rating * Clean up database repository * Aggregating likes count --------- Co-authored-by: Marina Coelho --- .../datasource/DatabaseRemoteDataSource.kt | 173 ++++++++---------- .../friendlymeals/data/model/Recipe.kt | 5 +- .../example/friendlymeals/data/model/Tag.kt | 6 - .../data/repository/DatabaseRepository.kt | 7 +- .../ui/generate/GenerateViewModel.kt | 2 - .../ui/recipeList/RecipeListViewModel.kt | 9 +- .../ui/recipeList/filter/FilterOptions.kt | 5 +- .../ui/recipeList/filter/FilterScreen.kt | 56 ++++-- .../ui/recipeList/filter/SortByFilter.kt | 1 + app/src/main/res/values/strings.xml | 3 + gradle/libs.versions.toml | 4 +- 11 files changed, 134 insertions(+), 137 deletions(-) delete mode 100644 app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Tag.kt diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt index 0bb0a76..59d0f90 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt @@ -4,24 +4,26 @@ import android.util.Log import com.google.firebase.example.friendlymeals.data.model.Recipe import com.google.firebase.example.friendlymeals.data.model.Review import com.google.firebase.example.friendlymeals.data.model.Like -import com.google.firebase.example.friendlymeals.data.model.Tag import com.google.firebase.example.friendlymeals.data.model.User import com.google.firebase.example.friendlymeals.ui.recipeList.RecipeListItem import com.google.firebase.example.friendlymeals.ui.recipeList.filter.FilterOptions import com.google.firebase.example.friendlymeals.ui.recipeList.filter.SortByFilter -import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.PipelineResult -import com.google.firebase.firestore.SetOptions -import com.google.firebase.firestore.pipeline.AggregateFunction +import com.google.firebase.firestore.PipelineSource +import com.google.firebase.firestore.pipeline.AggregateFunction.Companion.average +import com.google.firebase.firestore.pipeline.AggregateFunction.Companion.countAll import com.google.firebase.firestore.pipeline.AggregateStage +import com.google.firebase.firestore.pipeline.Expression +import com.google.firebase.firestore.pipeline.Expression.Companion.documentId import com.google.firebase.firestore.pipeline.Expression.Companion.field +import com.google.firebase.firestore.pipeline.Expression.Companion.variable +import com.google.firebase.firestore.pipeline.SearchStage import kotlinx.coroutines.tasks.await import javax.inject.Inject import kotlin.collections.first import kotlin.collections.mapNotNull -@Suppress("UnstableApiUsage") class DatabaseRemoteDataSource @Inject constructor( private val firestore: FirebaseFirestore ) { @@ -40,6 +42,14 @@ class DatabaseRemoteDataSource @Inject constructor( return firestore .pipeline() .documents(recipePath) + .define( + documentId(field(NAME_FIELD_PATH)) + .alias(CURRENT_RECIPE_ID_VAR)) + .addFields( + PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) + .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) + .toScalarExpression().alias(AVERAGE_RATING_FIELD) + ) .execute().await().results.toRecipe() } @@ -47,52 +57,30 @@ class DatabaseRemoteDataSource @Inject constructor( return firestore .pipeline() .collection(RECIPES_COLLECTION) - .execute().await().results.toRecipeListItem() - } - - suspend fun addTags(tagNames: List) { - val normalizedTags = tagNames - .map { it.trim() } - .distinct() - - val batch = firestore.batch() - val tagsCollection = firestore.collection(TAGS_COLLECTION) - - normalizedTags.forEach { tagName -> - val tagRef = tagsCollection.document(tagName) - - val data = hashMapOf( - NAME_FIELD to tagName, - TOTAL_RECIPES_FIELD to FieldValue.increment(1) + .define( + documentId(field(NAME_FIELD_PATH)) + .alias(CURRENT_RECIPE_ID_VAR)) + .addFields( + PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) + .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) + .toScalarExpression().alias(AVERAGE_RATING_FIELD) ) - - batch.set(tagRef, data, SetOptions.merge()) - } - - batch.commit().await() + .execute().await().results.toRecipeListItem() } - suspend fun getPopularTags(): List { + suspend fun getPopularTags(): List { val results = firestore.pipeline() - .collection(TAGS_COLLECTION) - .sort(field(TOTAL_RECIPES_FIELD).descending()) + .collection(RECIPES_COLLECTION) + .unnest(field(TAGS_FIELD).alias(TAG_NAME_ALIAS)) + .aggregate( + AggregateStage.withAccumulators(countAll().alias(TAG_COUNT_ALIAS)) + .withGroups(TAG_NAME_ALIAS) + ) + .sort(field(TAG_COUNT_ALIAS).descending()) .limit(10) .execute().await().results - return results.mapNotNull { result -> - val itemData = result.getData() - val name = itemData[NAME_FIELD] as? String - - if (name.isNullOrEmpty()) { - Log.w(this::class.java.simpleName, "Empty tag name") - return@mapNotNull null - } - - Tag( - name = name, - totalRecipes = itemData[TOTAL_RECIPES_FIELD] as? Int ?: 0 - ) - } + return results.mapNotNull { it.getData()[TAG_NAME_ALIAS] as? String } } /* @@ -113,27 +101,6 @@ class DatabaseRemoteDataSource @Inject constructor( .document("${review.recipeId}_${review.userId}") reviewRef.set(review).await() - - val newAvg = getAverageRatingForRecipe(review.recipeId) - recipeRef.update(AVERAGE_RATING_FIELD, newAvg).await() - } - - private suspend fun getAverageRatingForRecipe(recipeId: String): Double { - val collectionPath = "${RECIPES_COLLECTION}/${recipeId}/${REVIEWS_SUBCOLLECTION}" - - val results = firestore - .pipeline() - .collection(collectionPath) - .aggregate( - AggregateStage.withAccumulators( - AggregateFunction - .average(RATING_FIELD) - .alias(AVG_RATING_ALIAS) - ) - ).execute().await().results - - val itemData = results.first().getData() - return (itemData[AVG_RATING_ALIAS] as? Number)?.toDouble() ?: 0.0 } suspend fun getRating(userId: String, recipeId: String): Int { @@ -148,7 +115,6 @@ class DatabaseRemoteDataSource @Inject constructor( if (results.isEmpty()) return 0 val reviewData = results.first().getData() - return (reviewData[RATING_FIELD] as? Number)?.toInt() ?: 0 } @@ -163,12 +129,6 @@ class DatabaseRemoteDataSource @Inject constructor( .document("${like.recipeId}_${like.userId}") likeRef.set(like).await() - - firestore - .collection(RECIPES_COLLECTION) - .document(like.recipeId) - .update(LIKES_FIELD, FieldValue.increment(1)) - .await() } suspend fun removeFavorite(like: Like) { @@ -177,12 +137,6 @@ class DatabaseRemoteDataSource @Inject constructor( .document("${like.recipeId}_${like.userId}") .delete() .await() - - firestore - .collection(RECIPES_COLLECTION) - .document(like.recipeId) - .update(LIKES_FIELD, FieldValue.increment(-1)) - .await() } suspend fun getFavorite(userId: String, recipeId: String): Boolean { @@ -195,13 +149,19 @@ class DatabaseRemoteDataSource @Inject constructor( .execute().await().results.isNotEmpty() } + @Suppress("UnstableApiUsage") suspend fun getFilteredRecipes( filterOptions: FilterOptions, userId: String ): List { var pipeline = firestore.pipeline().collection(RECIPES_COLLECTION) - if (filterOptions.recipeTitle.isNotBlank()) { + if (filterOptions.searchQuery.isNotBlank()) { + val searchStage = SearchStage.withQuery(filterOptions.searchQuery) + .withAddFields(Expression.score().alias(SCORE_ALIAS)) + + pipeline = pipeline.search(searchStage).sort(field(SCORE_ALIAS).descending()) + } else if (filterOptions.recipeTitle.isNotBlank()) { pipeline = pipeline .where( field(TITLE_FIELD).toLower() @@ -210,38 +170,44 @@ class DatabaseRemoteDataSource @Inject constructor( } if (filterOptions.filterByMine) { - pipeline = pipeline - .where(field(AUTHOR_ID_FIELD) - .equal(userId)) + pipeline = pipeline.where(field(AUTHOR_ID_FIELD).equal(userId)) } + pipeline = pipeline + .define( + documentId(field(NAME_FIELD_PATH)).alias(CURRENT_RECIPE_ID_VAR) + ).addFields( + PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) + .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) + .toScalarExpression().alias(AVERAGE_RATING_FIELD), + firestore.pipeline() + .collectionGroup(LIKES_COLLECTION) + .where(field(RECIPE_ID_FIELD) + .equal(variable(CURRENT_RECIPE_ID_VAR))) + .aggregate(countAll().alias(LIKES_COUNT_ALIAS)) + .toScalarExpression().alias(LIKES_FIELD) + ) + if (filterOptions.rating > 0) { pipeline = pipeline - .where(field(AVERAGE_RATING_FIELD) - .greaterThanOrEqual(filterOptions.rating)) + .where(field(AVERAGE_RATING_FIELD).greaterThanOrEqual(filterOptions.rating)) } if (filterOptions.selectedTags.isNotEmpty()) { pipeline = pipeline - .where(field(TAGS_FIELD) - .arrayContainsAny(filterOptions.selectedTags)) + .where(field(TAGS_FIELD).arrayContainsAny(filterOptions.selectedTags)) } when (filterOptions.sortBy) { + SortByFilter.DEFAULT -> {} SortByFilter.RATING -> { - pipeline = pipeline - .sort(field(AVERAGE_RATING_FIELD) - .descending()) + pipeline = pipeline.sort(field(AVERAGE_RATING_FIELD).descending()) } SortByFilter.ALPHABETICAL -> { - pipeline = pipeline - .sort(field(TITLE_FIELD) - .ascending()) + pipeline = pipeline.sort(field(TITLE_FIELD).ascending()) } SortByFilter.POPULARITY -> { - pipeline = pipeline - .sort(field(LIKES_FIELD) - .descending()) + pipeline = pipeline.sort(field(LIKES_FIELD).descending()) } } @@ -258,7 +224,6 @@ class DatabaseRemoteDataSource @Inject constructor( authorId = itemData[AUTHOR_ID_FIELD] as? String ?: "", tags = (itemData[TAGS_FIELD] as? List<*>)?.filterIsInstance() ?: listOf(), averageRating = (itemData[AVERAGE_RATING_FIELD] as? Number)?.toDouble() ?: 0.0, - likes = (itemData[LIKES_FIELD] as? Number)?.toInt() ?: 0, prepTime = itemData[PREP_TIME_FIELD] as? String ?: "", cookTime = itemData[COOK_TIME_FIELD] as? String ?: "", servings = itemData[SERVINGS_FIELD] as? String ?: "", @@ -280,7 +245,7 @@ class DatabaseRemoteDataSource @Inject constructor( RecipeListItem( id = id, title = itemData[TITLE_FIELD] as? String ?: "", - averageRating = itemData[AVERAGE_RATING_FIELD] as? Double ?: 0.0, + averageRating = (itemData[AVERAGE_RATING_FIELD] as? Number)?.toDouble() ?: 0.0, imageUri = itemData[IMAGE_URI_FIELD] as? String ) } @@ -290,14 +255,11 @@ class DatabaseRemoteDataSource @Inject constructor( //Collections private const val USERS_COLLECTION = "users" private const val RECIPES_COLLECTION = "recipes" - private const val TAGS_COLLECTION = "tags" private const val LIKES_COLLECTION = "likes" private const val REVIEWS_SUBCOLLECTION = "reviews" //Fields private const val RATING_FIELD = "rating" - private const val NAME_FIELD = "name" - private const val TOTAL_RECIPES_FIELD = "totalRecipes" private const val AVERAGE_RATING_FIELD = "averageRating" private const val AUTHOR_ID_FIELD = "authorId" private const val TITLE_FIELD = "title" @@ -309,8 +271,19 @@ class DatabaseRemoteDataSource @Inject constructor( private const val SERVINGS_FIELD = "servings" private const val INSTRUCTIONS_FIELD = "instructions" private const val INGREDIENTS_FIELD = "ingredients" + private const val RECIPE_ID_FIELD = "recipeId" //Field aliases private const val AVG_RATING_ALIAS = "avg_rating" + private const val TAG_NAME_ALIAS = "tagName" + private const val TAG_COUNT_ALIAS = "tagCount" + private const val LIKES_COUNT_ALIAS = "likesCount" + private const val SCORE_ALIAS = "score" + + //Variables + private const val CURRENT_RECIPE_ID_VAR = "current_recipe_id" + + //Field paths + private const val NAME_FIELD_PATH = "__name__" } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt index 67e4499..2e222ce 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Recipe.kt @@ -1,13 +1,14 @@ package com.google.firebase.example.friendlymeals.data.model +import com.google.firebase.firestore.Exclude + data class Recipe( val title: String = "", val instructions: String = "", val ingredients: List = listOf(), val authorId: String = "", val tags: List = listOf(), - val averageRating: Double = 0.0, - val likes: Int = 0, + @get:Exclude val averageRating: Double = 0.0, val prepTime: String = "", val cookTime: String = "", val servings: String = "", diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Tag.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Tag.kt deleted file mode 100644 index 483b8b5..0000000 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/Tag.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.google.firebase.example.friendlymeals.data.model - -data class Tag( - val name: String = "", - val totalRecipes: Int = 0 -) \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt index 446fa83..d064328 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt @@ -4,7 +4,6 @@ import com.google.firebase.example.friendlymeals.data.datasource.DatabaseRemoteD import com.google.firebase.example.friendlymeals.data.model.Recipe import com.google.firebase.example.friendlymeals.data.model.Review import com.google.firebase.example.friendlymeals.data.model.Like -import com.google.firebase.example.friendlymeals.data.model.Tag import com.google.firebase.example.friendlymeals.data.model.User import com.google.firebase.example.friendlymeals.ui.recipeList.RecipeListItem import com.google.firebase.example.friendlymeals.ui.recipeList.filter.FilterOptions @@ -29,11 +28,7 @@ class DatabaseRepository @Inject constructor( return databaseRemoteDataSource.getAllRecipes() } - suspend fun addTags(tagNames: List) { - return databaseRemoteDataSource.addTags(tagNames) - } - - suspend fun getPopularTags(): List { + suspend fun getPopularTags(): List { return databaseRemoteDataSource.getPopularTags() } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt index 178333b..796429f 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt @@ -107,8 +107,6 @@ class GenerateViewModel @Inject constructor( recipeImageUri = storageRepository.addImage(recipeImage) } - databaseRepository.addTags(generatedRecipe.tags) - val storedRecipeId = databaseRepository.addRecipe( recipe = generatedRecipe.toRecipe( authorId = authRepository.currentUser?.uid.orEmpty(), diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/RecipeListViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/RecipeListViewModel.kt index c37ad19..58e59ee 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/RecipeListViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/RecipeListViewModel.kt @@ -1,7 +1,6 @@ package com.google.firebase.example.friendlymeals.ui.recipeList import com.google.firebase.example.friendlymeals.MainViewModel -import com.google.firebase.example.friendlymeals.data.model.Tag import com.google.firebase.example.friendlymeals.data.repository.AuthRepository import com.google.firebase.example.friendlymeals.data.repository.DatabaseRepository import com.google.firebase.example.friendlymeals.ui.recipeList.filter.FilterOptions @@ -21,8 +20,8 @@ class RecipeListViewModel @Inject constructor( val filterOptions: StateFlow get() = _filterOptions.asStateFlow() - private val _tags = MutableStateFlow(listOf()) - val tags: StateFlow> + private val _tags = MutableStateFlow(listOf()) + val tags: StateFlow> get() = _tags.asStateFlow() private val _recipes = MutableStateFlow>(listOf()) @@ -40,6 +39,10 @@ class RecipeListViewModel @Inject constructor( _filterOptions.value = _filterOptions.value.copy(recipeTitle = recipeName) } + fun updateSearchQuery(query: String) { + _filterOptions.value = _filterOptions.value.copy(searchQuery = query) + } + fun updateFilterByMine() { val currentValue = _filterOptions.value.filterByMine _filterOptions.value = _filterOptions.value.copy(filterByMine = !currentValue) diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterOptions.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterOptions.kt index c58c3d8..0de0f42 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterOptions.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterOptions.kt @@ -1,11 +1,12 @@ package com.google.firebase.example.friendlymeals.ui.recipeList.filter -import com.google.firebase.example.friendlymeals.ui.recipeList.filter.SortByFilter.RATING +import com.google.firebase.example.friendlymeals.ui.recipeList.filter.SortByFilter.DEFAULT data class FilterOptions( val recipeTitle: String = "", + val searchQuery: String = "", val filterByMine: Boolean = false, val rating: Int = 0, val selectedTags: List = listOf(), - val sortBy: SortByFilter = RATING + val sortBy: SortByFilter = DEFAULT ) \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterScreen.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterScreen.kt index 5985514..4a1ff0e 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterScreen.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.firebase.example.friendlymeals.R -import com.google.firebase.example.friendlymeals.data.model.Tag import com.google.firebase.example.friendlymeals.ui.recipeList.RecipeListViewModel import com.google.firebase.example.friendlymeals.ui.shared.RatingButton import com.google.firebase.example.friendlymeals.ui.theme.BorderColor @@ -73,6 +72,7 @@ fun FilterScreen( FilterScreenContent( navigateBack = navigateBack, updateRecipeTitle = viewModel::updateRecipeTitle, + updateSearchQuery = viewModel::updateSearchQuery, updateFilterByMine = viewModel::updateFilterByMine, updateRating = viewModel::updateRating, removeTag = viewModel::removeTag, @@ -96,6 +96,7 @@ fun FilterScreen( fun FilterScreenContent( navigateBack: () -> Unit = {}, updateRecipeTitle: (String) -> Unit = {}, + updateSearchQuery: (String) -> Unit = {}, updateFilterByMine: () -> Unit = {}, updateRating: (Int) -> Unit = {}, removeTag: (String) -> Unit = {}, @@ -104,7 +105,7 @@ fun FilterScreenContent( resetFilters: () -> Unit = {}, applyFilters: () -> Unit = {}, filterOptions: FilterOptions, - tags: List + tags: List ) { Scaffold( topBar = { @@ -136,6 +137,26 @@ fun FilterScreenContent( ) { Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.filter_search_label), + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 8.dp) + ) + + OutlinedTextField( + value = filterOptions.searchQuery, + onValueChange = { updateSearchQuery(it) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(id = R.string.filter_search_hint), color = Color.Gray) }, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = BorderColor, + unfocusedBorderColor = BorderColor + ) + ) + + Spacer(modifier = Modifier.height(24.dp)) + Text( text = stringResource(id = R.string.filter_recipe_title_label), fontWeight = FontWeight.Medium, @@ -213,17 +234,17 @@ fun FilterScreenContent( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - tags.forEach { tag -> - val isSelected = filterOptions.selectedTags.contains(tag.name) + tags.forEach { tagName -> + val isSelected = filterOptions.selectedTags.contains(tagName) FilterChip( - text = tag.name, + text = tagName, isSelected = isSelected, onClick = { if (isSelected) { - removeTag(tag.name) + removeTag(tagName) } else { - addTag(tag.name) + addTag(tagName) } } ) @@ -240,32 +261,39 @@ fun FilterScreenContent( ) Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = filterOptions.sortBy == SortByFilter.DEFAULT, + onClick = { updateSortBy(SortByFilter.DEFAULT) }, + colors = RadioButtonDefaults.colors(selectedColor = Teal) + ) + Text(stringResource(id = SortByFilter.DEFAULT.title)) + + Spacer(modifier = Modifier.width(24.dp)) + RadioButton( selected = filterOptions.sortBy == SortByFilter.RATING, onClick = { updateSortBy(SortByFilter.RATING) }, colors = RadioButtonDefaults.colors(selectedColor = Teal) ) Text(stringResource(id = SortByFilter.RATING.title)) - - Spacer(modifier = Modifier.width(24.dp)) - + } + + Row(verticalAlignment = Alignment.CenterVertically) { RadioButton( selected = filterOptions.sortBy == SortByFilter.ALPHABETICAL, onClick = { updateSortBy(SortByFilter.ALPHABETICAL) }, colors = RadioButtonDefaults.colors(selectedColor = Teal) ) Text(stringResource(id = SortByFilter.ALPHABETICAL.title)) - } - Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.width(24.dp)) + RadioButton( selected = filterOptions.sortBy == SortByFilter.POPULARITY, onClick = { updateSortBy(SortByFilter.POPULARITY) }, colors = RadioButtonDefaults.colors(selectedColor = Teal) ) Text(stringResource(id = SortByFilter.POPULARITY.title)) - - Spacer(modifier = Modifier.width(24.dp)) } Spacer(modifier = Modifier.weight(1f)) diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/SortByFilter.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/SortByFilter.kt index 89476d5..1cdcdf0 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/SortByFilter.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/SortByFilter.kt @@ -3,6 +3,7 @@ package com.google.firebase.example.friendlymeals.ui.recipeList.filter import com.google.firebase.example.friendlymeals.R enum class SortByFilter(val title: Int) { + DEFAULT(R.string.filter_sort_by_default), RATING(R.string.filter_sort_by_rating), ALPHABETICAL(R.string.filter_sort_by_alphabetical), POPULARITY(R.string.filter_sort_by_popularity) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2847816..1cfb76c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,11 +24,14 @@ Filter Recipes Recipe Title e.g. Arrabbiata sauce + Search + Search recipes… My recipes only Rating %d Stars Tags Sort By + Default Rating Alphabetical Popularity diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3d74d2..be04753 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ coilCompose = "2.7.0" exifinterface = "1.4.2" firebaseAi = "17.10.1" firebaseAiOndevice = "16.0.0-beta01" -firebaseBom = "34.11.0" +firebaseBom = "34.12.0" kotlin = "2.2.21" coreKtx = "1.17.0" junit = "4.13.2" @@ -15,7 +15,7 @@ activityCompose = "1.12.0" composeBom = "2025.11.01" googleServices = "4.4.4" googleHilt = "2.57.2" -googleKotlinKsp = "2.2.10-2.0.2" +googleKotlinKsp = "2.2.21-2.0.5" hiltAndroidCompiler = "2.57.2" coreSplashscreen = "1.2.0" hiltNavigationCompose = "1.3.0" From 7b4bb66ae284520f4e3142c4af2c5f11b2901402 Mon Sep 17 00:00:00 2001 From: Marina Coelho Date: Tue, 19 May 2026 08:00:08 +0100 Subject: [PATCH 4/6] Add cooking assistance with Gemini Live and grocery list feature (#5) * Update libraries and AGP * Removed deprecated Imagen models, updated Download Status for on device models * Add Live Assistant Screen and functionality * Clean up code and fix architecture * Add grocery list tab and functionality, and tool calling to Gemini Live * Migrating prompt to Firebase Remote Config * Remove unused libraries, use Dispatchers IO when compressin bitmap * Improve database function, upgrade target sdk --- app/build.gradle.kts | 12 +- app/src/main/AndroidManifest.xml | 1 + .../example/friendlymeals/MainActivity.kt | 23 +- .../example/friendlymeals/MainViewModel.kt | 9 + .../data/datasource/AIRemoteDataSource.kt | 56 ++-- .../datasource/DatabaseRemoteDataSource.kt | 58 ++++ .../data/datasource/LiveAIRemoteDataSource.kt | 82 ++++++ .../data/injection/FirebaseHiltModule.kt | 13 - .../friendlymeals/data/model/GroceryItem.kt | 8 + .../data/repository/AIRepository.kt | 4 - .../data/repository/DatabaseRepository.kt | 22 ++ .../data/repository/LiveAIRepository.kt | 16 + .../ui/groceryList/GroceryListScreen.kt | 276 ++++++++++++++++++ .../ui/groceryList/GroceryListViewModel.kt | 64 ++++ .../ui/live/LiveAssistantScreen.kt | 214 ++++++++++++++ .../ui/live/LiveAssistantUiState.kt | 9 + .../ui/live/LiveAssistantViewModel.kt | 155 ++++++++++ .../friendlymeals/ui/recipe/RecipeScreen.kt | 113 ++++++- .../ui/recipe/RecipeViewModel.kt | 10 + .../ui/recipe/RecipeViewState.kt | 1 + .../ui/recipeList/filter/FilterScreen.kt | 2 +- .../friendlymeals/ui/shared/BottomNavBar.kt | 5 +- app/src/main/res/drawable/ic_add.xml | 9 + app/src/main/res/drawable/ic_delete.xml | 9 + app/src/main/res/values/strings.xml | 13 +- .../main/res/xml/remote_config_defaults.xml | 26 +- build.gradle.kts | 1 - gradle.properties | 12 +- gradle/libs.versions.toml | 38 +-- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 3 + 31 files changed, 1178 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/LiveAIRemoteDataSource.kt create mode 100644 app/src/main/java/com/google/firebase/example/friendlymeals/data/model/GroceryItem.kt create mode 100644 app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/LiveAIRepository.kt create mode 100644 app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListScreen.kt create mode 100644 app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListViewModel.kt create mode 100644 app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantScreen.kt create mode 100644 app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantUiState.kt create mode 100644 app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantViewModel.kt create mode 100644 app/src/main/res/drawable/ic_add.xml create mode 100644 app/src/main/res/drawable/ic_delete.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2189d6f..04a0a2a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.google.services) alias(libs.plugins.google.ksp) @@ -11,12 +10,12 @@ plugins { android { namespace = "com.google.firebase.example.friendlymeals" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "com.google.firebase.example.friendlymeals" minSdk = 26 - targetSdk = 36 + targetSdk = 37 versionCode = 1 versionName = "1.0" @@ -36,9 +35,6 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } buildFeatures { compose = true } @@ -58,6 +54,10 @@ dependencies { implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.exifinterface) + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) implementation(libs.kotlinx.serialization.json) implementation(libs.hilt.android) ksp(libs.hilt.android.compiler) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0e8567a..4ab4840 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + { RecipeScreen( - navigateBack = { navController.popBackStack() } + navigateBack = { navController.popBackStack() }, + navigateToLiveAssistant = { recipeId -> + navController.navigate(LiveAssistantRoute(recipeId)) { + launchSingleTop = true + } + } ) } + composable { + LiveAssistantScreen( + navigateBack = { navController.popBackStack() }, + showError = { + val message = this@MainActivity.getString(R.string.camera_error_message) + scope.launch { snackbarHostState.showSnackbar(message) } + } + ) + } + composable { + GroceryListScreen() + } } } } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/MainViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/MainViewModel.kt index c528afd..98cf927 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/MainViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/MainViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch open class MainViewModel : ViewModel() { @@ -15,4 +16,12 @@ open class MainViewModel : ViewModel() { }, block = block ) + + fun launchCatchingIO(block: suspend CoroutineScope.() -> Unit) = + viewModelScope.launch( + Dispatchers.IO + CoroutineExceptionHandler { _, throwable -> + Log.e("MainViewModel", throwable.message ?: "Unknown error") + }, + block = block + ) } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt index ac3e97a..45e019c 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/AIRemoteDataSource.kt @@ -3,15 +3,18 @@ package com.google.firebase.example.friendlymeals.data.datasource import android.graphics.Bitmap import android.util.Log import com.google.firebase.Firebase +import com.google.firebase.ai.DownloadStatus.DownloadCompleted +import com.google.firebase.ai.DownloadStatus.DownloadFailed +import com.google.firebase.ai.DownloadStatus.DownloadInProgress +import com.google.firebase.ai.DownloadStatus.DownloadStarted import com.google.firebase.ai.FirebaseAI import com.google.firebase.ai.InferenceMode import com.google.firebase.ai.InferenceSource import com.google.firebase.ai.OnDeviceConfig -import com.google.firebase.ai.TemplateGenerativeModel -import com.google.firebase.ai.TemplateImagenModel -import com.google.firebase.ai.ondevice.DownloadStatus -import com.google.firebase.ai.ondevice.FirebaseAIOnDevice -import com.google.firebase.ai.ondevice.OnDeviceModelStatus +import com.google.firebase.ai.OnDeviceModelStatus.Companion.AVAILABLE +import com.google.firebase.ai.OnDeviceModelStatus.Companion.DOWNLOADABLE +import com.google.firebase.ai.OnDeviceModelStatus.Companion.DOWNLOADING +import com.google.firebase.ai.OnDeviceModelStatus.Companion.UNAVAILABLE import com.google.firebase.ai.type.ImagePart import com.google.firebase.ai.type.PublicPreviewAPI import com.google.firebase.ai.type.content @@ -25,9 +28,7 @@ import javax.inject.Inject @OptIn(PublicPreviewAPI::class) class AIRemoteDataSource @Inject constructor( - private val aiModel: FirebaseAI, - private val generativeModel: TemplateGenerativeModel, - private val imagenModel: TemplateImagenModel, + aiModel: FirebaseAI, private val remoteConfig: FirebaseRemoteConfig ) { private val json = Json { ignoreUnknownKeys = true } @@ -36,7 +37,8 @@ class AIRemoteDataSource @Inject constructor( onDeviceConfig = OnDeviceConfig(mode = InferenceMode.PREFER_IN_CLOUD) ) - @OptIn(PublicPreviewAPI::class) + private val templateGenerativeModel = aiModel.templateGenerativeModel() + suspend fun generateIngredients(image: Bitmap): String { // Adding a Performance Monitoring trace is completely optional. Traces can help you // measure how long it takes to generate ingredients on device and in cloud. @@ -63,7 +65,7 @@ class AIRemoteDataSource @Inject constructor( } suspend fun generateRecipe(ingredients: String, notes: String): RecipeSchema? { - val response = generativeModel.generateContent( + val response = templateGenerativeModel.generateContent( templateId = remoteConfig.getString(GENERATE_RECIPE_KEY), inputs = buildMap { put(INGREDIENTS_FIELD, ingredients) @@ -79,7 +81,7 @@ class AIRemoteDataSource @Inject constructor( } suspend fun generateRecipePhoto(recipeTitle: String): Bitmap? { - val response = generativeModel.generateContent( + val response = templateGenerativeModel.generateContent( templateId = remoteConfig.getString(GENERATE_RECIPE_PHOTO_GEMINI_KEY), inputs = mapOf(RECIPE_TITLE_FIELD to recipeTitle) ) @@ -88,17 +90,8 @@ class AIRemoteDataSource @Inject constructor( ?.filterIsInstance()?.firstOrNull()?.image } - suspend fun generateRecipePhotoImagen(recipeTitle: String): Bitmap? { - val response = imagenModel.generateImages( - templateId = remoteConfig.getString(GENERATE_RECIPE_PHOTO_IMAGEN_KEY), - inputs = mapOf(RECIPE_TITLE_FIELD to recipeTitle) - ) - - return response.images.firstOrNull()?.asBitmap() - } - suspend fun scanMeal(imageData: String): MealSchema? { - val response = generativeModel.generateContent( + val response = templateGenerativeModel.generateContent( templateId = remoteConfig.getString(SCAN_MEAL_KEY), inputs = mapOf( MIME_TYPE_FIELD to MIME_TYPE_VALUE, @@ -112,31 +105,31 @@ class AIRemoteDataSource @Inject constructor( } suspend fun loadOnDeviceModel() { - when (FirebaseAIOnDevice.checkStatus()) { - OnDeviceModelStatus.UNAVAILABLE -> { + when (hybridGenerativeModel.onDeviceExtension?.checkStatus()) { + UNAVAILABLE -> { Log.i(TAG, "On-device model is unavailable") } - OnDeviceModelStatus.DOWNLOADABLE -> { - FirebaseAIOnDevice.download().collect { status -> + DOWNLOADABLE -> { + hybridGenerativeModel.onDeviceExtension?.download()?.collect { status -> when (status) { - is DownloadStatus.DownloadStarted -> + is DownloadStarted -> Log.i(TAG, "Starting download - ${status.bytesToDownload}") - is DownloadStatus.DownloadInProgress -> + is DownloadInProgress -> Log.i(TAG, "Download in progress ${status.totalBytesDownloaded} bytes downloaded") - is DownloadStatus.DownloadCompleted -> + is DownloadCompleted -> Log.i(TAG, "On-device model download complete") - is DownloadStatus.DownloadFailed -> + is DownloadFailed -> Log.e(TAG, "Download failed $status") } } } - OnDeviceModelStatus.DOWNLOADING -> { + DOWNLOADING -> { Log.i(TAG, "On-device model is being downloaded") } - OnDeviceModelStatus.AVAILABLE -> { + AVAILABLE -> { Log.i(TAG, "On-device model is available") } } @@ -146,7 +139,6 @@ class AIRemoteDataSource @Inject constructor( //Remote Config Keys private const val GENERATE_RECIPE_KEY = "generate_recipe" private const val GENERATE_RECIPE_PHOTO_GEMINI_KEY = "generate_recipe_photo_gemini" - private const val GENERATE_RECIPE_PHOTO_IMAGEN_KEY = "generate_recipe_photo_imagen" private const val SCAN_MEAL_KEY = "scan_meal" private const val HYBRID_CLOUD_MODEL_KEY = "hybrid_cloud_model" private const val HYBRID_INGREDIENTS_PROMPT_KEY = "hybrid_ingredients_prompt" diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt index 59d0f90..dde709b 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt @@ -1,6 +1,7 @@ package com.google.firebase.example.friendlymeals.data.datasource import android.util.Log +import com.google.firebase.example.friendlymeals.data.model.GroceryItem import com.google.firebase.example.friendlymeals.data.model.Recipe import com.google.firebase.example.friendlymeals.data.model.Review import com.google.firebase.example.friendlymeals.data.model.Like @@ -19,6 +20,10 @@ import com.google.firebase.firestore.pipeline.Expression.Companion.documentId import com.google.firebase.firestore.pipeline.Expression.Companion.field import com.google.firebase.firestore.pipeline.Expression.Companion.variable import com.google.firebase.firestore.pipeline.SearchStage +import com.google.firebase.firestore.snapshots +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.tasks.await import javax.inject.Inject import kotlin.collections.first @@ -251,12 +256,63 @@ class DatabaseRemoteDataSource @Inject constructor( } } + fun getGroceriesFlow(userId: String): Flow> { + if (userId.isEmpty()) { + return flowOf(emptyList()) + } + + return firestore.collection(GROCERIES_COLLECTION) + .whereEqualTo(USER_ID_FIELD, userId) + .snapshots() + .mapNotNull { snapshot -> + snapshot.documents.mapNotNull { doc -> + doc.toObject(GroceryItem::class.java)?.copy(id = doc.id) + } + } + } + + suspend fun addGroceryItem(item: GroceryItem) { + val docRef = firestore.collection(GROCERIES_COLLECTION).document() + val itemWithId = item.copy(id = docRef.id) + docRef.set(itemWithId).await() + } + + suspend fun updateGroceryItemChecked(itemId: String, checked: Boolean) { + firestore.collection(GROCERIES_COLLECTION).document(itemId) + .update(CHECKED_FIELD, checked).await() + } + + suspend fun deleteGroceryItem(itemId: String) { + firestore.collection(GROCERIES_COLLECTION).document(itemId) + .delete().await() + } + + suspend fun addIngredientsToGroceries(userId: String, ingredients: List) { + if (userId.isEmpty() || ingredients.isEmpty()) return + + val batch = firestore.batch() + val collection = firestore.collection(GROCERIES_COLLECTION) + + for (ingredient in ingredients) { + val docRef = collection.document() + val item = GroceryItem( + id = docRef.id, + userId = userId, + name = ingredient, + checked = false + ) + batch.set(docRef, item) + } + batch.commit().await() + } + companion object { //Collections private const val USERS_COLLECTION = "users" private const val RECIPES_COLLECTION = "recipes" private const val LIKES_COLLECTION = "likes" private const val REVIEWS_SUBCOLLECTION = "reviews" + private const val GROCERIES_COLLECTION = "groceries" //Fields private const val RATING_FIELD = "rating" @@ -272,6 +328,8 @@ class DatabaseRemoteDataSource @Inject constructor( private const val INSTRUCTIONS_FIELD = "instructions" private const val INGREDIENTS_FIELD = "ingredients" private const val RECIPE_ID_FIELD = "recipeId" + private const val USER_ID_FIELD = "userId" + private const val CHECKED_FIELD = "checked" //Field aliases private const val AVG_RATING_ALIAS = "avg_rating" diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/LiveAIRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/LiveAIRemoteDataSource.kt new file mode 100644 index 0000000..417d0d1 --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/LiveAIRemoteDataSource.kt @@ -0,0 +1,82 @@ +package com.google.firebase.example.friendlymeals.data.datasource + +import com.google.firebase.ai.FirebaseAI +import com.google.firebase.ai.type.LiveSession +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.ai.type.ResponseModality +import com.google.firebase.ai.type.SpeechConfig +import com.google.firebase.ai.type.Voice +import com.google.firebase.ai.type.content +import com.google.firebase.ai.type.liveGenerationConfig +import com.google.firebase.ai.type.Tool +import com.google.firebase.ai.type.FunctionDeclaration +import com.google.firebase.ai.type.Schema +import com.google.firebase.example.friendlymeals.data.model.Recipe +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import javax.inject.Inject + +@OptIn(PublicPreviewAPI::class) +class LiveAIRemoteDataSource @Inject constructor( + private val aiModel: FirebaseAI, + private val remoteConfig: FirebaseRemoteConfig +) { + private val groceryListTool = Tool.functionDeclarations(listOf( + FunctionDeclaration( + name = ADD_INGREDIENTS_TOOL_NAME, + description = ADD_INGREDIENTS_TOOL_DESCRIPTION, + parameters = mapOf( + INGREDIENT_FIELD_NAME to Schema.string(INGREDIENT_FIELD_DESCRIPTION) + ) + ) + )) + + @OptIn(PublicPreviewAPI::class) + suspend fun setupLiveSession(recipe: Recipe): LiveSession? { + val liveGenerationConfig = liveGenerationConfig { + speechConfig = SpeechConfig(voice = Voice(LIVE_MODEL_VOICE)) + responseModality = ResponseModality.AUDIO + } + + val promptTemplate = remoteConfig.getString(LIVE_MODEL_PROMPT_KEY) + val instructionText = formatInstructionPrompt(promptTemplate, recipe) + + val liveModel = aiModel.liveModel( + modelName = remoteConfig.getString(LIVE_MODEL_NAME_KEY), + generationConfig = liveGenerationConfig, + systemInstruction = content { text(instructionText) }, + tools = listOf(groceryListTool) + ) + + return try { + liveModel.connect() + } catch (_: Exception) { + null + } + } + + private fun formatInstructionPrompt(template: String, recipe: Recipe): String { + return template + .replace("{{title}}", recipe.title) + .replace("{{prepTime}}", recipe.prepTime) + .replace("{{cookTime}}", recipe.cookTime) + .replace("{{servings}}", recipe.servings) + .replace("{{ingredients}}", recipe.ingredients.joinToString("\n")) + .replace("{{instructions}}", recipe.instructions) + } + + companion object { + //Live Model Config + private const val LIVE_MODEL_VOICE = "CHARON" + + //Tools config + private const val ADD_INGREDIENTS_TOOL_NAME = "addIngredientToGroceryList" + private const val ADD_INGREDIENTS_TOOL_DESCRIPTION = "Adds a specified ingredient to the " + + "user's grocery list in the database." + private const val INGREDIENT_FIELD_NAME = "ingredient" + private const val INGREDIENT_FIELD_DESCRIPTION = "The name of the ingredient to add." + + //Remote Config Keys + private const val LIVE_MODEL_NAME_KEY = "live_model_name" + private const val LIVE_MODEL_PROMPT_KEY = "live_model_prompt" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/injection/FirebaseHiltModule.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/injection/FirebaseHiltModule.kt index a934497..595a0d5 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/injection/FirebaseHiltModule.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/injection/FirebaseHiltModule.kt @@ -3,11 +3,8 @@ package com.google.firebase.example.friendlymeals.data.injection import android.util.Log import com.google.firebase.Firebase import com.google.firebase.ai.FirebaseAI -import com.google.firebase.ai.TemplateGenerativeModel -import com.google.firebase.ai.TemplateImagenModel import com.google.firebase.ai.ai import com.google.firebase.ai.type.GenerativeBackend -import com.google.firebase.ai.type.PublicPreviewAPI import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.auth import com.google.firebase.example.friendlymeals.R @@ -37,16 +34,6 @@ object FirebaseHiltModule { return Firebase.ai(backend = GenerativeBackend.googleAI()) } - @OptIn(PublicPreviewAPI::class) - @Provides fun generativeModel(ai: FirebaseAI): TemplateGenerativeModel { - return ai.templateGenerativeModel() - } - - @OptIn(PublicPreviewAPI::class) - @Provides fun imagenModel(ai: FirebaseAI): TemplateImagenModel { - return ai.templateImagenModel() - } - @Provides fun storage(): StorageReference { return Firebase.storage.reference } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/GroceryItem.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/GroceryItem.kt new file mode 100644 index 0000000..b33e935 --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/model/GroceryItem.kt @@ -0,0 +1,8 @@ +package com.google.firebase.example.friendlymeals.data.model + +data class GroceryItem( + val id: String = "", + val userId: String = "", + val name: String = "", + val checked: Boolean = false +) diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt index c1a6253..5a1a37e 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt @@ -21,10 +21,6 @@ class AIRepository @Inject constructor( return aiRemoteDataSource.generateRecipePhoto(recipeTitle) } - suspend fun generateRecipePhotoImagen(recipeTitle: String): Bitmap? { - return aiRemoteDataSource.generateRecipePhotoImagen(recipeTitle) - } - suspend fun scanMeal(imageData: String): MealSchema? { return aiRemoteDataSource.scanMeal(imageData) } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt index d064328..951ef93 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/DatabaseRepository.kt @@ -7,6 +7,8 @@ import com.google.firebase.example.friendlymeals.data.model.Like import com.google.firebase.example.friendlymeals.data.model.User import com.google.firebase.example.friendlymeals.ui.recipeList.RecipeListItem import com.google.firebase.example.friendlymeals.ui.recipeList.filter.FilterOptions +import com.google.firebase.example.friendlymeals.data.model.GroceryItem +import kotlinx.coroutines.flow.Flow import javax.inject.Inject class DatabaseRepository @Inject constructor( @@ -58,4 +60,24 @@ class DatabaseRepository @Inject constructor( ): List { return databaseRemoteDataSource.getFilteredRecipes(filterOptions, userId) } + + fun getGroceriesFlow(userId: String): Flow> { + return databaseRemoteDataSource.getGroceriesFlow(userId) + } + + suspend fun addGroceryItem(item: GroceryItem) { + databaseRemoteDataSource.addGroceryItem(item) + } + + suspend fun updateGroceryItemChecked(itemId: String, checked: Boolean) { + databaseRemoteDataSource.updateGroceryItemChecked(itemId, checked) + } + + suspend fun deleteGroceryItem(itemId: String) { + databaseRemoteDataSource.deleteGroceryItem(itemId) + } + + suspend fun addIngredientsToGroceries(userId: String, ingredients: List) { + databaseRemoteDataSource.addIngredientsToGroceries(userId, ingredients) + } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/LiveAIRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/LiveAIRepository.kt new file mode 100644 index 0000000..066cd82 --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/LiveAIRepository.kt @@ -0,0 +1,16 @@ +package com.google.firebase.example.friendlymeals.data.repository + +import com.google.firebase.ai.type.LiveSession +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.example.friendlymeals.data.datasource.LiveAIRemoteDataSource +import com.google.firebase.example.friendlymeals.data.model.Recipe +import javax.inject.Inject + +class LiveAIRepository @Inject constructor( + private val liveAiRemoteDataSource: LiveAIRemoteDataSource +) { + @OptIn(PublicPreviewAPI::class) + suspend fun setupLiveSession(recipe: Recipe): LiveSession? { + return liveAiRemoteDataSource.setupLiveSession(recipe) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListScreen.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListScreen.kt new file mode 100644 index 0000000..fd56772 --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListScreen.kt @@ -0,0 +1,276 @@ +package com.google.firebase.example.friendlymeals.ui.groceryList + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.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.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.firebase.example.friendlymeals.R +import com.google.firebase.example.friendlymeals.data.model.GroceryItem +import com.google.firebase.example.friendlymeals.ui.theme.BorderColor +import com.google.firebase.example.friendlymeals.ui.theme.FriendlyMealsTheme +import com.google.firebase.example.friendlymeals.ui.theme.LightTeal +import com.google.firebase.example.friendlymeals.ui.theme.Teal +import com.google.firebase.example.friendlymeals.ui.theme.TextColor +import kotlinx.serialization.Serializable + +@Serializable +object GroceryListRoute + +@Composable +fun GroceryListScreen( + viewModel: GroceryListViewModel = hiltViewModel() +) { + val groceries = viewModel.groceries.collectAsStateWithLifecycle() + + GroceryListScreenContent( + groceries = groceries.value, + onAddItem = viewModel::addItem, + onToggleItem = viewModel::toggleItem, + onDeleteItem = viewModel::deleteItem + ) +} + +@Composable +fun GroceryListScreenContent( + groceries: List, + onAddItem: (String) -> Unit = {}, + onToggleItem: (GroceryItem) -> Unit = {}, + onDeleteItem: (GroceryItem) -> Unit = {} +) { + var inputText by remember { mutableStateOf("") } + + fun handleAdd() { + if (inputText.isNotBlank()) { + onAddItem(inputText) + inputText = "" + } + } + + Scaffold( + topBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = R.string.grocery_list_title), + fontSize = 28.sp, + fontWeight = FontWeight.Bold + ) + } + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 24.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = inputText, + onValueChange = { inputText = it }, + placeholder = { Text( + stringResource(R.string.add_grocery_item_hint), + color = Color.Gray + ) }, + shape = RoundedCornerShape(16.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Teal, + unfocusedBorderColor = BorderColor + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { handleAdd() }), + modifier = Modifier + .weight(1f) + .height(54.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Button( + onClick = { handleAdd() }, + colors = ButtonDefaults.buttonColors(containerColor = Teal), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.size(54.dp), + contentPadding = PaddingValues(0.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = stringResource(R.string.add_button), + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } + + if (groceries.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.grocery_list_empty_message), + fontSize = 16.sp, + color = Color.Gray + ) + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(bottom = 24.dp), + modifier = Modifier.fillMaxSize() + ) { + items(items = groceries, key = { it.id }) { item -> + GroceryCard( + item = item, + onToggle = { onToggleItem(item) }, + onDelete = { onDeleteItem(item) } + ) + } + } + } + } + } +} + +@Composable +fun GroceryCard( + item: GroceryItem, + onToggle: () -> Unit, + onDelete: () -> Unit +) { + Card( + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = if (item.checked) 0.dp else 2.dp), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .clickable { onToggle() } + ) { + Row( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 14.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Box( + modifier = Modifier + .size(28.dp) + .background(if (item.checked) Teal else LightTeal, CircleShape), + contentAlignment = Alignment.Center + ) { + if (item.checked) { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + } else { + Box( + modifier = Modifier + .size(14.dp) + .background(Color.White, CircleShape) + ) + } + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = item.name, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = if (item.checked) Color.Gray else TextColor, + textDecoration = if (item.checked) TextDecoration.LineThrough else TextDecoration.None, + lineHeight = 22.sp + ) + } + + IconButton( + onClick = { onDelete() }, + modifier = Modifier.size(36.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_delete), + contentDescription = stringResource(R.string.delete_button_content_description), + tint = Color.Gray, + modifier = Modifier.size(20.dp) + ) + } + } + } +} + +@Preview +@Composable +fun GroceryListScreenPreview() { + FriendlyMealsTheme { + GroceryListScreenContent( + groceries = listOf( + GroceryItem(id = "1", name = "2 cloves garlic", checked = true), + GroceryItem(id = "2", name = "400g canned tomatoes", checked = false), + GroceryItem(id = "3", name = "Fresh basil", checked = false) + ) + ) + } +} diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListViewModel.kt new file mode 100644 index 0000000..48f7d9f --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/groceryList/GroceryListViewModel.kt @@ -0,0 +1,64 @@ +package com.google.firebase.example.friendlymeals.ui.groceryList + +import com.google.firebase.example.friendlymeals.MainViewModel +import com.google.firebase.example.friendlymeals.data.model.GroceryItem +import com.google.firebase.example.friendlymeals.data.repository.AuthRepository +import com.google.firebase.example.friendlymeals.data.repository.DatabaseRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import javax.inject.Inject + +@HiltViewModel +class GroceryListViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val databaseRepository: DatabaseRepository +) : MainViewModel() { + private val _groceries = MutableStateFlow>(emptyList()) + val groceries: StateFlow> = _groceries.asStateFlow() + + val userId: String get() = authRepository.currentUser?.uid.orEmpty() + + init { + loadGroceries() + } + + private fun loadGroceries() { + if (userId.isEmpty()) return + + launchCatching { + databaseRepository.getGroceriesFlow(userId) + .catch { _ -> } + .collect { items -> + _groceries.value = items + } + } + } + + fun toggleItem(item: GroceryItem) { + launchCatching { + databaseRepository.updateGroceryItemChecked(item.id, !item.checked) + } + } + + fun addItem(name: String) { + if (name.isBlank() || userId.isEmpty()) return + + launchCatching { + val item = GroceryItem( + userId = userId, + name = name.trim(), + checked = false + ) + databaseRepository.addGroceryItem(item) + } + } + + fun deleteItem(item: GroceryItem) { + launchCatching { + databaseRepository.deleteGroceryItem(item.id) + } + } +} diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantScreen.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantScreen.kt new file mode 100644 index 0000000..32f4dab --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantScreen.kt @@ -0,0 +1,214 @@ +package com.google.firebase.example.friendlymeals.ui.live + +import android.graphics.Bitmap +import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +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.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.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import androidx.core.content.ContextCompat.getMainExecutor +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.firebase.example.friendlymeals.R +import com.google.firebase.example.friendlymeals.data.model.Recipe +import com.google.firebase.example.friendlymeals.ui.theme.FriendlyMealsTheme +import com.google.firebase.example.friendlymeals.ui.theme.Teal +import kotlinx.serialization.Serializable + +@Serializable +data class LiveAssistantRoute(val recipeId: String) + +@Composable +fun LiveAssistantScreen( + viewModel: LiveAssistantViewModel = hiltViewModel(), + navigateBack: () -> Unit, + showError: () -> Unit +) { + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + + LiveAssistantScreenContent( + uiState = uiState.value, + navigateBack = navigateBack, + sendVideoFrame = viewModel::sendVideoFrame, + showError = showError + ) +} + +@Composable +fun LiveAssistantScreenContent( + uiState: LiveAssistantUiState, + navigateBack: () -> Unit = {}, + sendVideoFrame: (Bitmap) -> Unit = {}, + showError: () -> Unit = {} +) { + val lifecycleOwner = LocalLifecycleOwner.current + + Scaffold { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .background(Color.Black), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = navigateBack, + modifier = Modifier + .background(Color.White.copy(alpha = 0.2f), CircleShape) + .size(40.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = stringResource(id = R.string.back_button_content_description), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = stringResource(id = R.string.live_assistant_title), + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + + when (uiState) { + is LiveAssistantUiState.Loading -> { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = Teal) + } + } + is LiveAssistantUiState.Error -> { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = uiState.message, + color = Color.Red, + modifier = Modifier.padding(16.dp), + textAlign = TextAlign.Center + ) + } + } + is LiveAssistantUiState.Success -> { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clip(RoundedCornerShape(24.dp)) + ) { + AndroidView( + factory = { context -> + val previewView = PreviewView(context) + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + val preview = Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + val imageAnalyzer = ImageAnalysis.Builder() + .setBackpressureStrategy(STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { analysis -> + analysis.setAnalyzer(Dispatchers.IO.asExecutor()) { imageProxy -> + val bitmap = imageProxy.toBitmap() + sendVideoFrame(bitmap) + imageProxy.close() + } + } + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + DEFAULT_BACK_CAMERA, + preview, + imageAnalyzer + ) + } catch (_: Exception) { + showError() + } + }, getMainExecutor(context)) + previewView + }, + modifier = Modifier.fillMaxSize() + ) + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(Color.Black.copy(alpha = 0.6f)) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(id = R.string.live_assistant_hint), + color = Color.White, + fontSize = 14.sp, + textAlign = TextAlign.Center + ) + } + } + } + } + } + } +} + +@androidx.compose.ui.tooling.preview.Preview +@Composable +fun LiveAssistantScreenPreview() { + FriendlyMealsTheme { + LiveAssistantScreenContent( + uiState = LiveAssistantUiState.Success(Recipe()) + ) + } +} diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantUiState.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantUiState.kt new file mode 100644 index 0000000..ba48ded --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantUiState.kt @@ -0,0 +1,9 @@ +package com.google.firebase.example.friendlymeals.ui.live + +import com.google.firebase.example.friendlymeals.data.model.Recipe + +sealed interface LiveAssistantUiState { + data object Loading : LiveAssistantUiState + data class Success(val recipe: Recipe) : LiveAssistantUiState + data class Error(val message: String) : LiveAssistantUiState +} diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantViewModel.kt new file mode 100644 index 0000000..c15b501 --- /dev/null +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/live/LiveAssistantViewModel.kt @@ -0,0 +1,155 @@ +package com.google.firebase.example.friendlymeals.ui.live + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.toRoute +import com.google.firebase.ai.type.FunctionCallPart +import com.google.firebase.ai.type.FunctionResponsePart +import com.google.firebase.ai.type.InlineData +import com.google.firebase.ai.type.LiveSession +import com.google.firebase.ai.type.PublicPreviewAPI +import com.google.firebase.example.friendlymeals.MainViewModel +import com.google.firebase.example.friendlymeals.data.model.GroceryItem +import com.google.firebase.example.friendlymeals.data.model.Recipe +import com.google.firebase.example.friendlymeals.data.repository.AuthRepository +import com.google.firebase.example.friendlymeals.data.repository.DatabaseRepository +import com.google.firebase.example.friendlymeals.data.repository.LiveAIRepository +import com.google.firebase.example.friendlymeals.ui.live.LiveAssistantUiState.Loading +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.io.ByteArrayOutputStream +import javax.inject.Inject + +@HiltViewModel +@OptIn(PublicPreviewAPI::class) +class LiveAssistantViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val authRepository: AuthRepository, + private val databaseRepository: DatabaseRepository, + private val liveAIRepository: LiveAIRepository +) : MainViewModel() { + private val route = savedStateHandle.toRoute() + val recipeId: String = route.recipeId + + private val _uiState = MutableStateFlow(Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private var liveSession: LiveSession? = null + private var isConnected = false + private var lastFrameTime = 0L + + init { + loadRecipeAndConnect() + } + + private fun loadRecipeAndConnect() { + launchCatching { + val recipe = databaseRepository.getRecipe(recipeId) + + if (recipe.title.isBlank()) { + _uiState.value = LiveAssistantUiState.Error(RECIPE_ERROR) + } else { + _uiState.value = LiveAssistantUiState.Success(recipe) + setupLiveSession(recipe) + } + } + } + + private suspend fun setupLiveSession(recipe: Recipe) { + val session = liveAIRepository.setupLiveSession(recipe) + + if (session == null) { + _uiState.value = LiveAssistantUiState.Error(CONNECTION_ERROR) + } else { + liveSession = session + isConnected = true + startConversation() + } + } + + private fun handler(functionCall: FunctionCallPart): FunctionResponsePart { + if (functionCall.name == ADD_INGREDIENTS_TOOL_NAME) { + val ingredient = functionCall.args[INGREDIENT_FIELD_NAME] + val ingredientName = when (ingredient) { + is JsonPrimitive -> ingredient.content + else -> ingredient?.toString() + }?.trim()?.removeSurrounding("\"") + + if (!ingredientName.isNullOrBlank()) { + val userId = authRepository.currentUser?.uid.orEmpty() + if (userId.isNotEmpty()) { + launchCatching { + val item = GroceryItem( + userId = userId, + name = ingredientName, + checked = false + ) + databaseRepository.addGroceryItem(item) + } + } + } + + return FunctionResponsePart( + functionCall.name, + JsonObject(mapOf( + "result" to JsonPrimitive("Successfully added $ingredientName to grocery list") + )), + functionCall.id + ) + } + + return FunctionResponsePart(functionCall.name, JsonObject(emptyMap()), functionCall.id) + } + + // Suppressing MissingPermission warning as we're + // checking permissions before opening the screen + @SuppressLint("MissingPermission") + private fun startConversation() { + launchCatching { + liveSession?.startAudioConversation(::handler) + } + } + + private fun endConversation() { + launchCatching { + liveSession?.stopAudioConversation() + } + } + + fun sendVideoFrame(bitmap: Bitmap) { + if (!isConnected || liveSession == null) return + val currentTime = System.currentTimeMillis() + + // Limit sending frames to once per second to conserve bandwidth and processing + if (currentTime - lastFrameTime < 1000) return + lastFrameTime = currentTime + + launchCatchingIO { + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) + val jpegBytes = outputStream.toByteArray() + liveSession?.sendVideoRealtime(InlineData(jpegBytes, MIME_TYPE)) + } + } + + override fun onCleared() { + super.onCleared() + endConversation() + } + + companion object { + //Connection config + private const val MIME_TYPE = "image/jpeg" + private const val RECIPE_ERROR = "Failed to load recipe" + private const val CONNECTION_ERROR = "Failed to connect to live assistant" + + //Tool config + private const val INGREDIENT_FIELD_NAME = "ingredient" + private const val ADD_INGREDIENTS_TOOL_NAME = "addIngredientToGroceryList" + } +} diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt index 29ac8f9..f547bca 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeScreen.kt @@ -1,6 +1,15 @@ package com.google.firebase.example.friendlymeals.ui.recipe +import android.Manifest.permission.CAMERA +import android.Manifest.permission.RECORD_AUDIO +import android.content.pm.PackageManager +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.core.content.ContextCompat import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -54,15 +63,26 @@ data class RecipeRoute(val recipeId: String) @Composable fun RecipeScreen( viewModel: RecipeViewModel = hiltViewModel(), - navigateBack: () -> Unit + navigateBack: () -> Unit, + navigateToLiveAssistant: (String) -> Unit ) { val recipeViewState = viewModel.recipeViewState.collectAsStateWithLifecycle() + val groceryListToast = stringResource(R.string.added_to_grocery_list_toast) + val context = LocalContext.current RecipeScreenContent( navigateBack = navigateBack, toggleFavorite = viewModel::toggleFavorite, leaveReview = viewModel::leaveReview, - recipeViewState = recipeViewState.value + recipeViewState = recipeViewState.value, + onLiveAssistantClick = { + navigateToLiveAssistant(recipeViewState.value.recipeId) + }, + onAddIngredientsToGrocery = { + viewModel.addIngredientsToGroceryList(recipeViewState.value.recipe.ingredients) { + Toast.makeText(context, groceryListToast, Toast.LENGTH_SHORT).show() + } + } ) } @@ -71,8 +91,25 @@ fun RecipeScreenContent( navigateBack: () -> Unit = {}, toggleFavorite: () -> Unit = {}, leaveReview: (Int) -> Unit = {}, - recipeViewState: RecipeViewState + recipeViewState: RecipeViewState, + onLiveAssistantClick: () -> Unit = {}, + onAddIngredientsToGrocery: () -> Unit = {} ) { + val context = LocalContext.current + val multiplePermissionsLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val cameraGranted = permissions[CAMERA] == true + val audioGranted = permissions[RECORD_AUDIO] == true + if (cameraGranted && audioGranted) { + onLiveAssistantClick() + } else { + Toast.makeText(context, "Camera and Microphone permissions are required to use the Live Assistant.", Toast.LENGTH_LONG).show() + } + } + val cameraPermissionGranted = ContextCompat.checkSelfPermission(context, CAMERA) == PackageManager.PERMISSION_GRANTED + val audioPermissionGranted = ContextCompat.checkSelfPermission(context, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val favoriteIcon = if (recipeViewState.favorite) { painterResource(R.drawable.ic_favorite_filled) } else { @@ -130,7 +167,7 @@ fun RecipeScreenContent( ) { Icon( painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = stringResource(id = R.string.recipe_back_button_content_description), + contentDescription = stringResource(id = R.string.back_button_content_description), tint = TextColor ) } @@ -165,6 +202,42 @@ fun RecipeScreenContent( lineHeight = 34.sp ) + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + if (cameraPermissionGranted && audioPermissionGranted) { + onLiveAssistantClick() + } else { + multiplePermissionsLauncher.launch( + arrayOf(CAMERA, RECORD_AUDIO) + ) + } + }, + colors = ButtonDefaults.buttonColors(containerColor = Teal), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth().height(52.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_cook), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.live_assistant_title), + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) Row( @@ -217,6 +290,38 @@ fun RecipeScreenContent( recipeViewState.recipe.ingredients.forEach { IngredientRow(it) } + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = { onAddIngredientsToGrocery() }, + colors = ButtonDefaults.buttonColors( + containerColor = LightTeal, + contentColor = Teal + ), + shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth().height(48.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_check), + contentDescription = null, + tint = Teal, + modifier = Modifier.size(18.dp) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = stringResource(R.string.add_to_grocery_list_button), + fontSize = 15.sp, + fontWeight = FontWeight.Bold + ) + } + } } } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt index ed0ae52..17d5d4d 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewModel.kt @@ -35,6 +35,7 @@ class RecipeViewModel @Inject constructor( fun loadRecipe() { launchCatching { _recipeViewState.value = RecipeViewState( + recipeId = recipeId, recipe = databaseRepository.getRecipe(recipeId), favorite = loadFavorite(), rating = loadRating() @@ -84,4 +85,13 @@ class RecipeViewModel @Inject constructor( ) } } + + fun addIngredientsToGroceryList(ingredients: List, onSuccess: () -> Unit) { + if (userId.isEmpty() || ingredients.isEmpty()) return + + launchCatching { + databaseRepository.addIngredientsToGroceries(userId, ingredients) + onSuccess() + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt index 2238413..1e8aee9 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipe/RecipeViewState.kt @@ -3,6 +3,7 @@ package com.google.firebase.example.friendlymeals.ui.recipe import com.google.firebase.example.friendlymeals.data.model.Recipe data class RecipeViewState( + val recipeId: String = "", val recipe: Recipe = Recipe(), val favorite: Boolean = false, val rating: Int = 0 diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterScreen.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterScreen.kt index 4a1ff0e..76aabba 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterScreen.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/recipeList/filter/FilterScreen.kt @@ -121,7 +121,7 @@ fun FilterScreenContent( IconButton(onClick = { navigateBack() }) { Icon( painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = stringResource(id = R.string.recipe_back_button_content_description) + contentDescription = stringResource(id = R.string.back_button_content_description) ) } } diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/BottomNavBar.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/BottomNavBar.kt index 82b2d15..c03d62d 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/BottomNavBar.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/shared/BottomNavBar.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.google.firebase.example.friendlymeals.R import com.google.firebase.example.friendlymeals.ui.generate.GenerateRoute +import com.google.firebase.example.friendlymeals.ui.groceryList.GroceryListRoute import com.google.firebase.example.friendlymeals.ui.recipeList.RecipeListRoute import com.google.firebase.example.friendlymeals.ui.scanMeal.ScanMealRoute import com.google.firebase.example.friendlymeals.ui.theme.Teal @@ -23,6 +24,7 @@ sealed class BottomNavItem(val route: Any, val icon: Int, val label: Int) { object ScanMeal : BottomNavItem(ScanMealRoute, R.drawable.ic_camera, R.string.nav_bar_scan_meal) object Generate : BottomNavItem(GenerateRoute, R.drawable.ic_generate, R.string.nav_bar_generate) object RecipeList : BottomNavItem(RecipeListRoute, R.drawable.ic_dine, R.string.nav_bar_recipe_list) + object GroceryList : BottomNavItem(GroceryListRoute, R.drawable.ic_check, R.string.nav_bar_grocery_list) } @Composable @@ -32,7 +34,8 @@ fun BottomNavBar(navigateTo: (Any) -> Unit) { val items = listOf( BottomNavItem.ScanMeal, BottomNavItem.Generate, - BottomNavItem.RecipeList + BottomNavItem.RecipeList, + BottomNavItem.GroceryList ) NavigationBar { diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..b9aa461 --- /dev/null +++ b/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..494ece6 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1cfb76c..c9ca124 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ Enter your list of ingredients Any notes or preferred cuisines? Generate recipe + Back Friendly Meals Logo New recipe List your ingredients @@ -13,7 +14,6 @@ Generate Recipe Recipe image Could not load image - Back Favorite Prep Time Cook Time @@ -53,4 +53,15 @@ Recipes Take a picture of ingredients Sorry, something went wrong :( + Error loading camera + Live Cooking Assistant + Point your camera at your cooking and ask questions + Grocery List + Grocery List + Add an ingredient… + Add + Delete + Add ingredients to Grocery List + Added to Grocery List! + Your grocery list is empty \ No newline at end of file diff --git a/app/src/main/res/xml/remote_config_defaults.xml b/app/src/main/res/xml/remote_config_defaults.xml index 1175602..91c3b0f 100644 --- a/app/src/main/res/xml/remote_config_defaults.xml +++ b/app/src/main/res/xml/remote_config_defaults.xml @@ -1,7 +1,7 @@ hybrid_cloud_model - gemini-3.1-flash-lite-preview + gemini-3.1-flash-lite hybrid_ingredients_prompt @@ -15,12 +15,28 @@ generate_recipe_photo_gemini generate-recipe-photo-gemini-template-v1-0-0 - - generate_recipe_photo_imagen - generate-recipe-photo-imagen-template-v1-0-0 - generate_recipe generate-recipe-template-v1-0-0 + + live_model_name + gemini-2.5-flash-native-audio-preview-12-2025 + + + live_model_prompt + You are a helpful live cooking assistant. The user is currently preparing the following recipe: +Title: {{title}} +Prep time: {{prepTime}}, Cook time: {{cookTime}}, Servings: {{servings}} + +Ingredients: +{{ingredients}} + +Instructions: +{{instructions}} + +The user will stream real-time video of their cooking and ask questions like "Is this the expected texture of the recipe?". +Confirm or deny accurately based on the recipe context and the video content. Be concise and helpful. +If the user asks you to add an ingredient or item to their grocery list or shopping list, call the addIngredientToGroceryList function. + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 074ea6b..89f8551 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.google.services) apply false alias(libs.plugins.google.hilt) apply false diff --git a/gradle.properties b/gradle.properties index 20e2a01..670e9be 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,14 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.disallowKotlinSourceSets=false +org.gradle.java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be04753..7a3479a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,29 +1,30 @@ [versions] -agp = "8.13.1" +agp = "9.2.1" coilCompose = "2.7.0" exifinterface = "1.4.2" -firebaseAi = "17.10.1" -firebaseAiOndevice = "16.0.0-beta01" -firebaseBom = "34.12.0" -kotlin = "2.2.21" -coreKtx = "1.17.0" +firebaseAi = "17.12.0" +firebaseAiOndevice = "16.0.0-beta02" +firebaseBom = "34.13.0" +kotlin = "2.3.21" +coreKtx = "1.18.0" junit = "4.13.2" -junitVersion = "1.2.1" -espressoCore = "3.6.1" +junitVersion = "1.3.0" +espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.0" -composeBom = "2025.11.01" +activityCompose = "1.13.0" +composeBom = "2026.05.00" googleServices = "4.4.4" -googleHilt = "2.57.2" -googleKotlinKsp = "2.2.21-2.0.5" -hiltAndroidCompiler = "2.57.2" +googleHilt = "2.59.2" +googleKotlinKsp = "2.3.2" +hiltAndroidCompiler = "2.59.2" coreSplashscreen = "1.2.0" hiltNavigationCompose = "1.3.0" -navigationCompose = "2.9.6" +navigationCompose = "2.9.8" constraintlayoutCompose = "1.1.1" -kotlinxSerializationJson = "1.9.0" -richtextCommonmark = "1.0.0-alpha03" +kotlinxSerializationJson = "1.11.0" +richtextCommonmark = "1.0.0-alpha04" firebasePerf = "2.0.2" +cameraView = "1.6.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -58,10 +59,13 @@ androidx-navigation-compose = { module = "androidx.navigation:navigation-compose androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayoutCompose" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } richtext-commonmark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtextCommonmark" } +androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraView" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraView" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraView" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraView" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } google-hilt = { id = "com.google.dagger.hilt.android", version.ref = "googleHilt" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9787e6b..8144ab6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Feb 13 16:25:53 GMT 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 82a9970..32a8873 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,6 +11,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { From 818918799479a33c435e6bf627e46a86fab93835 Mon Sep 17 00:00:00 2001 From: Morgan Chen Date: Tue, 19 May 2026 15:20:46 -0700 Subject: [PATCH 5/6] update codelab with next content --- codelab.md | 578 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 codelab.md diff --git a/codelab.md b/codelab.md new file mode 100644 index 0000000..d5ab2ab --- /dev/null +++ b/codelab.md @@ -0,0 +1,578 @@ +# Firestore Pipeline Queries on Android + + + +# Before you begin + +In this codelab, you'll learn how to use the powerful new Pipeline Queries available in Firestore Enterprise edition. You will build a recipe management and discovery app on Android called **Friendly Meals**. With this app, you will learn how to perform advanced filtering, case-insensitive searches, complex sorting and more. These complex searches and transformations were previously not possible or required manual filtering in Firestore Standard edition. + + + +## What you’ll learn + + + +* Standard **CRUD operations** in Firestore. +* Using **atomic batches** to maintain data consistency. +* Constructing **multi-stage Pipeline Queries** for advanced filtering. +* Implementing **sorting and ordering** when filtering data. +* Implementing **aggregations** when filtering data. +* Implementing **case-insensitive searches**. + + +## Prerequisites + + + +* Latest version of [Android Studio](https://developer.android.com/studio). +* An Android emulator with API 26 or higher. +* Basic knowledge of Kotlin and [Coroutines](https://kotlinlang.org/docs/coroutines-overview.html). + + + +--- + + + +# Create a Firebase project + +Duration: 3:00 + + + +1. Sign into the [Firebase console](https://console.firebase.google.com/) using your Google Account. +2. Click the **Create a new Firebase project **button, then enter a project name (for example, **FriendlyMeals**). + + ``` +Note: This project name is used as a display name in Firebase interfaces, and Firebase auto-creates a unique project ID based on this project name. Note that you can optionally click the Edit icon to set your preferred project ID, but you cannot change this ID after project creation. If you forget your ID, you can always find it later in the Project Settings. +``` + + +3. Click **Continue**. +4. If prompted, review and accept the Firebase terms, and then click **Continue**. +5. Follow the steps to finish setting up your project, then click **Create project**. +6. Wait for your project to provision, and then click **Continue**. + + + +--- + + + +# Enable Firebase services + +Duration: 3:00 + +In order to run all the steps in this codelab, you will need to enable a few services on the Firebase project you just created: [Firestore Enterprise edition](https://firebase.google.com/docs/firestore) (to store the recipes), [Authentication](https://firebase.google.com/docs/auth) (to sign the user in as a guest), and [Firebase AI Logic](https://firebase.google.com/docs/ai-logic) (since this app uses Gemini through Firebase AI Logic to generate recipes). At the end of this step, you will also need to register your Android app in this Firebase project. + + +## Enable Firestore Enterprise edition + + + +1. In the Firebase console, [open the Firestore Database section](https://console.firebase.google.com/project/_/firestore/?useAutoProject=true). +2. Click **Create database** and select **Enterprise** for the database mode. Click **Next**. +3. Select **Firestore in Native Mode** for the operation mode. +4. You can use the **default database ID**. If you choose a custom ID, make sure to copy it exactly as written. You’ll need it soon. +5. Select a location for your database and click **Next**. +6. Select **Start in Test Mode**. Then click **Create**. + + ``` +Warning: For real-world applications, you should always start Firestore in Production Mode. We are using Test Mode now to get started quickly, but make sure you understand how Security Rules work and implement them on your database before shipping your app to production. +``` + + + + +## Enable Authentication + + + +1. In the Firebase console, [open the Authentication section](https://console.firebase.google.com/project/_/authentication/?useAutoProject=true). +2. Click **Get started** and choose **Anonymous**. +3. Toggle the enable button and click **Save**. + + +## Enable Firebase AI Logic and register app + + + +1. In the Firebase console, [open the AI Logic section](https://console.firebase.google.com/project/_/ailogic/?useAutoProject=true). +2. Click **Get started**, then **Enable API**. + 1. If prompted to select an API, proceed with the **Gemini Developer API**. +3. When prompted to add an app, **click the Android icon**. +4. When prompted for a package name, use `com.google.firebase.example.friendlymeals` +5. Leave other fields blank, **click Register App** and follow the instructions to download the `google-services.json` file. Keep this file handy as you’re going to need it soon, then click **Next**. +6. You can skip the instructions to add the Firebase SDK, as the code you will download in the next step already includes the Firebase SDK. At the end, click **Continue to console**. + + + +--- + + + +# Set up the sample project + +Duration: 8:00 + + +## Download the code + +Run the following command to clone the sample code for this codelab from [GitHub](https://github.com/FirebaseExtended/FriendlyMeals-Android). This will create a folder called `FriendlyMeals-Android` on your machine: + + +``` +$ git clone https://github.com/FirebaseExtended/FriendlyMeals-Android.git +``` + + +Navigate to the project folder and checkout the codelab branch: + + +``` +$ cd FriendlyMeals-Android +$ git checkout codelab/firestore-enterprise-on-android +``` + + + +## Import the project + +Open Android Studio. **Click File > New > Import Project** and select the `FriendlyMeals-Android` folder. + +Move the `google-services.json` file you just downloaded into the **app/** folder. + + +## Run the app + +Now that you have added the `google-services.json` file, the project should compile and you can safely run the app on your Android emulator, or on a physical device. Since this is the first time you’re running this app on your machine, it may take a while to sync the Gradle files and build the app. + +Once the app is launched, you will automatically be signed in as a guest user through Firebase Anonymous Authentication, and you should land on the _New recipe screen_: + +>>>>>GDCALERT:inline image link here (to images/image2.png). Store image on your image server and adjust path/filename/extension if necessary.>>>>> +![alt_text](images/image2.png "image_tooltip") + + +You can use the tabs at the bottom to navigate to other screens. + + + +--- + + + +# Write data to Firestore + +Duration: 10:00 + +In this step you will write some data to Firestore so that you can populate the currently empty _Recipes screen_. Firestore Enterprise edition uses the same fundamental [data model](https://firebase.google.com/docs/firestore/data-model) as Firestore Standard edition:** Documents which are grouped into Collections and Subcollections. ** + + +## Initialize Firestore + +Before you can make any calls to Firestore, you need to create a Cloud Firestore instance. Open `data/injection/FirebaseHiltModule.kt` and make sure the `firestore()` function is initializing Firestore with the correct database ID. If you created the database with the default database ID, it should look like this: + + +``` +@Provides fun firestore(): FirebaseFirestore { + return FirebaseFirestore.getInstance("default") +} +``` + + +If you chose a different database ID, make sure you’re using that when calling `getInstance()`. Now you’re ready to make some calls to Firestore. + + +``` +Note: Friendly Meals uses Hilt to inject dependencies. To learn more, check out the Hilt documentation. +``` + + + +## Add Recipes to the database + +The main model object in this app is a `Recipe` (see `data/model/Recipe.kt`). You will store each recipe as a document in a top-level collection called `recipes`. Every time the user generates a new recipe using Gemini, and saves it, the app will create a new recipe document in this collection. + +Open `data/datasource/DatabaseRemoteDataSource.kt` and replace the `addRecipe` function with: + + +``` +suspend fun addRecipe(recipe: Recipe): String { + val recipeRef = firestore.collection(RECIPES_COLLECTION).add(recipe).await() + return recipeRef.id +} +``` + + +Here you’re starting by getting a reference to the `recipes` collection. Collections are created implicitly when documents are added, so there is no need to create the collection before writing data. Documents can be created using Kotlin data classes, which you’re using here to create each `recipe` doc. Lastly, you’re returning the `id` of the document so it can be passed from the _Generate screen_ to the _Recipe screen_ once the recipe is finished being generated and stored. + +--- + + + +# Update data in Firestore + +Duration: 10:00 + +In Firestore Standard edition, calculating the average rating of a recipe would typically require writing client-side transactions or cloud functions to re-calculate the average and persist that average back onto the recipe document on every review write. + +With **Firestore Enterprise edition**, this pre-calculation is entirely unnecessary! We can leverage **subcollection aggregation** to calculate the average rating of a recipe dynamically on-the-fly during read and query operations. + +As a result, we no longer need to persist a static average rating field or run client-side update logic when users write reviews. In the next section, you'll learn how to build a query pipeline that performs this subcollection aggregation dynamically. + + + + + +--- + + + +# Read data from Firestore + +Duration: 5:00 + +So far you have added and updated documents in your database. Now, let's retrieve these saved documents! + +In the same file, replace the `getAllRecipes` function with: + +```kotlin +suspend fun getAllRecipes(): List { + return firestore + .pipeline() + .collection(RECIPES_COLLECTION) + .define( + documentId(field(NAME_FIELD_PATH)).alias(CURRENT_RECIPE_ID_VAR) + ) + .addFields( + PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) + .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) + .toScalarExpression().alias(AVERAGE_RATING_FIELD) + ) + .execute().await().results.toRecipeListItem() +} +``` + + +This function retrieves all documents in the `recipe` collection. Notice the call to the `toRecipeListItem` extension (available at the bottom of the class). This extension maps each `PipelineResult` in the list to an object of the type `RecipeListItem`, which is a type that the application understands and uses in its business logic and to display data in the UI. + +Similarly, when fetching a single recipe, you can call the `toRecipe` function to convert a single document to a `Recipe`. In the same file, replace the `getRecipe` function with: + +```kotlin +suspend fun getRecipe(recipeId: String): Recipe { + val recipePath = "${RECIPES_COLLECTION}/${recipeId}" + + return firestore + .pipeline() + .documents(recipePath) + .define( + documentId(field(NAME_FIELD_PATH)).alias(CURRENT_RECIPE_ID_VAR) + ) + .addFields( + PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) + .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) + .toScalarExpression().alias(AVERAGE_RATING_FIELD) + ) + .execute().await().results.toRecipe() +} +``` + + +Once again you are constructing a pipeline, but using the `documents()` function instead of `collection()`, because you want to retrieve one recipe only. You can make sure the pipeline will fetch only this specific recipe by providing the path to the document (`recipePath`). + +The `getAllRecipes` and `getRecipe` functions are being called in the `RecipeListViewModel` and `RecipeViewModel` respectively. Once the view models receive the results, they assign these results to a `MutableStateFlow` object observed by the view layer. + +Once a new state is emitted via `MutableStateFlow`, the Jetpack Compose UI will collect this new state and recompose itself to display the changes to the user. You can check the code for these UIs in the `RecipeListScreen.kt` and `RecipeScreen.kt` files. + + + +--- + + + +# Test recipe creation and listing + +Duration: 8:00 + + +## Enable Cloud Storage + +All the recipe images generated by Gemini are stored in [Cloud Storage](https://firebase.google.com/docs/storage). In order to use Storage, you need to make sure that your Firebase project is on the [pay-as-you-go Blaze pricing plan](https://firebase.google.com/pricing). Don’t worry, you’re not going to be charged for anything at this stage! + +To upgrade to Blaze and enable Cloud Storage, follow these steps: + + + +1. Open the [Firebase console](https://console.firebase.google.com/project/_/?useAutoProject=true) and click **Upgrade** (bottom right corner). +2. Click **Select plan** and follow the steps to set up a billing account. +3. Finish the setup by **linking your project** with the selected billing account. +4. [Open the Storage section](https://console.firebase.google.com/project/_/storage/?useAutoProject=true). +5. Click **Get started**, select **No cost location** and click **Continue**. +6. Select **Start in Test Mode**. Then click **Create**. + 1. Once again: don’t forget that **for real-world applications, you should always start Firestore in Production Mode**! + + +## Time to run the app! + +To test recipe creation, run the app and type a list of ingredients into the first input field in the _New recipe screen_. If you're feeling fancy, you can add special notes or cuisines on the second input field. Click **Generate Recipe** and wait for Gemini to generate a recipe for you. \ + \ +Once the recipe is generated, the _Recipe screen _appears, where you can see the recipe image, ingredients, instructions and a few other details, like preparation time and number of servings. If you scroll down to the bottom of the recipe, you can add your rating to the recipe, which will trigger the `getAverageRatingForRecipe` function you wrote in a previous step. + +Scroll back to the top of the page and click the back arrow icon in the top left corner of the screen. It will take you back to the _Generate screen_, where you can generate as many recipes as you want. When you’re done generating recipes, navigate to the _Recipes screen_ using the navigation bar at the bottom, to see a beautiful list of all the recipes you generated: + +>>>>>GDCALERT:inline image link here (to images/image3.png). Store image on your image server and adjust path/filename/extension if necessary.>>>>> +![alt_text](images/image3.png "image_tooltip") +>>>>>GDCALERT:inline image link here (to images/image4.png). Store image on your image server and adjust path/filename/extension if necessary.>>>>> +![alt_text](images/image4.png "image_tooltip") + + + +``` +Note: As you start generating recipes and updating the rating, you can open the Firestore section in the Firebase console and see all the updates happening in real time! +``` + + + + +--- + + + +# Build a Pipeline Query + +Duration: 8:00 + +Hopefully, by now, you have created lots of delicious recipes while testing the app! Which means now you need to build a good _Filter screen_ to allow users to find the recipes they want quickly and efficiently. + +Go back to the `DatabaseRemoteDataSource.kt` file and locate the `getFilteredRecipes` function. You can see that this function receives two parameters: `filterOptions` and `userId`. You will use `filterOptions` to check which filters the user set up in the _Filter screen_. You will use `userId` to filter the recipes created by that user, should this filter be `true` in the `filterOptions `object. \ + \ +Now open the `ui/recipeList/filter/FilterOptions.kt` file. It should look like this: + + +```kotlin +data class FilterOptions( + val recipeTitle: String = "", + val searchQuery: String = "", + val filterByMine: Boolean = false, + val rating: Int = 0, + val selectedTags: List = listOf(), + val sortBy: SortByFilter = DEFAULT +) +``` + +The following steps will build on each other, all adding stages to a single pipeline query in the same function. + +Open `data/datasource/DatabaseRemoteDataSource.kt` and locate the `getFilteredRecipes` function. The starting state of the function in the codebase is a simple stub that initializes the pipeline on the `recipes` collection and returns all results without any filters: + +```kotlin +@Suppress("UnstableApiUsage") +suspend fun getFilteredRecipes( + filterOptions: FilterOptions, + userId: String +): List { + var pipeline = firestore.pipeline().collection(RECIPES_COLLECTION) + + // Implement this function in the next codelab steps. + + return pipeline.execute().await().results.toRecipeListItem() +} +``` + +Let’s implement these filters and the sorting criteria using Pipelines. + +## Text Search + +Before we implement standard filtering, let's explore the new Full-Text Search functionality. We'll use full-text search to find content in recipe descriptions. + +Inside the `getFilteredRecipes` function of `DatabaseRemoteDataSource.kt`, the text search is constructed using the `SearchStage.withQuery(...)` pipeline stage, which automatically ranks matching recipes by search score. Since average ratings and likes are dynamically calculated inline rather than stored statically on recipe documents, the query also defines dynamic pipeline aggregations. + +Add the following full-text search and dynamic aggregation stages directly under the pipeline initialization: + +```kotlin +if (filterOptions.searchQuery.isNotBlank()) { + val searchStage = SearchStage.withQuery(filterOptions.searchQuery) + .withAddFields(Expression.score().alias(SCORE_ALIAS)) + + pipeline = pipeline.search(searchStage).sort(field(SCORE_ALIAS).descending()) +} + +pipeline = pipeline + .define( + documentId(field(NAME_FIELD_PATH)).alias(CURRENT_RECIPE_ID_VAR) + ).addFields( + PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) + .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) + .toScalarExpression().alias(AVERAGE_RATING_FIELD), + firestore.pipeline() + .collectionGroup(LIKES_COLLECTION) + .where(field(RECIPE_ID_FIELD) + .equal(variable(CURRENT_RECIPE_ID_VAR))) + .aggregate(countAll().alias(LIKES_COUNT_ALIAS)) + .toScalarExpression().alias(LIKES_FIELD) + ) +``` + + +## Filter by title + +Users can search for text in the description and filter by title at the same time. To filter by recipe title, add another `if` block right below the previous one: + +```kotlin +if (filterOptions.recipeTitle.isNotBlank()) { + pipeline = pipeline + .where(field(TITLE_FIELD).toLower().stringContains(filterOptions.recipeTitle.lowercase())) +} +``` + + +First, you need to check if the user is using this filter by checking if `recipeTitle` is not blank. Next, you need to add a `where` clause and check if the recipe’s title contains the words typed by the user, which you can do with `stringContains`. Don’t forget to use `toLower()` when specifying the `field`, and make sure the words typed by the user are _also_ in lower case. This is necessary because pipeline searches are case sensitive, so you need to transform these values to lowercase before comparing them. + + +## Filter by author + +In the _Filter screen_, users can toggle the _Filter by mine_ option. To implement that, add another `if` statement right below the previous one: + + +``` +if (filterOptions.filterByMine) { + pipeline = pipeline + .where(field(AUTHOR_ID_FIELD).equal(userId)) +} +``` + + +First, check if `filterByMine` is true - which means the user wants to use this filter. Then, add a `where` clause and check if the recipe’s author has the same id as the currently authenticated user, which you can do with the `equal` function. Here’s where you use that `userId` parameter! + + +## Filter by rating + +Users can filter recipes based on a 1 to 5 stars average rating - here you can see how important it is to recalculate the average rating when someone submits a new review! To implement that, add another `if` statement below the previous one: + + +``` +if (filterOptions.rating > 0) { + pipeline = pipeline + .where(field(AVERAGE_RATING_FIELD).greaterThanOrEqual(filterOptions.rating)) +} +``` + + +Start by checking if the user wants to filter by rating, by checking if `filterOptions.rating` is greater than zero. Next, you need to add another `where` clause and check if the recipe’s average rating is greater than, or equal to the minimum rating selected by the user. + + +## Filter by tags + +Gemini generates a list of tags for each recipe (e.g.: _#dessert_, _#healthy_). Users can select a few of these tags in the _Filter screen _when searching for recipes. To implement that, add one last `if` statement to the function: + + +``` +if (filterOptions.selectedTags.isNotEmpty()) { + pipeline = pipeline + .where(field(TAGS_FIELD).arrayContainsAny(filterOptions.selectedTags)) +} +``` + + +First you need to check if the list of selected tags is not empty. Then, add another `where` clause and check if the recipe’s tags list contains any of these tags selected by the user. You can do that with the `arrayContainsAny` function. + + +## Sorting + +Users can also opt to sort recipes based on four different criteria: default (no sort), the average rating, the title (alphabetical order), or the popularity (how many users liked the recipe). To implement that, add the following code right below the last `if` statement: + +``` +when (filterOptions.sortBy) { + SortByFilter.DEFAULT -> {} + SortByFilter.RATING -> { + pipeline = pipeline.sort(field(AVERAGE_RATING_FIELD).descending()) + } + SortByFilter.ALPHABETICAL -> { + pipeline = pipeline.sort(field(TITLE_FIELD).ascending()) + } + SortByFilter.POPULARITY -> { + pipeline = pipeline.sort(field(LIKES_FIELD).descending()) + } +} + +return pipeline.execute().await().results.toRecipeListItem() +``` + + +Each of these sorting criteria will have a logic to it - whether it is `ascending` or `descending` will depend on the sorting type selected using the radio buttons in the _Filter screen_. + +At the end you need to execute the pipeline, transform the results in a list of `RecipeListItem`, and return the list so the UI can be updated to display only the recipes that match the criteria. + +## Suggest Tags + +In the Filter screen, the app can suggest popular category tags to the user. To support this, we need to write a pipeline query that unrolls the tags array on all recipes, groups and aggregates them to count occurrences, and sorts them to return the top 10 most popular tags. + +In `DatabaseRemoteDataSource.kt`, replace the `getPopularTags` function with: + +```kotlin +suspend fun getPopularTags(): List { + val results = firestore.pipeline() + .collection(RECIPES_COLLECTION) + .unnest(field(TAGS_FIELD).alias(TAG_NAME_ALIAS)) + .aggregate( + AggregateStage.withAccumulators(countAll().alias(TAG_COUNT_ALIAS)) + .withGroups(TAG_NAME_ALIAS) + ) + .sort(field(TAG_COUNT_ALIAS).descending()) + .limit(10) + .execute().await().results + + return results.mapNotNull { it.getData()[TAG_NAME_ALIAS] as? String } +} +``` + +First, this code is getting a reference to the `recipes` collection and using the `unnest` operator to flatten the tags array on each recipe. It then groups them by tag name and aggregates them using `countAll()`. Finally, it sorts by popularity in descending order, limits the results to 10, executes the pipeline, and extracts the tag names from the resulting data maps. + + +## Test queries in the Firebase console + +**You can test these queries straight in the Firebase console!** The Firestore section has a **Query Explorer** where you can run and test your queries in JavaScript. Although the implementation varies slightly from Kotlin, the logic is straightforward and user-friendly. The console also has a **Query Explainer** where you can see if your pipeline is using an index or performing a slow collection scan. + +>>>>>GDCALERT:inline image link here (to images/image5.png). Store image on your image server and adjust path/filename/extension if necessary.>>>>> +![alt_text](images/image5.png "image_tooltip") + + + + +--- + + + +# Test recipes filter + +Duration: 8:00 + +**Time to run the app again!** To test the filters you built in the previous step, navigate to the _Recipes screen_, then click the filter icon in the top right corner to navigate to the** _Filter screen_**. Once in this screen, you can play around with all the filters, then **click the Apply Filters button**. You will be taken back to the _Recipes screen_, but you should now only see the recipes that match the filters you selected: + +>>>>>GDCALERT:inline image link here (to images/image6.png). Store image on your image server and adjust path/filename/extension if necessary.>>>>> +![alt_text](images/image6.png "image_tooltip") + + +To modify the filters, or go back to the unfiltered list, click the filter icon again, then click **Reset**. + + + +--- + + + +# Conclusion + +Congratulations! **You've successfully implemented Firestore Enterprise edition in an Android app and learned the basics about Pipeline Queries**. In this codelab, you learned how to perform complex searches and transformations with Pipeline Queries and convert it to an object that is recognizable in your Kotlin code, so you can show it in the UI. + +**But there’s much, much more you can do with pipelines!** Firestore Enterprise edition also handles indexing and performance differently than the Standard edition. If you’re just starting out with Firestore or planning a migration from Standard edition, reviewing these **best practices** is essential. **They will help you optimize performance and ensure cost-efficiency, protecting you from unexpected billing spikes.** + +To learn about Pipeline operations, indexes, pricing and performance, take a look at these resources: + + + +* [Pipeline operations documentation](https://firebase.google.com/docs/firestore/enterprise/pipelines-overview) +* [Quick start documentation](https://firebase.google.com/docs/firestore/enterprise/quickstart) +* [Optimize query execution](https://firebase.google.com/docs/firestore/enterprise/optimize-query-performance) +* [Pricing documentation](https://firebase.google.com/docs/firestore/enterprise/pricing) + +**Happy coding!** From e2bac1d480c17bd83198c72c340f032210057749 Mon Sep 17 00:00:00 2001 From: Morgan Chen Date: Tue, 19 May 2026 16:40:46 -0700 Subject: [PATCH 6/6] update codelab for next --- .../datasource/DatabaseRemoteDataSource.kt | 103 +--- codelab.md | 578 ------------------ 2 files changed, 5 insertions(+), 676 deletions(-) delete mode 100644 codelab.md diff --git a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt index dde709b..e2fe2bd 100644 --- a/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt +++ b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt @@ -37,55 +37,19 @@ class DatabaseRemoteDataSource @Inject constructor( } suspend fun addRecipe(recipe: Recipe): String { - val recipeRef = firestore.collection(RECIPES_COLLECTION).add(recipe).await() - return recipeRef.id + return "" } suspend fun getRecipe(recipeId: String): Recipe { - val recipePath = "${RECIPES_COLLECTION}/${recipeId}" - - return firestore - .pipeline() - .documents(recipePath) - .define( - documentId(field(NAME_FIELD_PATH)) - .alias(CURRENT_RECIPE_ID_VAR)) - .addFields( - PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) - .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) - .toScalarExpression().alias(AVERAGE_RATING_FIELD) - ) - .execute().await().results.toRecipe() + return Recipe() } suspend fun getAllRecipes(): List { - return firestore - .pipeline() - .collection(RECIPES_COLLECTION) - .define( - documentId(field(NAME_FIELD_PATH)) - .alias(CURRENT_RECIPE_ID_VAR)) - .addFields( - PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) - .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) - .toScalarExpression().alias(AVERAGE_RATING_FIELD) - ) - .execute().await().results.toRecipeListItem() + return listOf() } suspend fun getPopularTags(): List { - val results = firestore.pipeline() - .collection(RECIPES_COLLECTION) - .unnest(field(TAGS_FIELD).alias(TAG_NAME_ALIAS)) - .aggregate( - AggregateStage.withAccumulators(countAll().alias(TAG_COUNT_ALIAS)) - .withGroups(TAG_NAME_ALIAS) - ) - .sort(field(TAG_COUNT_ALIAS).descending()) - .limit(10) - .execute().await().results - - return results.mapNotNull { it.getData()[TAG_NAME_ALIAS] as? String } + return listOf() } /* @@ -159,64 +123,7 @@ class DatabaseRemoteDataSource @Inject constructor( filterOptions: FilterOptions, userId: String ): List { - var pipeline = firestore.pipeline().collection(RECIPES_COLLECTION) - - if (filterOptions.searchQuery.isNotBlank()) { - val searchStage = SearchStage.withQuery(filterOptions.searchQuery) - .withAddFields(Expression.score().alias(SCORE_ALIAS)) - - pipeline = pipeline.search(searchStage).sort(field(SCORE_ALIAS).descending()) - } else if (filterOptions.recipeTitle.isNotBlank()) { - pipeline = pipeline - .where( - field(TITLE_FIELD).toLower() - .stringContains(filterOptions.recipeTitle.lowercase()) - ) - } - - if (filterOptions.filterByMine) { - pipeline = pipeline.where(field(AUTHOR_ID_FIELD).equal(userId)) - } - - pipeline = pipeline - .define( - documentId(field(NAME_FIELD_PATH)).alias(CURRENT_RECIPE_ID_VAR) - ).addFields( - PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) - .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) - .toScalarExpression().alias(AVERAGE_RATING_FIELD), - firestore.pipeline() - .collectionGroup(LIKES_COLLECTION) - .where(field(RECIPE_ID_FIELD) - .equal(variable(CURRENT_RECIPE_ID_VAR))) - .aggregate(countAll().alias(LIKES_COUNT_ALIAS)) - .toScalarExpression().alias(LIKES_FIELD) - ) - - if (filterOptions.rating > 0) { - pipeline = pipeline - .where(field(AVERAGE_RATING_FIELD).greaterThanOrEqual(filterOptions.rating)) - } - - if (filterOptions.selectedTags.isNotEmpty()) { - pipeline = pipeline - .where(field(TAGS_FIELD).arrayContainsAny(filterOptions.selectedTags)) - } - - when (filterOptions.sortBy) { - SortByFilter.DEFAULT -> {} - SortByFilter.RATING -> { - pipeline = pipeline.sort(field(AVERAGE_RATING_FIELD).descending()) - } - SortByFilter.ALPHABETICAL -> { - pipeline = pipeline.sort(field(TITLE_FIELD).ascending()) - } - SortByFilter.POPULARITY -> { - pipeline = pipeline.sort(field(LIKES_FIELD).descending()) - } - } - - return pipeline.execute().await().results.toRecipeListItem() + return listOf() } private fun List.toRecipe(): Recipe { diff --git a/codelab.md b/codelab.md deleted file mode 100644 index d5ab2ab..0000000 --- a/codelab.md +++ /dev/null @@ -1,578 +0,0 @@ -# Firestore Pipeline Queries on Android - - - -# Before you begin - -In this codelab, you'll learn how to use the powerful new Pipeline Queries available in Firestore Enterprise edition. You will build a recipe management and discovery app on Android called **Friendly Meals**. With this app, you will learn how to perform advanced filtering, case-insensitive searches, complex sorting and more. These complex searches and transformations were previously not possible or required manual filtering in Firestore Standard edition. - - - -## What you’ll learn - - - -* Standard **CRUD operations** in Firestore. -* Using **atomic batches** to maintain data consistency. -* Constructing **multi-stage Pipeline Queries** for advanced filtering. -* Implementing **sorting and ordering** when filtering data. -* Implementing **aggregations** when filtering data. -* Implementing **case-insensitive searches**. - - -## Prerequisites - - - -* Latest version of [Android Studio](https://developer.android.com/studio). -* An Android emulator with API 26 or higher. -* Basic knowledge of Kotlin and [Coroutines](https://kotlinlang.org/docs/coroutines-overview.html). - - - ---- - - - -# Create a Firebase project - -Duration: 3:00 - - - -1. Sign into the [Firebase console](https://console.firebase.google.com/) using your Google Account. -2. Click the **Create a new Firebase project **button, then enter a project name (for example, **FriendlyMeals**). - - ``` -Note: This project name is used as a display name in Firebase interfaces, and Firebase auto-creates a unique project ID based on this project name. Note that you can optionally click the Edit icon to set your preferred project ID, but you cannot change this ID after project creation. If you forget your ID, you can always find it later in the Project Settings. -``` - - -3. Click **Continue**. -4. If prompted, review and accept the Firebase terms, and then click **Continue**. -5. Follow the steps to finish setting up your project, then click **Create project**. -6. Wait for your project to provision, and then click **Continue**. - - - ---- - - - -# Enable Firebase services - -Duration: 3:00 - -In order to run all the steps in this codelab, you will need to enable a few services on the Firebase project you just created: [Firestore Enterprise edition](https://firebase.google.com/docs/firestore) (to store the recipes), [Authentication](https://firebase.google.com/docs/auth) (to sign the user in as a guest), and [Firebase AI Logic](https://firebase.google.com/docs/ai-logic) (since this app uses Gemini through Firebase AI Logic to generate recipes). At the end of this step, you will also need to register your Android app in this Firebase project. - - -## Enable Firestore Enterprise edition - - - -1. In the Firebase console, [open the Firestore Database section](https://console.firebase.google.com/project/_/firestore/?useAutoProject=true). -2. Click **Create database** and select **Enterprise** for the database mode. Click **Next**. -3. Select **Firestore in Native Mode** for the operation mode. -4. You can use the **default database ID**. If you choose a custom ID, make sure to copy it exactly as written. You’ll need it soon. -5. Select a location for your database and click **Next**. -6. Select **Start in Test Mode**. Then click **Create**. - - ``` -Warning: For real-world applications, you should always start Firestore in Production Mode. We are using Test Mode now to get started quickly, but make sure you understand how Security Rules work and implement them on your database before shipping your app to production. -``` - - - - -## Enable Authentication - - - -1. In the Firebase console, [open the Authentication section](https://console.firebase.google.com/project/_/authentication/?useAutoProject=true). -2. Click **Get started** and choose **Anonymous**. -3. Toggle the enable button and click **Save**. - - -## Enable Firebase AI Logic and register app - - - -1. In the Firebase console, [open the AI Logic section](https://console.firebase.google.com/project/_/ailogic/?useAutoProject=true). -2. Click **Get started**, then **Enable API**. - 1. If prompted to select an API, proceed with the **Gemini Developer API**. -3. When prompted to add an app, **click the Android icon**. -4. When prompted for a package name, use `com.google.firebase.example.friendlymeals` -5. Leave other fields blank, **click Register App** and follow the instructions to download the `google-services.json` file. Keep this file handy as you’re going to need it soon, then click **Next**. -6. You can skip the instructions to add the Firebase SDK, as the code you will download in the next step already includes the Firebase SDK. At the end, click **Continue to console**. - - - ---- - - - -# Set up the sample project - -Duration: 8:00 - - -## Download the code - -Run the following command to clone the sample code for this codelab from [GitHub](https://github.com/FirebaseExtended/FriendlyMeals-Android). This will create a folder called `FriendlyMeals-Android` on your machine: - - -``` -$ git clone https://github.com/FirebaseExtended/FriendlyMeals-Android.git -``` - - -Navigate to the project folder and checkout the codelab branch: - - -``` -$ cd FriendlyMeals-Android -$ git checkout codelab/firestore-enterprise-on-android -``` - - - -## Import the project - -Open Android Studio. **Click File > New > Import Project** and select the `FriendlyMeals-Android` folder. - -Move the `google-services.json` file you just downloaded into the **app/** folder. - - -## Run the app - -Now that you have added the `google-services.json` file, the project should compile and you can safely run the app on your Android emulator, or on a physical device. Since this is the first time you’re running this app on your machine, it may take a while to sync the Gradle files and build the app. - -Once the app is launched, you will automatically be signed in as a guest user through Firebase Anonymous Authentication, and you should land on the _New recipe screen_: - ->>>>>GDCALERT:inline image link here (to images/image2.png). Store image on your image server and adjust path/filename/extension if necessary.>>>>> -![alt_text](images/image2.png "image_tooltip") - - -You can use the tabs at the bottom to navigate to other screens. - - - ---- - - - -# Write data to Firestore - -Duration: 10:00 - -In this step you will write some data to Firestore so that you can populate the currently empty _Recipes screen_. Firestore Enterprise edition uses the same fundamental [data model](https://firebase.google.com/docs/firestore/data-model) as Firestore Standard edition:** Documents which are grouped into Collections and Subcollections. ** - - -## Initialize Firestore - -Before you can make any calls to Firestore, you need to create a Cloud Firestore instance. Open `data/injection/FirebaseHiltModule.kt` and make sure the `firestore()` function is initializing Firestore with the correct database ID. If you created the database with the default database ID, it should look like this: - - -``` -@Provides fun firestore(): FirebaseFirestore { - return FirebaseFirestore.getInstance("default") -} -``` - - -If you chose a different database ID, make sure you’re using that when calling `getInstance()`. Now you’re ready to make some calls to Firestore. - - -``` -Note: Friendly Meals uses Hilt to inject dependencies. To learn more, check out the Hilt documentation. -``` - - - -## Add Recipes to the database - -The main model object in this app is a `Recipe` (see `data/model/Recipe.kt`). You will store each recipe as a document in a top-level collection called `recipes`. Every time the user generates a new recipe using Gemini, and saves it, the app will create a new recipe document in this collection. - -Open `data/datasource/DatabaseRemoteDataSource.kt` and replace the `addRecipe` function with: - - -``` -suspend fun addRecipe(recipe: Recipe): String { - val recipeRef = firestore.collection(RECIPES_COLLECTION).add(recipe).await() - return recipeRef.id -} -``` - - -Here you’re starting by getting a reference to the `recipes` collection. Collections are created implicitly when documents are added, so there is no need to create the collection before writing data. Documents can be created using Kotlin data classes, which you’re using here to create each `recipe` doc. Lastly, you’re returning the `id` of the document so it can be passed from the _Generate screen_ to the _Recipe screen_ once the recipe is finished being generated and stored. - ---- - - - -# Update data in Firestore - -Duration: 10:00 - -In Firestore Standard edition, calculating the average rating of a recipe would typically require writing client-side transactions or cloud functions to re-calculate the average and persist that average back onto the recipe document on every review write. - -With **Firestore Enterprise edition**, this pre-calculation is entirely unnecessary! We can leverage **subcollection aggregation** to calculate the average rating of a recipe dynamically on-the-fly during read and query operations. - -As a result, we no longer need to persist a static average rating field or run client-side update logic when users write reviews. In the next section, you'll learn how to build a query pipeline that performs this subcollection aggregation dynamically. - - - - - ---- - - - -# Read data from Firestore - -Duration: 5:00 - -So far you have added and updated documents in your database. Now, let's retrieve these saved documents! - -In the same file, replace the `getAllRecipes` function with: - -```kotlin -suspend fun getAllRecipes(): List { - return firestore - .pipeline() - .collection(RECIPES_COLLECTION) - .define( - documentId(field(NAME_FIELD_PATH)).alias(CURRENT_RECIPE_ID_VAR) - ) - .addFields( - PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) - .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) - .toScalarExpression().alias(AVERAGE_RATING_FIELD) - ) - .execute().await().results.toRecipeListItem() -} -``` - - -This function retrieves all documents in the `recipe` collection. Notice the call to the `toRecipeListItem` extension (available at the bottom of the class). This extension maps each `PipelineResult` in the list to an object of the type `RecipeListItem`, which is a type that the application understands and uses in its business logic and to display data in the UI. - -Similarly, when fetching a single recipe, you can call the `toRecipe` function to convert a single document to a `Recipe`. In the same file, replace the `getRecipe` function with: - -```kotlin -suspend fun getRecipe(recipeId: String): Recipe { - val recipePath = "${RECIPES_COLLECTION}/${recipeId}" - - return firestore - .pipeline() - .documents(recipePath) - .define( - documentId(field(NAME_FIELD_PATH)).alias(CURRENT_RECIPE_ID_VAR) - ) - .addFields( - PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) - .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) - .toScalarExpression().alias(AVERAGE_RATING_FIELD) - ) - .execute().await().results.toRecipe() -} -``` - - -Once again you are constructing a pipeline, but using the `documents()` function instead of `collection()`, because you want to retrieve one recipe only. You can make sure the pipeline will fetch only this specific recipe by providing the path to the document (`recipePath`). - -The `getAllRecipes` and `getRecipe` functions are being called in the `RecipeListViewModel` and `RecipeViewModel` respectively. Once the view models receive the results, they assign these results to a `MutableStateFlow` object observed by the view layer. - -Once a new state is emitted via `MutableStateFlow`, the Jetpack Compose UI will collect this new state and recompose itself to display the changes to the user. You can check the code for these UIs in the `RecipeListScreen.kt` and `RecipeScreen.kt` files. - - - ---- - - - -# Test recipe creation and listing - -Duration: 8:00 - - -## Enable Cloud Storage - -All the recipe images generated by Gemini are stored in [Cloud Storage](https://firebase.google.com/docs/storage). In order to use Storage, you need to make sure that your Firebase project is on the [pay-as-you-go Blaze pricing plan](https://firebase.google.com/pricing). Don’t worry, you’re not going to be charged for anything at this stage! - -To upgrade to Blaze and enable Cloud Storage, follow these steps: - - - -1. Open the [Firebase console](https://console.firebase.google.com/project/_/?useAutoProject=true) and click **Upgrade** (bottom right corner). -2. Click **Select plan** and follow the steps to set up a billing account. -3. Finish the setup by **linking your project** with the selected billing account. -4. [Open the Storage section](https://console.firebase.google.com/project/_/storage/?useAutoProject=true). -5. Click **Get started**, select **No cost location** and click **Continue**. -6. Select **Start in Test Mode**. Then click **Create**. - 1. Once again: don’t forget that **for real-world applications, you should always start Firestore in Production Mode**! - - -## Time to run the app! - -To test recipe creation, run the app and type a list of ingredients into the first input field in the _New recipe screen_. If you're feeling fancy, you can add special notes or cuisines on the second input field. Click **Generate Recipe** and wait for Gemini to generate a recipe for you. \ - \ -Once the recipe is generated, the _Recipe screen _appears, where you can see the recipe image, ingredients, instructions and a few other details, like preparation time and number of servings. If you scroll down to the bottom of the recipe, you can add your rating to the recipe, which will trigger the `getAverageRatingForRecipe` function you wrote in a previous step. - -Scroll back to the top of the page and click the back arrow icon in the top left corner of the screen. It will take you back to the _Generate screen_, where you can generate as many recipes as you want. When you’re done generating recipes, navigate to the _Recipes screen_ using the navigation bar at the bottom, to see a beautiful list of all the recipes you generated: - ->>>>>GDCALERT:inline image link here (to images/image3.png). Store image on your image server and adjust path/filename/extension if necessary.>>>>> -![alt_text](images/image3.png "image_tooltip") ->>>>>GDCALERT:inline image link here (to images/image4.png). Store image on your image server and adjust path/filename/extension if necessary.>>>>> -![alt_text](images/image4.png "image_tooltip") - - - -``` -Note: As you start generating recipes and updating the rating, you can open the Firestore section in the Firebase console and see all the updates happening in real time! -``` - - - - ---- - - - -# Build a Pipeline Query - -Duration: 8:00 - -Hopefully, by now, you have created lots of delicious recipes while testing the app! Which means now you need to build a good _Filter screen_ to allow users to find the recipes they want quickly and efficiently. - -Go back to the `DatabaseRemoteDataSource.kt` file and locate the `getFilteredRecipes` function. You can see that this function receives two parameters: `filterOptions` and `userId`. You will use `filterOptions` to check which filters the user set up in the _Filter screen_. You will use `userId` to filter the recipes created by that user, should this filter be `true` in the `filterOptions `object. \ - \ -Now open the `ui/recipeList/filter/FilterOptions.kt` file. It should look like this: - - -```kotlin -data class FilterOptions( - val recipeTitle: String = "", - val searchQuery: String = "", - val filterByMine: Boolean = false, - val rating: Int = 0, - val selectedTags: List = listOf(), - val sortBy: SortByFilter = DEFAULT -) -``` - -The following steps will build on each other, all adding stages to a single pipeline query in the same function. - -Open `data/datasource/DatabaseRemoteDataSource.kt` and locate the `getFilteredRecipes` function. The starting state of the function in the codebase is a simple stub that initializes the pipeline on the `recipes` collection and returns all results without any filters: - -```kotlin -@Suppress("UnstableApiUsage") -suspend fun getFilteredRecipes( - filterOptions: FilterOptions, - userId: String -): List { - var pipeline = firestore.pipeline().collection(RECIPES_COLLECTION) - - // Implement this function in the next codelab steps. - - return pipeline.execute().await().results.toRecipeListItem() -} -``` - -Let’s implement these filters and the sorting criteria using Pipelines. - -## Text Search - -Before we implement standard filtering, let's explore the new Full-Text Search functionality. We'll use full-text search to find content in recipe descriptions. - -Inside the `getFilteredRecipes` function of `DatabaseRemoteDataSource.kt`, the text search is constructed using the `SearchStage.withQuery(...)` pipeline stage, which automatically ranks matching recipes by search score. Since average ratings and likes are dynamically calculated inline rather than stored statically on recipe documents, the query also defines dynamic pipeline aggregations. - -Add the following full-text search and dynamic aggregation stages directly under the pipeline initialization: - -```kotlin -if (filterOptions.searchQuery.isNotBlank()) { - val searchStage = SearchStage.withQuery(filterOptions.searchQuery) - .withAddFields(Expression.score().alias(SCORE_ALIAS)) - - pipeline = pipeline.search(searchStage).sort(field(SCORE_ALIAS).descending()) -} - -pipeline = pipeline - .define( - documentId(field(NAME_FIELD_PATH)).alias(CURRENT_RECIPE_ID_VAR) - ).addFields( - PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) - .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) - .toScalarExpression().alias(AVERAGE_RATING_FIELD), - firestore.pipeline() - .collectionGroup(LIKES_COLLECTION) - .where(field(RECIPE_ID_FIELD) - .equal(variable(CURRENT_RECIPE_ID_VAR))) - .aggregate(countAll().alias(LIKES_COUNT_ALIAS)) - .toScalarExpression().alias(LIKES_FIELD) - ) -``` - - -## Filter by title - -Users can search for text in the description and filter by title at the same time. To filter by recipe title, add another `if` block right below the previous one: - -```kotlin -if (filterOptions.recipeTitle.isNotBlank()) { - pipeline = pipeline - .where(field(TITLE_FIELD).toLower().stringContains(filterOptions.recipeTitle.lowercase())) -} -``` - - -First, you need to check if the user is using this filter by checking if `recipeTitle` is not blank. Next, you need to add a `where` clause and check if the recipe’s title contains the words typed by the user, which you can do with `stringContains`. Don’t forget to use `toLower()` when specifying the `field`, and make sure the words typed by the user are _also_ in lower case. This is necessary because pipeline searches are case sensitive, so you need to transform these values to lowercase before comparing them. - - -## Filter by author - -In the _Filter screen_, users can toggle the _Filter by mine_ option. To implement that, add another `if` statement right below the previous one: - - -``` -if (filterOptions.filterByMine) { - pipeline = pipeline - .where(field(AUTHOR_ID_FIELD).equal(userId)) -} -``` - - -First, check if `filterByMine` is true - which means the user wants to use this filter. Then, add a `where` clause and check if the recipe’s author has the same id as the currently authenticated user, which you can do with the `equal` function. Here’s where you use that `userId` parameter! - - -## Filter by rating - -Users can filter recipes based on a 1 to 5 stars average rating - here you can see how important it is to recalculate the average rating when someone submits a new review! To implement that, add another `if` statement below the previous one: - - -``` -if (filterOptions.rating > 0) { - pipeline = pipeline - .where(field(AVERAGE_RATING_FIELD).greaterThanOrEqual(filterOptions.rating)) -} -``` - - -Start by checking if the user wants to filter by rating, by checking if `filterOptions.rating` is greater than zero. Next, you need to add another `where` clause and check if the recipe’s average rating is greater than, or equal to the minimum rating selected by the user. - - -## Filter by tags - -Gemini generates a list of tags for each recipe (e.g.: _#dessert_, _#healthy_). Users can select a few of these tags in the _Filter screen _when searching for recipes. To implement that, add one last `if` statement to the function: - - -``` -if (filterOptions.selectedTags.isNotEmpty()) { - pipeline = pipeline - .where(field(TAGS_FIELD).arrayContainsAny(filterOptions.selectedTags)) -} -``` - - -First you need to check if the list of selected tags is not empty. Then, add another `where` clause and check if the recipe’s tags list contains any of these tags selected by the user. You can do that with the `arrayContainsAny` function. - - -## Sorting - -Users can also opt to sort recipes based on four different criteria: default (no sort), the average rating, the title (alphabetical order), or the popularity (how many users liked the recipe). To implement that, add the following code right below the last `if` statement: - -``` -when (filterOptions.sortBy) { - SortByFilter.DEFAULT -> {} - SortByFilter.RATING -> { - pipeline = pipeline.sort(field(AVERAGE_RATING_FIELD).descending()) - } - SortByFilter.ALPHABETICAL -> { - pipeline = pipeline.sort(field(TITLE_FIELD).ascending()) - } - SortByFilter.POPULARITY -> { - pipeline = pipeline.sort(field(LIKES_FIELD).descending()) - } -} - -return pipeline.execute().await().results.toRecipeListItem() -``` - - -Each of these sorting criteria will have a logic to it - whether it is `ascending` or `descending` will depend on the sorting type selected using the radio buttons in the _Filter screen_. - -At the end you need to execute the pipeline, transform the results in a list of `RecipeListItem`, and return the list so the UI can be updated to display only the recipes that match the criteria. - -## Suggest Tags - -In the Filter screen, the app can suggest popular category tags to the user. To support this, we need to write a pipeline query that unrolls the tags array on all recipes, groups and aggregates them to count occurrences, and sorts them to return the top 10 most popular tags. - -In `DatabaseRemoteDataSource.kt`, replace the `getPopularTags` function with: - -```kotlin -suspend fun getPopularTags(): List { - val results = firestore.pipeline() - .collection(RECIPES_COLLECTION) - .unnest(field(TAGS_FIELD).alias(TAG_NAME_ALIAS)) - .aggregate( - AggregateStage.withAccumulators(countAll().alias(TAG_COUNT_ALIAS)) - .withGroups(TAG_NAME_ALIAS) - ) - .sort(field(TAG_COUNT_ALIAS).descending()) - .limit(10) - .execute().await().results - - return results.mapNotNull { it.getData()[TAG_NAME_ALIAS] as? String } -} -``` - -First, this code is getting a reference to the `recipes` collection and using the `unnest` operator to flatten the tags array on each recipe. It then groups them by tag name and aggregates them using `countAll()`. Finally, it sorts by popularity in descending order, limits the results to 10, executes the pipeline, and extracts the tag names from the resulting data maps. - - -## Test queries in the Firebase console - -**You can test these queries straight in the Firebase console!** The Firestore section has a **Query Explorer** where you can run and test your queries in JavaScript. Although the implementation varies slightly from Kotlin, the logic is straightforward and user-friendly. The console also has a **Query Explainer** where you can see if your pipeline is using an index or performing a slow collection scan. - ->>>>>GDCALERT:inline image link here (to images/image5.png). Store image on your image server and adjust path/filename/extension if necessary.>>>>> -![alt_text](images/image5.png "image_tooltip") - - - - ---- - - - -# Test recipes filter - -Duration: 8:00 - -**Time to run the app again!** To test the filters you built in the previous step, navigate to the _Recipes screen_, then click the filter icon in the top right corner to navigate to the** _Filter screen_**. Once in this screen, you can play around with all the filters, then **click the Apply Filters button**. You will be taken back to the _Recipes screen_, but you should now only see the recipes that match the filters you selected: - ->>>>>GDCALERT:inline image link here (to images/image6.png). Store image on your image server and adjust path/filename/extension if necessary.>>>>> -![alt_text](images/image6.png "image_tooltip") - - -To modify the filters, or go back to the unfiltered list, click the filter icon again, then click **Reset**. - - - ---- - - - -# Conclusion - -Congratulations! **You've successfully implemented Firestore Enterprise edition in an Android app and learned the basics about Pipeline Queries**. In this codelab, you learned how to perform complex searches and transformations with Pipeline Queries and convert it to an object that is recognizable in your Kotlin code, so you can show it in the UI. - -**But there’s much, much more you can do with pipelines!** Firestore Enterprise edition also handles indexing and performance differently than the Standard edition. If you’re just starting out with Firestore or planning a migration from Standard edition, reviewing these **best practices** is essential. **They will help you optimize performance and ensure cost-efficiency, protecting you from unexpected billing spikes.** - -To learn about Pipeline operations, indexes, pricing and performance, take a look at these resources: - - - -* [Pipeline operations documentation](https://firebase.google.com/docs/firestore/enterprise/pipelines-overview) -* [Quick start documentation](https://firebase.google.com/docs/firestore/enterprise/quickstart) -* [Optimize query execution](https://firebase.google.com/docs/firestore/enterprise/optimize-query-performance) -* [Pricing documentation](https://firebase.google.com/docs/firestore/enterprise/pricing) - -**Happy coding!**