diff --git a/firebase-ai-friendly-meals/flutter/android/app/build.gradle.kts b/firebase-ai-friendly-meals/flutter/android/app/build.gradle.kts
index 36b46fe0..931ca920 100644
--- a/firebase-ai-friendly-meals/flutter/android/app/build.gradle.kts
+++ b/firebase-ai-friendly-meals/flutter/android/app/build.gradle.kts
@@ -9,8 +9,9 @@ plugins {
}
android {
- namespace = "com.example.myapp"
+ namespace = "com.google.firebase.example.friendlymeals"
compileSdk = flutter.compileSdkVersion
+ ndkVersion = "28.2.13676358"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
@@ -22,11 +23,10 @@ android {
}
defaultConfig {
- // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
- applicationId = "com.example.myapp"
+ applicationId = "com.google.firebase.example.friendlymeals"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
- minSdk = 23
+ minSdk = 26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
diff --git a/firebase-ai-friendly-meals/flutter/android/app/src/main/AndroidManifest.xml b/firebase-ai-friendly-meals/flutter/android/app/src/main/AndroidManifest.xml
index de8dcecb..ae175960 100644
--- a/firebase-ai-friendly-meals/flutter/android/app/src/main/AndroidManifest.xml
+++ b/firebase-ai-friendly-meals/flutter/android/app/src/main/AndroidManifest.xml
@@ -1,6 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/example/myapp/MainActivity.kt b/firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/google/firebase/example/friendlymeals/MainActivity.kt
similarity index 65%
rename from firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/example/myapp/MainActivity.kt
rename to firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/google/firebase/example/friendlymeals/MainActivity.kt
index 6aec4e99..abb92c4d 100644
--- a/firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/example/myapp/MainActivity.kt
+++ b/firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/google/firebase/example/friendlymeals/MainActivity.kt
@@ -1,4 +1,4 @@
-package com.example.myapp
+package com.google.firebase.example.friendlymeals
import io.flutter.embedding.android.FlutterActivity
diff --git a/firebase-ai-friendly-meals/flutter/android/gradle.properties b/firebase-ai-friendly-meals/flutter/android/gradle.properties
index d2572314..1152c863 100644
--- a/firebase-ai-friendly-meals/flutter/android/gradle.properties
+++ b/firebase-ai-friendly-meals/flutter/android/gradle.properties
@@ -1,5 +1,8 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
-gradle.user.home=/home/user/.gradle
org.gradle.caching=true
+# This builtInKotlin flag was added automatically by Flutter migrator
+android.builtInKotlin=false
+# This newDsl flag was added automatically by Flutter migrator
+android.newDsl=false
diff --git a/firebase-ai-friendly-meals/flutter/android/gradle/wrapper/gradle-wrapper.properties b/firebase-ai-friendly-meals/flutter/android/gradle/wrapper/gradle-wrapper.properties
index ac3b4792..02767eb1 100644
--- a/firebase-ai-friendly-meals/flutter/android/gradle/wrapper/gradle-wrapper.properties
+++ b/firebase-ai-friendly-meals/flutter/android/gradle/wrapper/gradle-wrapper.properties
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
diff --git a/firebase-ai-friendly-meals/flutter/android/settings.gradle.kts b/firebase-ai-friendly-meals/flutter/android/settings.gradle.kts
index bd7522f7..ea334df4 100644
--- a/firebase-ai-friendly-meals/flutter/android/settings.gradle.kts
+++ b/firebase-ai-friendly-meals/flutter/android/settings.gradle.kts
@@ -18,11 +18,11 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
- id("com.android.application") version "8.7.3" apply false
+ id("com.android.application") version "8.11.1" apply false
// START: FlutterFire Configuration
id("com.google.gms.google-services") version("4.3.15") apply false
// END: FlutterFire Configuration
- id("org.jetbrains.kotlin.android") version "2.1.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
diff --git a/firebase-ai-friendly-meals/flutter/assets/images/ic_launcher_no_bg.png b/firebase-ai-friendly-meals/flutter/assets/images/ic_launcher_no_bg.png
new file mode 100644
index 00000000..7f3569cc
Binary files /dev/null and b/firebase-ai-friendly-meals/flutter/assets/images/ic_launcher_no_bg.png differ
diff --git a/firebase-ai-friendly-meals/flutter/lib/data/app_repositories.dart b/firebase-ai-friendly-meals/flutter/lib/data/app_repositories.dart
new file mode 100644
index 00000000..53a0f2b7
--- /dev/null
+++ b/firebase-ai-friendly-meals/flutter/lib/data/app_repositories.dart
@@ -0,0 +1,48 @@
+import 'datasource/ai_remote_datasource.dart';
+import 'datasource/auth_remote_datasource.dart';
+import 'datasource/database_remote_datasource.dart';
+import 'datasource/live_ai_remote_datasource.dart';
+import 'datasource/remote_config_service.dart';
+import 'datasource/storage_remote_datasource.dart';
+import 'repository/ai_repository.dart';
+import 'repository/auth_repository.dart';
+import 'repository/database_repository.dart';
+import 'repository/live_ai_repository.dart';
+import 'repository/storage_repository.dart';
+
+class AppRepositories {
+ AppRepositories({
+ required this.remoteConfigService,
+ required this.authRepository,
+ required this.databaseRepository,
+ required this.storageRepository,
+ required this.aiRepository,
+ required this.liveAiRepository,
+ });
+
+ factory AppRepositories.create(RemoteConfigService remoteConfigService) {
+ final authDataSource = AuthRemoteDataSource();
+ final databaseDataSource = DatabaseRemoteDataSource();
+ final storageDataSource = StorageRemoteDataSource();
+ final aiDataSource = AIRemoteDataSource(remoteConfig: remoteConfigService);
+ final liveAiDataSource = LiveAIRemoteDataSource(
+ remoteConfig: remoteConfigService,
+ );
+
+ return AppRepositories(
+ remoteConfigService: remoteConfigService,
+ authRepository: AuthRepository(authDataSource),
+ databaseRepository: DatabaseRepository(databaseDataSource),
+ storageRepository: StorageRepository(storageDataSource),
+ aiRepository: AIRepository(aiDataSource),
+ liveAiRepository: LiveAIRepository(liveAiDataSource),
+ );
+ }
+
+ final RemoteConfigService remoteConfigService;
+ final AuthRepository authRepository;
+ final DatabaseRepository databaseRepository;
+ final StorageRepository storageRepository;
+ final AIRepository aiRepository;
+ final LiveAIRepository liveAiRepository;
+}
diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/ai_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/ai_remote_datasource.dart
new file mode 100644
index 00000000..7dcbdc85
--- /dev/null
+++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/ai_remote_datasource.dart
@@ -0,0 +1,229 @@
+import 'dart:convert';
+
+import 'package:firebase_ai/firebase_ai.dart';
+import 'package:firebase_performance/firebase_performance.dart';
+import 'package:flutter/foundation.dart';
+
+import '../schema/meal_schema.dart';
+import '../schema/recipe_schema.dart';
+import '../schema/store_schema.dart';
+import 'remote_config_service.dart';
+
+class AIRemoteDataSource {
+ AIRemoteDataSource({
+ FirebaseAI? aiModel,
+ required RemoteConfigService remoteConfig,
+ }) : _aiModel = aiModel ?? FirebaseAI.googleAI(),
+ _remoteConfig = remoteConfig;
+
+ final FirebaseAI _aiModel;
+ final RemoteConfigService _remoteConfig;
+
+ // Remote Config Keys
+ static const String generateRecipeKey = 'generate_recipe';
+ static const String generateRecipePhotoGeminiKey =
+ 'generate_recipe_photo_gemini';
+ static const String scanMealKey = 'scan_meal';
+ static const String findStoresKey = 'find_stores';
+ static const String hybridCloudModelKey = 'hybrid_cloud_model';
+ static const String hybridIngredientsPromptKey = 'hybrid_ingredients_prompt';
+ static const String recipePairingKey = 'recipe_pairing';
+ static const String voiceProfileKey = 'recipe_pairing_voice_profile';
+ static const String ttsModelKey = 'tts_model';
+
+ // Template input fields
+ static const String imageDataField = 'imageData';
+ static const String mimeTypeField = 'mimeType';
+ static const String ingredientsField = 'ingredients';
+ static const String notesField = 'notes';
+ static const String recipeTitleField = 'recipeTitle';
+ static const String dayField = 'dayOfWeek';
+ static const String timeField = 'currentTime';
+
+ // Template input values
+ static const String mimeTypeValue = 'image/jpeg';
+
+ // Grounding with Maps config
+ static const String language = 'en_US';
+
+ // TTS config
+ static const String ttsVoice = 'Charon';
+ static const String ttsLanguage = 'en-US';
+
+ GenerativeModel get _hybridGenerativeModel => _aiModel.generativeModel(
+ model: _remoteConfig.getString(hybridCloudModelKey),
+ );
+
+ // ignore: experimental_member_use
+ TemplateGenerativeModel get _templateGenerativeModel =>
+ // ignore: experimental_member_use
+ _aiModel.templateGenerativeModel();
+
+ Future> findStores({
+ required List ingredients,
+ required double latitude,
+ required double longitude,
+ required String currentTime,
+ required String dayOfWeek,
+ }) async {
+ try {
+ // ignore: experimental_member_use
+ final response = await _templateGenerativeModel.generateContent(
+ _remoteConfig.getString(findStoresKey),
+ inputs: {
+ ingredientsField: ingredients.join(', '),
+ dayField: dayOfWeek,
+ timeField: currentTime,
+ },
+ toolConfig: TemplateToolConfig(
+ retrievalConfig: RetrievalConfig(
+ latLng: LatLng(latitude: latitude, longitude: longitude),
+ languageCode: language,
+ ),
+ ),
+ );
+
+ final decoded = _extractJsonObject(response.text);
+ if (decoded == null) {
+ return const [];
+ }
+ return StoreFinderResult.fromJson(decoded).stores;
+ } catch (e) {
+ debugPrint('Error finding stores with these ingredients: $e');
+ return const [];
+ }
+ }
+
+ Future generateIngredients(Uint8List imageBytes) async {
+ final trace = FirebasePerformance.instance.newTrace('hybrid-inference');
+ await trace.start();
+ try {
+ final prompt = Content.multi([
+ InlineDataPart(mimeTypeValue, imageBytes),
+ TextPart(_remoteConfig.getString(hybridIngredientsPromptKey)),
+ ]);
+
+ final response = await _hybridGenerativeModel.generateContent([prompt]);
+ trace.putAttribute('inferenceSource', 'In cloud');
+ return response.text ?? '';
+ } finally {
+ await trace.stop();
+ }
+ }
+
+ Future generateRecipe(
+ String ingredients,
+ String notes,
+ ) async {
+ final inputs = {
+ ingredientsField: ingredients,
+ if (notes.trim().isNotEmpty) notesField: notes,
+ };
+
+ // ignore: experimental_member_use
+ final response = await _templateGenerativeModel.generateContent(
+ _remoteConfig.getString(generateRecipeKey),
+ inputs: inputs,
+ );
+
+ final decoded = _extractJsonObject(response.text);
+ if (decoded == null) {
+ return null;
+ }
+ return RecipeSchema.fromJson(decoded);
+ }
+
+ Future generateRecipePhoto(String recipeTitle) async {
+ // ignore: experimental_member_use
+ final response = await _templateGenerativeModel.generateContent(
+ _remoteConfig.getString(generateRecipePhotoGeminiKey),
+ inputs: {recipeTitleField: recipeTitle},
+ );
+
+ final parts = response.candidates.firstOrNull?.content.parts ?? const [];
+ for (final part in parts) {
+ if (part is InlineDataPart) {
+ return part.bytes;
+ }
+ }
+ return null;
+ }
+
+ Future scanMeal(String imageData) async {
+ // ignore: experimental_member_use
+ final response = await _templateGenerativeModel.generateContent(
+ _remoteConfig.getString(scanMealKey),
+ inputs: {
+ mimeTypeField: mimeTypeValue,
+ imageDataField: imageData,
+ },
+ );
+
+ final decoded = _extractJsonObject(response.text);
+ if (decoded == null) {
+ return null;
+ }
+ return MealSchema.fromJson(decoded);
+ }
+
+ Map? _extractJsonObject(String? rawText) {
+ if (rawText == null || rawText.trim().isEmpty) {
+ return null;
+ }
+ final start = rawText.indexOf('{');
+ final end = rawText.lastIndexOf('}');
+ if (start == -1 || end == -1 || end < start) {
+ return null;
+ }
+ final cleanJson = rawText.substring(start, end + 1);
+ return jsonDecode(cleanJson) as Map;
+ }
+
+ Future craftRecipePairing(
+ String dishTitle,
+ List ingredients,
+ ) async {
+ // ignore: experimental_member_use
+ final response = await _templateGenerativeModel.generateContent(
+ _remoteConfig.getString(recipePairingKey),
+ inputs: {
+ recipeTitleField: dishTitle,
+ ingredientsField: ingredients.join(', '),
+ },
+ );
+
+ return (response.text ?? '').trim();
+ }
+
+ Future generateSpeech(String? text) async {
+ if (text == null || text.trim().isEmpty) return null;
+
+ final model = _aiModel.generativeModel(
+ model: _remoteConfig.getString(ttsModelKey),
+ generationConfig: GenerationConfig(
+ responseModalities: [ResponseModalities.audio],
+ speechConfig: SpeechConfig(
+ voiceName: ttsVoice,
+ languageCode: ttsLanguage,
+ ),
+ ),
+ );
+
+ final voiceProfile = _remoteConfig.getString(voiceProfileKey);
+ final response = await model.generateContent([
+ Content.text('$voiceProfile $text'),
+ ]);
+
+ final part =
+ response.candidates.firstOrNull?.content.parts.firstOrNull;
+ if (part is InlineDataPart) {
+ return part.bytes;
+ }
+ return null;
+ }
+
+ Future loadOnDeviceModel() async {
+ // On-device model preloading via Gemini Nano is handled by cloud fallback in Flutter.
+ debugPrint('AIRemoteDataSource: Cloud inference configured for hybrid model');
+ }
+}
diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/auth_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/auth_remote_datasource.dart
new file mode 100644
index 00000000..1f119c3a
--- /dev/null
+++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/auth_remote_datasource.dart
@@ -0,0 +1,18 @@
+import 'package:firebase_auth/firebase_auth.dart' as fb_auth;
+import '../model/user.dart';
+
+class AuthRemoteDataSource {
+ AuthRemoteDataSource({fb_auth.FirebaseAuth? auth})
+ : _auth = auth ?? fb_auth.FirebaseAuth.instance;
+
+ final fb_auth.FirebaseAuth _auth;
+
+ fb_auth.User? get currentUser => _auth.currentUser;
+
+ Stream get authStateChanges => _auth.authStateChanges();
+
+ Future createAnonymousAccount() async {
+ final authResult = await _auth.signInAnonymously();
+ return AppUser(authId: authResult.user?.uid ?? '');
+ }
+}
diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/database_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/database_remote_datasource.dart
new file mode 100644
index 00000000..27734953
--- /dev/null
+++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/database_remote_datasource.dart
@@ -0,0 +1,447 @@
+import 'package:cloud_firestore/cloud_firestore.dart';
+import 'package:firebase_core/firebase_core.dart';
+import 'package:flutter/foundation.dart';
+
+import '../../ui/recipe_list/filter_options.dart';
+import '../model/grocery_item.dart';
+import '../model/like.dart';
+import '../model/recipe.dart';
+import '../model/review.dart';
+import '../model/user.dart';
+
+class DatabaseRemoteDataSource {
+ DatabaseRemoteDataSource({FirebaseFirestore? firestore})
+ : _firestore = firestore ??
+ FirebaseFirestore.instanceFor(
+ app: Firebase.app(),
+ databaseId: 'default',
+ );
+
+ final FirebaseFirestore _firestore;
+
+ // Collections
+ static const String usersCollection = 'users';
+ static const String recipesCollection = 'recipes';
+ static const String likesCollection = 'likes';
+ static const String reviewsSubcollection = 'reviews';
+ static const String groceriesCollection = 'groceries';
+
+ // Fields
+ static const String ratingField = 'rating';
+ static const String averageRatingField = 'averageRating';
+ static const String authorIdField = 'authorId';
+ static const String titleField = 'title';
+ static const String tagsField = 'tags';
+ static const String likesField = 'likes';
+ static const String imageUriField = 'imageUri';
+ static const String prepTimeField = 'prepTime';
+ static const String cookTimeField = 'cookTime';
+ static const String servingsField = 'servings';
+ static const String instructionsField = 'instructions';
+ static const String ingredientsField = 'ingredients';
+ static const String recipeIdField = 'recipeId';
+ static const String userIdField = 'userId';
+ static const String checkedField = 'checked';
+
+ // Field aliases
+ static const String avgRatingAlias = 'avg_rating';
+ static const String tagNameAlias = 'tagName';
+ static const String tagCountAlias = 'tagCount';
+ static const String likesCountAlias = 'likesCount';
+ static const String scoreAlias = 'score';
+
+ // Field paths
+ static const String nameFieldPath = '__name__';
+
+ Future addUser(AppUser user) async {
+ await _firestore.collection(usersCollection).add(user.toMap());
+ }
+
+ Future addRecipe(Recipe recipe) async {
+ final recipeRef = await _firestore
+ .collection(recipesCollection)
+ .add(recipe.toFirestore());
+ return recipeRef.id;
+ }
+
+ Future getRecipe(String recipeId) async {
+ if (recipeId.isEmpty) {
+ return const Recipe();
+ }
+
+ final resultsFuture = _firestore
+ .pipeline()
+ .collection(recipesCollection)
+ .where(
+ Expression.field(nameFieldPath).documentId().equalValue(recipeId),
+ )
+ .execute();
+ final avgRatingFuture = _firestore
+ .pipeline()
+ .collection('$recipesCollection/$recipeId/$reviewsSubcollection')
+ .aggregate(Expression.field(ratingField).average().as(avgRatingAlias))
+ .execute();
+
+ final docSnapshot = await resultsFuture;
+ final docResults = docSnapshot.result;
+
+ if (docResults.isEmpty) {
+ return const Recipe();
+ }
+
+ final itemData = Map.from(docResults.first.data() ?? {});
+ try {
+ final ratingSnapshot = await avgRatingFuture;
+ final ratingResults = ratingSnapshot.result;
+ if (ratingResults.isNotEmpty) {
+ final ratingData = ratingResults.first.data();
+ final computedAvg = (ratingData?[avgRatingAlias] as num?)?.toDouble();
+ if (computedAvg != null) {
+ itemData[averageRatingField] = computedAvg;
+ }
+ }
+ } catch (e) {
+ debugPrint('Error aggregating recipe rating: $e');
+ }
+
+ return _mapToRecipe(itemData);
+ }
+
+ Future> getAllRecipes() async {
+ final recipesFuture =
+ _firestore.pipeline().collection(recipesCollection).execute();
+ final ratingsFuture = _getAverageRatingsByRecipeId();
+
+ final results = await Future.wait([recipesFuture, ratingsFuture]);
+ final pipelineResults = (results[0] as PipelineSnapshot).result;
+ final ratingsMap = results[1] as Map;
+
+ return _toRecipeListItems(pipelineResults, ratingsMap: ratingsMap);
+ }
+
+ Future> getPopularTags() async {
+ final snapshot = await _firestore
+ .pipeline()
+ .collection(recipesCollection)
+ .unnest(Expression.field(tagsField).as(tagNameAlias))
+ .aggregateWithOptions(
+ AggregateStageOptions(
+ accumulators: [CountAll().as(tagCountAlias)],
+ groups: [Expression.field(tagNameAlias)],
+ ),
+ )
+ .sort(Expression.field(tagCountAlias).descending())
+ .limit(10)
+ .execute();
+
+ return snapshot.result
+ .map((r) => r.data()?[tagNameAlias])
+ .whereType()
+ .where((tag) => tag.isNotEmpty)
+ .toList();
+ }
+
+ /// Enforces a "one review per user" constraint using deterministic document ID "${recipeId}_${userId}".
+ Future setReview(Review review) async {
+ final reviewRef = _firestore
+ .collection(recipesCollection)
+ .doc(review.recipeId)
+ .collection(reviewsSubcollection)
+ .doc('${review.recipeId}_${review.userId}');
+
+ await reviewRef.set(review.toMap());
+ }
+
+ Future getRating(String userId, String recipeId) async {
+ if (userId.isEmpty || recipeId.isEmpty) return 0;
+ final reviewId = '${recipeId}_$userId';
+
+ final snapshot = await _firestore
+ .pipeline()
+ .collection('$recipesCollection/$recipeId/$reviewsSubcollection')
+ .where(
+ Expression.field(nameFieldPath).documentId().equalValue(reviewId),
+ )
+ .execute();
+ final results = snapshot.result;
+ if (results.isEmpty) return 0;
+
+ final reviewData = results.first.data();
+ if (reviewData == null) return 0;
+ return (reviewData[ratingField] as num?)?.toInt() ?? 0;
+ }
+
+ /// Enforces a "one favorite per user" constraint using deterministic document ID "${recipeId}_${userId}".
+ Future setFavorite(Like like) async {
+ final likeRef = _firestore
+ .collection(likesCollection)
+ .doc('${like.recipeId}_${like.userId}');
+
+ await likeRef.set(like.toMap());
+ }
+
+ Future removeFavorite(Like like) async {
+ await _firestore
+ .collection(likesCollection)
+ .doc('${like.recipeId}_${like.userId}')
+ .delete();
+ }
+
+ Future getFavorite(String userId, String recipeId) async {
+ if (userId.isEmpty || recipeId.isEmpty) return false;
+ final favoriteId = '${recipeId}_$userId';
+
+ final snapshot = await _firestore
+ .pipeline()
+ .collection(likesCollection)
+ .where(
+ Expression.field(nameFieldPath).documentId().equalValue(favoriteId),
+ )
+ .execute();
+ return snapshot.result.any((r) => r.data() != null);
+ }
+
+ Future> getFilteredRecipes(
+ FilterOptions filterOptions,
+ String userId,
+ ) async {
+ var pipeline = _firestore.pipeline().collection(recipesCollection);
+
+ if (filterOptions.searchQuery.trim().isNotEmpty) {
+ final searchStage = SearchStage.withQuery(filterOptions.searchQuery);
+ pipeline = pipeline.search(searchStage);
+ } else if (filterOptions.recipeTitle.trim().isNotEmpty) {
+ pipeline = pipeline.where(
+ Expression.field(titleField)
+ .toLowerCase()
+ .stringIndexOf(filterOptions.recipeTitle.toLowerCase())
+ .greaterThanOrEqualValue(0),
+ );
+ }
+
+ if (filterOptions.filterByMine) {
+ pipeline = pipeline.where(
+ Expression.field(authorIdField).equalValue(userId),
+ );
+ }
+
+ if (filterOptions.selectedTags.isNotEmpty) {
+ pipeline = pipeline.where(
+ Expression.field(tagsField).arrayContainsAny(filterOptions.selectedTags),
+ );
+ }
+
+ if (filterOptions.sortBy == SortByFilter.alphabetical) {
+ pipeline = pipeline.sort(Expression.field(titleField).ascending());
+ }
+
+ final recipesFuture = pipeline.execute();
+ final ratingsFuture = _getAverageRatingsByRecipeId();
+ final likesFuture = filterOptions.sortBy == SortByFilter.popularity
+ ? _getLikesCountByRecipeId()
+ : Future.value({});
+
+ final results = await Future.wait([
+ recipesFuture,
+ ratingsFuture,
+ likesFuture,
+ ]);
+
+ final pipelineResults = (results[0] as PipelineSnapshot).result;
+ final ratingsMap = results[1] as Map;
+ final likesMap = results[2] as Map;
+
+ var items = _toRecipeListItems(pipelineResults, ratingsMap: ratingsMap);
+
+ if (filterOptions.rating > 0) {
+ items = items
+ .where((item) => item.averageRating >= filterOptions.rating)
+ .toList();
+ }
+
+ switch (filterOptions.sortBy) {
+ case SortByFilter.defaultSort:
+ case SortByFilter.alphabetical:
+ break;
+ case SortByFilter.rating:
+ items.sort((a, b) => b.averageRating.compareTo(a.averageRating));
+ break;
+ case SortByFilter.popularity:
+ items.sort(
+ (a, b) => (likesMap[b.id] ?? 0).compareTo(likesMap[a.id] ?? 0),
+ );
+ break;
+ }
+
+ return items;
+ }
+
+ Future