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..74d3846 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,18 +4,22 @@ 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.PipelineSource import com.google.firebase.firestore.pipeline.AggregateFunction +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.equal 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 @@ -40,6 +44,16 @@ class DatabaseRemoteDataSource @Inject constructor( return firestore .pipeline() .documents(recipePath) + .define(field("id").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().collection(LIKES_COLLECTION) + .where(equal(RECIPE_ID_FIELD, variable(CURRENT_RECIPE_ID_VAR))) + .aggregate(countAll().alias(COUNT_ALIAS)) + .toScalarExpression().alias(LIKES_FIELD) + ) .execute().await().results.toRecipe() } @@ -47,52 +61,28 @@ 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(field("id").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,9 +103,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 { @@ -163,12 +150,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 +158,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 { @@ -201,7 +176,13 @@ class DatabaseRemoteDataSource @Inject constructor( ): 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() @@ -230,6 +211,12 @@ class DatabaseRemoteDataSource @Inject constructor( when (filterOptions.sortBy) { SortByFilter.RATING -> { pipeline = pipeline + .define(field("id").alias(CURRENT_RECIPE_ID_VAR)) + .addFields( + PipelineSource.subcollection(REVIEWS_SUBCOLLECTION) + .aggregate(average(RATING_FIELD).alias(AVG_RATING_ALIAS)) + .toScalarExpression().alias(AVERAGE_RATING_FIELD) + ) .sort(field(AVERAGE_RATING_FIELD) .descending()) } @@ -258,7 +245,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 +266,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 +276,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 +292,16 @@ 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 COUNT_ALIAS = "count" + private const val SCORE_ALIAS = "score" + + //Variables + private const val CURRENT_RECIPE_ID_VAR = "current_recipe_id" } } \ 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..81a6c1b 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 @@ -4,6 +4,7 @@ import com.google.firebase.example.friendlymeals.ui.recipeList.filter.SortByFilt data class FilterOptions( val recipeTitle: String = "", + val searchQuery: String = "", val filterByMine: Boolean = false, val rating: Int = 0, val selectedTags: List = listOf(), 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..68c5698 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) } } ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2847816..5619b8b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,8 @@ Filter Recipes Recipe Title e.g. Arrabbiata sauce + Search + Search recipes… My recipes only Rating %d Stars 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"