diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a07fc2a..04a0a2a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,21 +1,21 @@ 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) alias(libs.plugins.google.hilt) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.firebase.perf) } 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" @@ -35,9 +35,6 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { - jvmTarget = "11" - } buildFeatures { compose = true } @@ -57,16 +54,22 @@ 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) 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/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 b9e06cb..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 @@ -1,117 +1,79 @@ 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.GenerativeModel -import com.google.firebase.ai.ImagenModel +import com.google.firebase.ai.InferenceMode +import com.google.firebase.ai.InferenceSource +import com.google.firebase.ai.OnDeviceConfig +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.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.PublicPreviewAPI 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.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 firebaseAI: FirebaseAI + aiModel: FirebaseAI, + 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) + ) - private val generativeModel: GenerativeModel get() = - firebaseAI.generativeModel( - modelName = "gemini-2.5-flash-image", - generationConfig = generationConfig { - responseModalities = listOf(ResponseModality.TEXT, ResponseModality.IMAGE) - } - ) - - private val imagenModel: ImagenModel get() = - firebaseAI.imagenModel( - modelName = "imagen-4.0-fast-generate-001", - generationConfig = imagenGenerationConfig { - numberOfImages = 1 - aspectRatio = ImagenAspectRatio.SQUARE_1x1 - imageFormat = ImagenImageFormat.png() - }, - safetySettings = ImagenSafetySettings( - safetyFilterLevel = ImagenSafetyFilterLevel.BLOCK_LOW_AND_ABOVE, - personFilterLevel = ImagenPersonFilterLevel.BLOCK_ALL - ) - ) + private val templateGenerativeModel = aiModel.templateGenerativeModel() - private val mealSchemaModel: GenerativeModel get() = - firebaseAI.generativeModel( - modelName = "gemini-2.5-flash", - 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()) - ) - ) + 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)) } - ) - private val recipeSchemaModel: GenerativeModel get() = - firebaseAI.generativeModel( - modelName = "gemini-2.5-flash", - 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()) - ) - ) - } - ) + val response = hybridGenerativeModel.generateContent(prompt) - 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.") - } + // 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" + } + ) - val response = generativeModel.generateContent(prompt) - return response.text.orEmpty() + 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 = templateGenerativeModel.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) @@ -119,41 +81,79 @@ 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 = templateGenerativeModel.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 imageResponse = imagenModel.generateImages(prompt) - return imageResponse.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 = templateGenerativeModel.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) } } + + suspend fun loadOnDeviceModel() { + when (hybridGenerativeModel.onDeviceExtension?.checkStatus()) { + UNAVAILABLE -> { + Log.i(TAG, "On-device model is unavailable") + } + DOWNLOADABLE -> { + hybridGenerativeModel.onDeviceExtension?.download()?.collect { status -> + when (status) { + is DownloadStarted -> + Log.i(TAG, "Starting download - ${status.bytesToDownload}") + + is DownloadInProgress -> + Log.i(TAG, "Download in progress ${status.totalBytesDownloaded} bytes downloaded") + + is DownloadCompleted -> + Log.i(TAG, "On-device model download complete") + + is DownloadFailed -> + Log.e(TAG, "Download failed $status") + } + } + } + DOWNLOADING -> { + Log.i(TAG, "On-device model is being downloaded") + } + AVAILABLE -> { + Log.i(TAG, "On-device model is available") + } + } + } + + companion object { + //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 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" + 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" + + //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/datasource/DatabaseRemoteDataSource.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/datasource/DatabaseRemoteDataSource.kt index 21472e3..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 @@ -1,27 +1,34 @@ 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 -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 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 import kotlin.collections.mapNotNull -@Suppress("UnstableApiUsage") class DatabaseRemoteDataSource @Inject constructor( private val firestore: FirebaseFirestore ) { @@ -41,31 +48,8 @@ class DatabaseRemoteDataSource @Inject constructor( return listOf() } - suspend fun addTags(tagNames: List) { - - } - - suspend fun getPopularTags(): List { - val results = firestore.pipeline() - .collection(TAGS_COLLECTION) - .sort(field(TOTAL_RECIPES_FIELD).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 - ) - } + suspend fun getPopularTags(): List { + return listOf() } /* @@ -86,13 +70,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 { - return 0.0 } suspend fun getRating(userId: String, recipeId: String): Int { @@ -107,7 +84,6 @@ class DatabaseRemoteDataSource @Inject constructor( if (results.isEmpty()) return 0 val reviewData = results.first().getData() - return (reviewData[RATING_FIELD] as? Number)?.toInt() ?: 0 } @@ -122,12 +98,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) { @@ -136,12 +106,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 { @@ -154,6 +118,7 @@ class DatabaseRemoteDataSource @Inject constructor( .execute().await().results.isNotEmpty() } + @Suppress("UnstableApiUsage") suspend fun getFilteredRecipes( filterOptions: FilterOptions, userId: String @@ -171,7 +136,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 ?: "", @@ -193,24 +157,72 @@ 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 ) } } + 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 TAGS_COLLECTION = "tags" 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" - 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" @@ -222,8 +234,21 @@ 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" + private const val USER_ID_FIELD = "userId" + private const val CHECKED_FIELD = "checked" //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/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 7c4bcce..a0fdbf4 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 @@ -21,7 +21,8 @@ object FirebaseHiltModule { @Provides fun auth(): FirebaseAuth = Firebase.auth - @Provides fun firebaseAI(): FirebaseAI { + @Provides + fun firebaseAI(): FirebaseAI { return Firebase.ai(backend = GenerativeBackend.googleAI()) } 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/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/AIRepository.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/data/repository/AIRepository.kt index 73a2eae..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,11 +21,11 @@ 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) } - suspend fun scanMeal(image: Bitmap): MealSchema? { - return aiRemoteDataSource.scanMeal(image) + suspend fun loadOnDeviceModel() { + aiRemoteDataSource.loadOnDeviceModel() } } \ 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..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 @@ -4,10 +4,11 @@ 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 +import com.google.firebase.example.friendlymeals.data.model.GroceryItem +import kotlinx.coroutines.flow.Flow import javax.inject.Inject class DatabaseRepository @Inject constructor( @@ -29,11 +30,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() } @@ -63,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/generate/GenerateViewModel.kt b/app/src/main/java/com/google/firebase/example/friendlymeals/ui/generate/GenerateViewModel.kt index 5dc87b6..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 @@ -27,6 +27,7 @@ class GenerateViewModel @Inject constructor( init { loadCurrentUser() + loadOnDeviceModel() } fun loadCurrentUser() { @@ -38,6 +39,12 @@ class GenerateViewModel @Inject constructor( } } + fun loadOnDeviceModel() { + launchCatching { + aiRepository.loadOnDeviceModel() + } + } + fun onIngredientsUpdated(ingredients: String) { _viewState.value = _viewState.value.copy( ingredients = ingredients @@ -100,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/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/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..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 @@ -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 = { @@ -120,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) ) } } @@ -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/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/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 2847816..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 @@ -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 @@ -50,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 7120688..91c3b0f 100644 --- a/app/src/main/res/xml/remote_config_defaults.xml +++ b/app/src/main/res/xml/remote_config_defaults.xml @@ -1,14 +1,42 @@ - - schema_model_name - gemini-2.5-flash - - - model_name - gemini-2.5-flash-image - - - imagen_name - imagen-4.0-fast-generate-001 - + + hybrid_cloud_model + gemini-3.1-flash-lite + + + 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 + generate-recipe-photo-gemini-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 7db73c4..89f8551 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,9 @@ // 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 alias(libs.plugins.google.ksp) apply false + alias(libs.plugins.firebase.perf) apply false } \ No newline at end of file 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 b115e5a..7a3479a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,36 +1,42 @@ [versions] -agp = "8.13.1" +agp = "9.2.1" coilCompose = "2.7.0" exifinterface = "1.4.2" -firebaseBom = "34.8.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.10-2.0.2" -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" } 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" } @@ -53,13 +59,17 @@ 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" } 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" } 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 {