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> _getAverageRatingsByRecipeId() async { + try { + final snapshot = await _firestore + .pipeline() + .collectionGroup(reviewsSubcollection) + .aggregateWithOptions( + AggregateStageOptions( + accumulators: [ + Expression.field(ratingField).average().as(avgRatingAlias), + ], + groups: [ + Expression.field(recipeIdField), + ], + ), + ) + .execute(); + + final map = {}; + for (final r in snapshot.result) { + final data = r.data(); + if (data == null) continue; + final recipeId = data[recipeIdField] as String?; + final avgRating = (data[avgRatingAlias] as num?)?.toDouble(); + if (recipeId != null && avgRating != null) { + map[recipeId] = avgRating; + } + } + return map; + } catch (e) { + debugPrint('Error aggregating ratings across recipes: $e'); + return {}; + } + } + + Future> _getLikesCountByRecipeId() async { + try { + final snapshot = await _firestore + .pipeline() + .collectionGroup(likesCollection) + .aggregateWithOptions( + AggregateStageOptions( + accumulators: [CountAll().as(likesCountAlias)], + groups: [Expression.field(recipeIdField)], + ), + ) + .execute(); + + final map = {}; + for (final r in snapshot.result) { + final data = r.data(); + if (data == null) continue; + final recipeId = data[recipeIdField] as String?; + final count = (data[likesCountAlias] as num?)?.toInt(); + if (recipeId != null && count != null) { + map[recipeId] = count; + } + } + return map; + } catch (e) { + debugPrint('Error aggregating likes across recipes: $e'); + return {}; + } + } + + Recipe _mapToRecipe(Map itemData) { + return Recipe( + title: itemData[titleField] as String? ?? '', + instructions: itemData[instructionsField] as String? ?? '', + ingredients: (itemData[ingredientsField] as List?) + ?.whereType() + .toList() ?? + const [], + authorId: itemData[authorIdField] as String? ?? '', + tags: (itemData[tagsField] as List?) + ?.whereType() + .toList() ?? + const [], + averageRating: (itemData[averageRatingField] as num?)?.toDouble() ?? 0.0, + prepTime: itemData[prepTimeField] as String? ?? '', + cookTime: itemData[cookTimeField] as String? ?? '', + servings: itemData[servingsField] as String? ?? '', + imageUri: itemData[imageUriField] as String?, + ); + } + + List _toRecipeListItems( + List results, { + required Map ratingsMap, + }) { + final list = []; + for (final result in results) { + final id = result.document?.id; + final itemData = result.data(); + if (id == null || id.isEmpty || itemData == null) { + debugPrint('DatabaseRemoteDataSource: Empty recipe ID or data'); + continue; + } + + final avgRating = ratingsMap[id] ?? + (itemData[averageRatingField] as num?)?.toDouble() ?? + 0.0; + + list.add( + RecipeListItem( + id: id, + title: itemData[titleField] as String? ?? '', + averageRating: avgRating, + imageUri: itemData[imageUriField] as String?, + ), + ); + } + return list; + } + + Stream> getGroceriesStream(String userId) { + if (userId.isEmpty) { + return Stream.value(const []); + } + + return _firestore + .collection(groceriesCollection) + .where(userIdField, isEqualTo: userId) + .snapshots() + .map((snapshot) { + return snapshot.docs.map((doc) { + return GroceryItem.fromMap(doc.data(), docId: doc.id); + }).toList(); + }); + } + + Future addGroceryItem(GroceryItem item) async { + final docRef = _firestore.collection(groceriesCollection).doc(); + final itemWithId = item.copyWith(id: docRef.id); + await docRef.set(itemWithId.toMap()); + } + + Future updateGroceryItemChecked(String itemId, bool checked) async { + await _firestore + .collection(groceriesCollection) + .doc(itemId) + .update({checkedField: checked}); + } + + Future deleteGroceryItem(String itemId) async { + await _firestore.collection(groceriesCollection).doc(itemId).delete(); + } + + Future addIngredientsToGroceries( + String userId, + List ingredients, + ) async { + if (userId.isEmpty || ingredients.isEmpty) return; + + final batch = _firestore.batch(); + final collection = _firestore.collection(groceriesCollection); + + for (final ingredient in ingredients) { + final docRef = collection.doc(); + final item = GroceryItem( + id: docRef.id, + userId: userId, + name: ingredient, + checked: false, + ); + batch.set(docRef, item.toMap()); + } + await batch.commit(); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/live_ai_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/live_ai_remote_datasource.dart new file mode 100644 index 00000000..c99e16a2 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/live_ai_remote_datasource.dart @@ -0,0 +1,77 @@ +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/foundation.dart'; + +import '../model/recipe.dart'; +import 'remote_config_service.dart'; + +class LiveAIRemoteDataSource { + LiveAIRemoteDataSource({ + FirebaseAI? aiModel, + required RemoteConfigService remoteConfig, + }) : _aiModel = aiModel ?? FirebaseAI.googleAI(), + _remoteConfig = remoteConfig; + + final FirebaseAI _aiModel; + final RemoteConfigService _remoteConfig; + + // Live Model Config + static const String liveModelVoice = 'CHARON'; + + // Tools config + static const String addIngredientsToolName = 'addIngredientToGroceryList'; + static const String addIngredientsToolDescription = + "Adds a specified ingredient to the user's grocery list in the database."; + static const String ingredientFieldName = 'ingredient'; + static const String ingredientFieldDescription = + 'The name of the ingredient to add.'; + + // Remote Config Keys + static const String liveModelNameKey = 'live_model_name'; + static const String liveModelPromptKey = 'live_model_prompt'; + + static final Tool _groceryListTool = Tool.functionDeclarations([ + FunctionDeclaration( + addIngredientsToolName, + addIngredientsToolDescription, + parameters: { + ingredientFieldName: Schema.string( + description: ingredientFieldDescription, + ), + }, + ), + ]); + + Future setupLiveSession(Recipe recipe) async { + final liveGenerationConfig = LiveGenerationConfig( + speechConfig: SpeechConfig(voiceName: liveModelVoice), + responseModalities: [ResponseModalities.audio], + ); + + final promptTemplate = _remoteConfig.getString(liveModelPromptKey); + final instructionText = _formatInstructionPrompt(promptTemplate, recipe); + + final liveModel = _aiModel.liveGenerativeModel( + model: _remoteConfig.getString(liveModelNameKey), + liveGenerationConfig: liveGenerationConfig, + systemInstruction: Content.text(instructionText), + tools: [_groceryListTool], + ); + + try { + return await liveModel.connect(); + } catch (e) { + debugPrint('Failed to connect LiveSession: $e'); + return null; + } + } + + String _formatInstructionPrompt(String template, Recipe recipe) { + return template + .replaceAll('{{title}}', recipe.title) + .replaceAll('{{prepTime}}', recipe.prepTime) + .replaceAll('{{cookTime}}', recipe.cookTime) + .replaceAll('{{servings}}', recipe.servings) + .replaceAll('{{ingredients}}', recipe.ingredients.join('\n')) + .replaceAll('{{instructions}}', recipe.instructions); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/remote_config_service.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/remote_config_service.dart new file mode 100644 index 00000000..c2a651cd --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/remote_config_service.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:firebase_remote_config/firebase_remote_config.dart'; +import 'package:flutter/foundation.dart'; + +class RemoteConfigService { + RemoteConfigService({FirebaseRemoteConfig? remoteConfig}) + : _remoteConfig = remoteConfig ?? FirebaseRemoteConfig.instance; + + final FirebaseRemoteConfig _remoteConfig; + StreamSubscription? _configUpdateSub; + + static const Map defaultParameters = { + 'find_stores': 'find-stores-template-v1-0-0', + 'recipe_pairing': 'recipe-pairing-v1-0-0', + 'recipe_pairing_voice_profile': + "[Audio Profile: A French chef with a charming French accent][Scene: An elegant Parisian restaurant during a masterclass on food and wine pairing][Director's Notes: Speak as if you're a French chef who is an expert in pairing dishes with wines. Speak with warm enthusiasm and culinary sophistication]", + 'tts_model': 'gemini-3.1-flash-tts-preview', + '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.''', + }; + + Future initialize() async { + try { + await _remoteConfig.setConfigSettings( + RemoteConfigSettings( + fetchTimeout: const Duration(minutes: 1), + minimumFetchInterval: const Duration(seconds: 1800), + ), + ); + await _remoteConfig.setDefaults(defaultParameters); + await _remoteConfig.fetchAndActivate(); + + if (!kIsWeb) { + _configUpdateSub = _remoteConfig.onConfigUpdated.listen( + (_) async { + try { + await _remoteConfig.activate(); + } catch (e) { + debugPrint('Error activating updated Remote Config: $e'); + } + }, + onError: (Object error) { + debugPrint('Remote Config real-time listener error: $error'); + }, + ); + } + } catch (e) { + debugPrint('Remote Config initialization error: $e'); + } + } + + String getString(String key) { + final value = _remoteConfig.getString(key); + if (value.isNotEmpty) { + return value; + } + return defaultParameters[key]?.toString() ?? ''; + } + + void dispose() { + _configUpdateSub?.cancel(); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/storage_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/storage_remote_datasource.dart new file mode 100644 index 00000000..9a3c79ab --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/storage_remote_datasource.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; +import 'package:uuid/uuid.dart'; + +class StorageRemoteDataSource { + StorageRemoteDataSource({Reference? storageRef}) + : _storageRef = storageRef ?? FirebaseStorage.instance.ref(); + + static const int _maxImageLength = 800; + final Reference _storageRef; + static const Uuid _uuid = Uuid(); + + Future addImage(Uint8List imageBytes) async { + final processedBytes = await compute(_scaleAndCompressImage, imageBytes); + final randomId = _uuid.v4(); + final imagesRef = _storageRef.child('images/$randomId.jpg'); + await imagesRef.putData( + processedBytes, + SettableMetadata(contentType: 'image/jpeg'), + ); + return imagesRef.getDownloadURL(); + } + + static Uint8List _scaleAndCompressImage(Uint8List rawBytes) { + final decoded = img.decodeImage(rawBytes); + if (decoded == null) { + return rawBytes; + } + + final lastWidth = decoded.width; + final lastHeight = decoded.height; + final int outWidth; + final int outHeight; + + if (lastWidth > lastHeight) { + outWidth = _maxImageLength; + outHeight = (lastHeight.toDouble() / lastWidth * _maxImageLength).toInt(); + } else { + outHeight = _maxImageLength; + outWidth = (lastWidth.toDouble() / lastHeight * _maxImageLength).toInt(); + } + + final scaled = img.copyResize( + decoded, + width: outWidth, + height: outHeight, + interpolation: img.Interpolation.linear, + ); + return Uint8List.fromList(img.encodeJpg(scaled, quality: 70)); + } +} + +/// Converts raw image bytes to a JPEG-compressed Base64 string (quality 80), +/// matching Android's `Bitmap.toBase64()`. +Future imageBytesToBase64(Uint8List imageBytes) { + return compute(_encodeJpegBase64, imageBytes); +} + +String _encodeJpegBase64(Uint8List rawBytes) { + final decoded = img.decodeImage(rawBytes); + if (decoded == null) { + return base64Encode(rawBytes); + } + final jpegBytes = img.encodeJpg(decoded, quality: 80); + return base64Encode(jpegBytes); +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/model/grocery_item.dart b/firebase-ai-friendly-meals/flutter/lib/data/model/grocery_item.dart new file mode 100644 index 00000000..dbefe228 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/model/grocery_item.dart @@ -0,0 +1,45 @@ +class GroceryItem { + final String id; + final String userId; + final String name; + final bool checked; + + const GroceryItem({ + this.id = '', + this.userId = '', + this.name = '', + this.checked = false, + }); + + GroceryItem copyWith({ + String? id, + String? userId, + String? name, + bool? checked, + }) { + return GroceryItem( + id: id ?? this.id, + userId: userId ?? this.userId, + name: name ?? this.name, + checked: checked ?? this.checked, + ); + } + + Map toMap() { + return { + 'id': id, + 'userId': userId, + 'name': name, + 'checked': checked, + }; + } + + factory GroceryItem.fromMap(Map map, {String? docId}) { + return GroceryItem( + id: docId ?? (map['id'] as String? ?? ''), + userId: map['userId'] as String? ?? '', + name: map['name'] as String? ?? '', + checked: map['checked'] as bool? ?? false, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/model/like.dart b/firebase-ai-friendly-meals/flutter/lib/data/model/like.dart new file mode 100644 index 00000000..112754c0 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/model/like.dart @@ -0,0 +1,23 @@ +class Like { + final String recipeId; + final String userId; + + const Like({ + this.recipeId = '', + this.userId = '', + }); + + Map toMap() { + return { + 'recipeId': recipeId, + 'userId': userId, + }; + } + + factory Like.fromMap(Map map) { + return Like( + recipeId: map['recipeId'] as String? ?? '', + userId: map['userId'] as String? ?? '', + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/model/recipe.dart b/firebase-ai-friendly-meals/flutter/lib/data/model/recipe.dart new file mode 100644 index 00000000..5375b921 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/model/recipe.dart @@ -0,0 +1,99 @@ +class Recipe { + final String title; + final String instructions; + final List ingredients; + final String authorId; + final List tags; + final double averageRating; + final String prepTime; + final String cookTime; + final String servings; + final String? imageUri; + + const Recipe({ + this.title = '', + this.instructions = '', + this.ingredients = const [], + this.authorId = '', + this.tags = const [], + this.averageRating = 0.0, + this.prepTime = '', + this.cookTime = '', + this.servings = '', + this.imageUri, + }); + + /// Matches @get:Exclude on averageRating in FriendlyMeals-Android Recipe.kt + Map toFirestore() { + return { + 'title': title, + 'instructions': instructions, + 'ingredients': ingredients, + 'authorId': authorId, + 'tags': tags, + 'prepTime': prepTime, + 'cookTime': cookTime, + 'servings': servings, + 'imageUri': imageUri, + }; + } + + factory Recipe.fromMap(Map data, {double averageRating = 0.0}) { + return Recipe( + title: data['title'] as String? ?? '', + instructions: data['instructions'] as String? ?? '', + ingredients: (data['ingredients'] as List?) + ?.whereType() + .toList() ?? + const [], + authorId: data['authorId'] as String? ?? '', + tags: (data['tags'] as List?)?.whereType().toList() ?? + const [], + averageRating: (data['averageRating'] as num?)?.toDouble() ?? averageRating, + prepTime: data['prepTime'] as String? ?? '', + cookTime: data['cookTime'] as String? ?? '', + servings: data['servings'] as String? ?? '', + imageUri: data['imageUri'] as String?, + ); + } + + Recipe copyWith({ + String? title, + String? instructions, + List? ingredients, + String? authorId, + List? tags, + double? averageRating, + String? prepTime, + String? cookTime, + String? servings, + String? imageUri, + }) { + return Recipe( + title: title ?? this.title, + instructions: instructions ?? this.instructions, + ingredients: ingredients ?? this.ingredients, + authorId: authorId ?? this.authorId, + tags: tags ?? this.tags, + averageRating: averageRating ?? this.averageRating, + prepTime: prepTime ?? this.prepTime, + cookTime: cookTime ?? this.cookTime, + servings: servings ?? this.servings, + imageUri: imageUri ?? this.imageUri, + ); + } +} + +class RecipeListItem { + final String id; + final String title; + final double averageRating; + final String? imageUri; + + const RecipeListItem({ + this.id = '', + this.title = '', + this.averageRating = 0.0, + this.imageUri, + }); +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/model/review.dart b/firebase-ai-friendly-meals/flutter/lib/data/model/review.dart new file mode 100644 index 00000000..154fa71b --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/model/review.dart @@ -0,0 +1,27 @@ +class Review { + final String userId; + final String recipeId; + final int rating; + + const Review({ + this.userId = '', + this.recipeId = '', + this.rating = 0, + }); + + Map toMap() { + return { + 'userId': userId, + 'recipeId': recipeId, + 'rating': rating, + }; + } + + factory Review.fromMap(Map map) { + return Review( + userId: map['userId'] as String? ?? '', + recipeId: map['recipeId'] as String? ?? '', + rating: (map['rating'] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/model/user.dart b/firebase-ai-friendly-meals/flutter/lib/data/model/user.dart new file mode 100644 index 00000000..8da3fec5 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/model/user.dart @@ -0,0 +1,13 @@ +class AppUser { + final String authId; + + const AppUser({this.authId = ''}); + + Map toMap() { + return {'authId': authId}; + } + + factory AppUser.fromMap(Map map) { + return AppUser(authId: map['authId'] as String? ?? ''); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/repository/ai_repository.dart b/firebase-ai-friendly-meals/flutter/lib/data/repository/ai_repository.dart new file mode 100644 index 00000000..07cacf59 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/repository/ai_repository.dart @@ -0,0 +1,59 @@ +import 'dart:typed_data'; + +import '../datasource/ai_remote_datasource.dart'; +import '../schema/meal_schema.dart'; +import '../schema/recipe_schema.dart'; +import '../schema/store_schema.dart'; + +class AIRepository { + AIRepository(this._aiRemoteDataSource); + + final AIRemoteDataSource _aiRemoteDataSource; + + Future generateIngredients(Uint8List imageBytes) { + return _aiRemoteDataSource.generateIngredients(imageBytes); + } + + Future> findStores({ + required List ingredients, + required double latitude, + required double longitude, + required String currentTime, + required String dayOfWeek, + }) { + return _aiRemoteDataSource.findStores( + ingredients: ingredients, + latitude: latitude, + longitude: longitude, + currentTime: currentTime, + dayOfWeek: dayOfWeek, + ); + } + + Future generateRecipe(String ingredients, String notes) { + return _aiRemoteDataSource.generateRecipe(ingredients, notes); + } + + Future generateRecipePhoto(String recipeTitle) { + return _aiRemoteDataSource.generateRecipePhoto(recipeTitle); + } + + Future scanMeal(String imageData) { + return _aiRemoteDataSource.scanMeal(imageData); + } + + Future craftRecipePairing( + String dishTitle, + List ingredients, + ) { + return _aiRemoteDataSource.craftRecipePairing(dishTitle, ingredients); + } + + Future generateSpeech(String? text) { + return _aiRemoteDataSource.generateSpeech(text); + } + + Future loadOnDeviceModel() { + return _aiRemoteDataSource.loadOnDeviceModel(); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/repository/auth_repository.dart b/firebase-ai-friendly-meals/flutter/lib/data/repository/auth_repository.dart new file mode 100644 index 00000000..335e8fca --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/repository/auth_repository.dart @@ -0,0 +1,18 @@ +import 'package:firebase_auth/firebase_auth.dart' as fb_auth; +import '../datasource/auth_remote_datasource.dart'; +import '../model/user.dart'; + +class AuthRepository { + AuthRepository(this._authRemoteDataSource); + + final AuthRemoteDataSource _authRemoteDataSource; + + fb_auth.User? get currentUser => _authRemoteDataSource.currentUser; + + Stream get authStateChanges => + _authRemoteDataSource.authStateChanges; + + Future createAnonymousAccount() { + return _authRemoteDataSource.createAnonymousAccount(); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/repository/database_repository.dart b/firebase-ai-friendly-meals/flutter/lib/data/repository/database_repository.dart new file mode 100644 index 00000000..c96b87d7 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/repository/database_repository.dart @@ -0,0 +1,86 @@ +import '../../ui/recipe_list/filter_options.dart'; +import '../datasource/database_remote_datasource.dart'; +import '../model/grocery_item.dart'; +import '../model/like.dart'; +import '../model/recipe.dart'; +import '../model/review.dart'; +import '../model/user.dart'; + +class DatabaseRepository { + DatabaseRepository(this._databaseRemoteDataSource); + + final DatabaseRemoteDataSource _databaseRemoteDataSource; + + Future addUser(AppUser user) { + return _databaseRemoteDataSource.addUser(user); + } + + Future addRecipe(Recipe recipe) { + return _databaseRemoteDataSource.addRecipe(recipe); + } + + Future getRecipe(String recipeId) { + return _databaseRemoteDataSource.getRecipe(recipeId); + } + + Future> getAllRecipes() { + return _databaseRemoteDataSource.getAllRecipes(); + } + + Future> getPopularTags() { + return _databaseRemoteDataSource.getPopularTags(); + } + + Future setReview(Review review) { + return _databaseRemoteDataSource.setReview(review); + } + + Future getRating(String userId, String recipeId) { + return _databaseRemoteDataSource.getRating(userId, recipeId); + } + + Future setFavorite(Like like) { + return _databaseRemoteDataSource.setFavorite(like); + } + + Future removeFavorite(Like like) { + return _databaseRemoteDataSource.removeFavorite(like); + } + + Future getFavorite(String userId, String recipeId) { + return _databaseRemoteDataSource.getFavorite(userId, recipeId); + } + + Future> getFilteredRecipes( + FilterOptions filterOptions, + String userId, + ) { + return _databaseRemoteDataSource.getFilteredRecipes(filterOptions, userId); + } + + Stream> getGroceriesStream(String userId) { + return _databaseRemoteDataSource.getGroceriesStream(userId); + } + + Future addGroceryItem(GroceryItem item) { + return _databaseRemoteDataSource.addGroceryItem(item); + } + + Future updateGroceryItemChecked(String itemId, bool checked) { + return _databaseRemoteDataSource.updateGroceryItemChecked(itemId, checked); + } + + Future deleteGroceryItem(String itemId) { + return _databaseRemoteDataSource.deleteGroceryItem(itemId); + } + + Future addIngredientsToGroceries( + String userId, + List ingredients, + ) { + return _databaseRemoteDataSource.addIngredientsToGroceries( + userId, + ingredients, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/repository/live_ai_repository.dart b/firebase-ai-friendly-meals/flutter/lib/data/repository/live_ai_repository.dart new file mode 100644 index 00000000..74075fe5 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/repository/live_ai_repository.dart @@ -0,0 +1,14 @@ +import 'package:firebase_ai/firebase_ai.dart'; + +import '../datasource/live_ai_remote_datasource.dart'; +import '../model/recipe.dart'; + +class LiveAIRepository { + LiveAIRepository(this._liveAiRemoteDataSource); + + final LiveAIRemoteDataSource _liveAiRemoteDataSource; + + Future setupLiveSession(Recipe recipe) { + return _liveAiRemoteDataSource.setupLiveSession(recipe); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/repository/storage_repository.dart b/firebase-ai-friendly-meals/flutter/lib/data/repository/storage_repository.dart new file mode 100644 index 00000000..cf909c14 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/repository/storage_repository.dart @@ -0,0 +1,12 @@ +import 'dart:typed_data'; +import '../datasource/storage_remote_datasource.dart'; + +class StorageRepository { + StorageRepository(this._storageRemoteDataSource); + + final StorageRemoteDataSource _storageRemoteDataSource; + + Future addImage(Uint8List imageBytes) { + return _storageRemoteDataSource.addImage(imageBytes); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/schema/meal_schema.dart b/firebase-ai-friendly-meals/flutter/lib/data/schema/meal_schema.dart new file mode 100644 index 00000000..f0c94331 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/schema/meal_schema.dart @@ -0,0 +1,28 @@ +class MealSchema { + final String protein; + final String fat; + final String carbs; + final String sugar; + final List ingredients; + + const MealSchema({ + this.protein = '0g', + this.fat = '0g', + this.carbs = '0g', + this.sugar = '0g', + this.ingredients = const [], + }); + + factory MealSchema.fromJson(Map json) { + return MealSchema( + protein: json['protein'] as String? ?? '0g', + fat: json['fat'] as String? ?? '0g', + carbs: json['carbs'] as String? ?? '0g', + sugar: json['sugar'] as String? ?? '0g', + ingredients: (json['ingredients'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/schema/recipe_schema.dart b/firebase-ai-friendly-meals/flutter/lib/data/schema/recipe_schema.dart new file mode 100644 index 00000000..8bde88a3 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/schema/recipe_schema.dart @@ -0,0 +1,53 @@ +import '../model/recipe.dart'; + +class RecipeSchema { + final String title; + final String instructions; + final List ingredients; + final String prepTime; + final String cookTime; + final String servings; + final List tags; + + const RecipeSchema({ + required this.title, + required this.instructions, + required this.ingredients, + required this.prepTime, + required this.cookTime, + required this.servings, + required this.tags, + }); + + factory RecipeSchema.fromJson(Map json) { + return RecipeSchema( + title: json['title'] as String? ?? '', + instructions: json['instructions'] as String? ?? '', + ingredients: (json['ingredients'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + prepTime: json['prepTime'] as String? ?? '', + cookTime: json['cookTime'] as String? ?? '', + servings: json['servings'] as String? ?? '', + tags: (json['tags'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + ); + } + + Recipe toRecipe({required String authorId, String? imageUri}) { + return Recipe( + title: title, + instructions: instructions, + ingredients: ingredients, + authorId: authorId, + tags: tags, + prepTime: prepTime, + cookTime: cookTime, + servings: servings, + imageUri: imageUri, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/schema/store_schema.dart b/firebase-ai-friendly-meals/flutter/lib/data/schema/store_schema.dart new file mode 100644 index 00000000..8307017a --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/schema/store_schema.dart @@ -0,0 +1,50 @@ +class StoreSchema { + final String name; + final String address; + final String distance; + final bool openNow; + final bool closingSoon; + final bool hasParking; + final String parkingDetails; + final String mapUrl; + + const StoreSchema({ + this.name = '', + this.address = '', + this.distance = '', + this.openNow = false, + this.closingSoon = false, + this.hasParking = false, + this.parkingDetails = '', + this.mapUrl = '', + }); + + factory StoreSchema.fromJson(Map json) { + return StoreSchema( + name: json['name'] as String? ?? '', + address: json['address'] as String? ?? '', + distance: json['distance'] as String? ?? '', + openNow: json['openNow'] as bool? ?? false, + closingSoon: json['closingSoon'] as bool? ?? false, + hasParking: json['hasParking'] as bool? ?? false, + parkingDetails: json['parkingDetails'] as String? ?? '', + mapUrl: json['mapUrl'] as String? ?? '', + ); + } +} + +class StoreFinderResult { + final List stores; + + const StoreFinderResult({this.stores = const []}); + + factory StoreFinderResult.fromJson(Map json) { + return StoreFinderResult( + stores: (json['stores'] as List?) + ?.whereType>() + .map(StoreSchema.fromJson) + .toList() ?? + const [], + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/main.dart b/firebase-ai-friendly-meals/flutter/lib/main.dart index aa71254e..b7fe4294 100644 --- a/firebase-ai-friendly-meals/flutter/lib/main.dart +++ b/firebase-ai-friendly-meals/flutter/lib/main.dart @@ -1,26 +1,196 @@ +import 'package:firebase_app_check/firebase_app_check.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:myapp/firebase_options.dart'; -import 'package:myapp/recipe_generator_screen.dart'; -void main() async { +import 'data/app_repositories.dart'; +import 'data/datasource/remote_config_service.dart'; +import 'firebase_options.dart'; +import 'ui/generate/generate_screen.dart'; +import 'ui/grocery_list/grocery_list_screen.dart'; +import 'ui/live/live_assistant_screen.dart'; +import 'ui/recipe/recipe_screen.dart'; +import 'ui/recipe_list/filter_screen.dart'; +import 'ui/recipe_list/recipe_list_controller.dart'; +import 'ui/recipe_list/recipe_list_screen.dart'; +import 'ui/scan_meal/scan_meal_screen.dart'; +import 'ui/shared/bottom_nav_bar.dart'; +import 'ui/theme/app_theme.dart'; + +Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - runApp(const MyApp()); + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + + await _initializeAppCheck(); + + final remoteConfigService = RemoteConfigService(); + await remoteConfigService.initialize(); + + final repositories = AppRepositories.create(remoteConfigService); + + if (repositories.authRepository.currentUser == null) { + try { + final user = await repositories.authRepository.createAnonymousAccount(); + await repositories.databaseRepository.addUser(user); + } catch (e) { + debugPrint('Error creating anonymous guest user on startup: $e'); + } + } + + runApp(FriendlyMealsApp(repositories: repositories)); +} + +Future _initializeAppCheck() async { + try { + await FirebaseAppCheck.instance.activate( + providerAndroid: kDebugMode + ? const AndroidDebugProvider() + : const AndroidPlayIntegrityProvider(), + providerApple: kDebugMode + ? const AppleDebugProvider() + : const AppleDeviceCheckProvider(), + ); + } catch (e) { + debugPrint('Error activating Firebase App Check: $e'); + } +} + +class FriendlyMealsApp extends StatefulWidget { + const FriendlyMealsApp({ + super.key, + required this.repositories, + }); + + final AppRepositories repositories; + + @override + State createState() => _FriendlyMealsAppState(); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class _FriendlyMealsAppState extends State { + final GlobalKey _scaffoldMessengerKey = + GlobalKey(); + final GlobalKey _navigatorKey = GlobalKey(); + + BottomNavTab _selectedTab = BottomNavTab.generate; + late final RecipeListController _recipeListController; + + @override + void initState() { + super.initState(); + _recipeListController = RecipeListController(widget.repositories); + } + + @override + void dispose() { + _recipeListController.dispose(); + widget.repositories.remoteConfigService.dispose(); + super.dispose(); + } + + void _showError([String message = 'Sorry, something went wrong :(']) { + _scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar(content: Text(message)), + ); + } + + void _onTabSelected(BottomNavTab tab) { + _navigatorKey.currentState?.popUntil((route) => route.isFirst); + setState(() { + _selectedTab = tab; + }); + } + + void _openRecipeScreen(String recipeId) { + _navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => RecipeScreen( + key: ValueKey('recipe_$recipeId'), + recipeId: recipeId, + repositories: widget.repositories, + navigateBack: () => _navigatorKey.currentState?.maybePop(), + navigateToLiveAssistant: _openLiveAssistantScreen, + ), + ), + ); + } + + void _openFilterScreen() { + _navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => FilterScreen( + controller: _recipeListController, + navigateBack: () => _navigatorKey.currentState?.maybePop(), + ), + ), + ); + } + + void _openLiveAssistantScreen(String recipeId) { + _navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => LiveAssistantScreen( + key: ValueKey('live_$recipeId'), + recipeId: recipeId, + repositories: widget.repositories, + navigateBack: () => _navigatorKey.currentState?.maybePop(), + showError: () => _showError('Error loading camera'), + ), + ), + ); + } @override Widget build(BuildContext context) { return MaterialApp( title: 'Friendly Meals', - theme: ThemeData( - primaryColor: const Color(0xFFFFC400), - colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFFFC400)), + debugShowCheckedModeBanner: false, + scaffoldMessengerKey: _scaffoldMessengerKey, + navigatorKey: _navigatorKey, + theme: FriendlyMealsTheme.lightTheme, + home: PopScope( + canPop: _selectedTab == BottomNavTab.generate, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (_selectedTab != BottomNavTab.generate) { + setState(() { + _selectedTab = BottomNavTab.generate; + }); + } + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + bottomNavigationBar: BottomNavBar( + selectedTab: _selectedTab, + onTabSelected: _onTabSelected, + ), + body: IndexedStack( + index: _selectedTab.index, + children: [ + ScanMealScreen( + repositories: widget.repositories, + showError: _showError, + ), + GenerateScreen( + repositories: widget.repositories, + openRecipeScreen: _openRecipeScreen, + showError: _showError, + ), + RecipeListScreen( + controller: _recipeListController, + openRecipeScreen: _openRecipeScreen, + openFilterScreen: _openFilterScreen, + ), + GroceryListScreen( + repositories: widget.repositories, + showError: _showError, + ), + ], + ), + ), ), - home: const RecipeGeneratorScreen(), ); } } diff --git a/firebase-ai-friendly-meals/flutter/lib/recipe_generator_screen.dart b/firebase-ai-friendly-meals/flutter/lib/recipe_generator_screen.dart deleted file mode 100644 index 34c4d60d..00000000 --- a/firebase-ai-friendly-meals/flutter/lib/recipe_generator_screen.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:firebase_ai/firebase_ai.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; - -class RecipeGeneratorScreen extends StatefulWidget { - const RecipeGeneratorScreen({super.key}); - - @override - State createState() => _RecipeGeneratorScreenState(); -} - -class _RecipeGeneratorScreenState extends State { - final _ingredientsController = TextEditingController(); - final _notesController = TextEditingController(); - bool _showRecipeCard = false; - String _generatedRecipe = ""; - bool _isLoading = false; - - void _handleGenerateRecipe() async { - if (_ingredientsController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please enter some ingredients.'), - backgroundColor: Colors.red, - ), - ); - return; - } - setState(() { - _isLoading = true; - _showRecipeCard = false; - }); - - try { - final model = FirebaseAI.googleAI().generativeModel( - model: 'gemini-2.5-flash-lite', - ); - - final ingredients = _ingredientsController.text; - final notes = _notesController.text; - String prompt = - "Based on this list of ingredients: $ingredients, please give me a recipe. "; - if (notes.isNotEmpty) { - prompt += "Please also take in consideration these notes: $notes"; - } - final response = await model.generateContent([Content.text(prompt)]); - - setState(() { - _generatedRecipe = response.text ?? 'No recipe generated.'; - _showRecipeCard = true; - }); - } catch (e) { - setState(() { - _generatedRecipe = 'Error generating recipe: $e'; - _showRecipeCard = true; - }); - } finally { - setState(() { - _isLoading = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Friendly Meals')), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 16.0), - TextField( - controller: _ingredientsController, - decoration: const InputDecoration( - labelText: 'Enter ingredients (comma separated)', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16.0), - TextField( - controller: _notesController, - decoration: const InputDecoration( - labelText: 'Notes or preferred cuisines', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 32.0), - TextButton( - onPressed: _isLoading ? null : _handleGenerateRecipe, - style: TextButton.styleFrom( - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16.0), - textStyle: const TextStyle(fontSize: 18.0), - ), - child: _isLoading - ? const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - ) - : const Text('Generate Recipe'), - ), - const SizedBox(height: 24.0), - Visibility( - visible: _showRecipeCard, - child: Card( - elevation: 2.0, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [MarkdownBody(data: _generatedRecipe)], - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/generate/generate_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/generate/generate_screen.dart new file mode 100644 index 00000000..0274219d --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/generate/generate_screen.dart @@ -0,0 +1,349 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../../data/app_repositories.dart'; +import '../shared/camera_component.dart'; +import '../shared/loading_indicator.dart'; +import '../theme/app_theme.dart'; + +class GenerateViewState { + const GenerateViewState({ + this.ingredients = '', + this.notes = '', + this.ingredientsLoading = false, + this.recipeLoading = false, + }); + + final String ingredients; + final String notes; + final bool ingredientsLoading; + final bool recipeLoading; + + GenerateViewState copyWith({ + String? ingredients, + String? notes, + bool? ingredientsLoading, + bool? recipeLoading, + }) { + return GenerateViewState( + ingredients: ingredients ?? this.ingredients, + notes: notes ?? this.notes, + ingredientsLoading: ingredientsLoading ?? this.ingredientsLoading, + recipeLoading: recipeLoading ?? this.recipeLoading, + ); + } +} + +class GenerateScreen extends StatefulWidget { + const GenerateScreen({ + super.key, + required this.repositories, + required this.openRecipeScreen, + required this.showError, + }); + + final AppRepositories repositories; + final ValueChanged openRecipeScreen; + final VoidCallback showError; + + @override + State createState() => _GenerateScreenState(); +} + +class _GenerateScreenState extends State { + GenerateViewState _viewState = const GenerateViewState(); + late final TextEditingController _ingredientsController; + late final TextEditingController _notesController; + + @override + void initState() { + super.initState(); + _ingredientsController = TextEditingController(); + _notesController = TextEditingController(); + _loadCurrentUser(); + _loadOnDeviceModel(); + } + + @override + void dispose() { + _ingredientsController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + Future _loadCurrentUser() async { + try { + if (widget.repositories.authRepository.currentUser == null) { + final user = + await widget.repositories.authRepository.createAnonymousAccount(); + await widget.repositories.databaseRepository.addUser(user); + } + } catch (e) { + debugPrint('Error loading current user: $e'); + } + } + + Future _loadOnDeviceModel() async { + try { + await widget.repositories.aiRepository.loadOnDeviceModel(); + } catch (e) { + debugPrint('Error loading on-device model: $e'); + } + } + + void _onIngredientsUpdated(String ingredients) { + setState(() { + _viewState = _viewState.copyWith(ingredients: ingredients); + }); + } + + void _onNotesUpdated(String notes) { + setState(() { + _viewState = _viewState.copyWith(notes: notes); + }); + } + + Future _onImageTaken( + Uint8List? imageBytes, + VoidCallback showError, + ) async { + if (imageBytes == null) { + showError(); + return; + } + + setState(() { + _viewState = _viewState.copyWith(ingredientsLoading: true); + }); + + try { + final ingredients = await widget.repositories.aiRepository + .generateIngredients(imageBytes); + if (!mounted) return; + _ingredientsController.text = ingredients; + setState(() { + _viewState = _viewState.copyWith( + ingredientsLoading: false, + ingredients: ingredients, + ); + }); + } catch (e) { + debugPrint('Error generating ingredients from image: $e'); + if (!mounted) return; + setState(() { + _viewState = _viewState.copyWith(ingredientsLoading: false); + }); + showError(); + } + } + + Future _generateRecipe( + ValueChanged openRecipeScreen, + VoidCallback showError, + ) async { + setState(() { + _viewState = _viewState.copyWith(recipeLoading: true); + }); + + try { + final generatedRecipe = await widget.repositories.aiRepository + .generateRecipe(_viewState.ingredients, _viewState.notes); + + if (generatedRecipe == null) { + if (!mounted) return; + setState(() { + _viewState = _viewState.copyWith(recipeLoading: false); + }); + showError(); + return; + } + + Uint8List? recipeImage; + try { + recipeImage = await widget.repositories.aiRepository + .generateRecipePhoto(generatedRecipe.title); + } catch (e) { + debugPrint('Error generating recipe photo: $e'); + } + + String? recipeImageUri; + if (recipeImage != null) { + try { + recipeImageUri = + await widget.repositories.storageRepository.addImage(recipeImage); + } catch (e) { + debugPrint('Error uploading recipe image: $e'); + } + } + + final storedRecipeId = + await widget.repositories.databaseRepository.addRecipe( + generatedRecipe.toRecipe( + authorId: + widget.repositories.authRepository.currentUser?.uid ?? '', + imageUri: recipeImageUri, + ), + ); + + if (!mounted) return; + setState(() { + _viewState = _viewState.copyWith(recipeLoading: false); + }); + + openRecipeScreen(storedRecipeId); + } catch (e) { + debugPrint('Error generating recipe: $e'); + if (!mounted) return; + setState(() { + _viewState = _viewState.copyWith(recipeLoading: false); + }); + showError(); + } + } + + @override + Widget build(BuildContext context) { + final ingredientsHint = _viewState.ingredientsLoading + ? 'Loading…' + : 'e.g., pasta, tomato, garlic, bacon, eggs'; + + final generateButtonEnabled = _viewState.ingredients.trim().isNotEmpty && + !_viewState.recipeLoading && + !_viewState.ingredientsLoading; + + final borderSide = BorderSide(color: Colors.grey.shade400); + final outlineBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: borderSide, + ); + + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(68), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: const Text( + 'New recipe', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'List your ingredients', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + height: 120, + child: TextField( + controller: _ingredientsController, + onChanged: _onIngredientsUpdated, + enabled: !_viewState.ingredientsLoading, + expands: true, + maxLines: null, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + hintText: ingredientsHint, + hintStyle: const TextStyle(color: Colors.grey), + border: outlineBorder, + enabledBorder: outlineBorder, + focusedBorder: outlineBorder, + disabledBorder: outlineBorder, + contentPadding: const EdgeInsets.all(16), + ), + ), + ), + const SizedBox(height: 24), + CameraComponent( + onImageTaken: _onImageTaken, + showError: widget.showError, + isTopBarIcon: false, + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Any special notes or cuisines?', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + height: 120, + child: TextField( + controller: _notesController, + onChanged: _onNotesUpdated, + expands: true, + maxLines: null, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + hintText: 'e.g., vegetarian, gluten-free, Italian', + hintStyle: const TextStyle(color: Colors.grey), + border: outlineBorder, + enabledBorder: outlineBorder, + focusedBorder: outlineBorder, + contentPadding: const EdgeInsets.all(16), + ), + ), + ), + const SizedBox(height: 48), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: generateButtonEnabled + ? () => _generateRecipe( + widget.openRecipeScreen, + widget.showError, + ) + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.teal, + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey.shade300, + disabledForegroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + child: const Text( + 'Generate Recipe', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 24), + if (_viewState.recipeLoading || _viewState.ingredientsLoading) ...[ + const SizedBox(height: 24), + const LoadingIndicator(), + ], + ], + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/grocery_list/grocery_list_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/grocery_list/grocery_list_screen.dart new file mode 100644 index 00000000..0d53a5c2 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/grocery_list/grocery_list_screen.dart @@ -0,0 +1,866 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:intl/intl.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../data/app_repositories.dart'; +import '../../data/model/grocery_item.dart'; +import '../../data/schema/store_schema.dart'; +import '../theme/app_theme.dart'; + +sealed class StoreFinderUiState { + const StoreFinderUiState(); +} + +class StoreFinderIdle extends StoreFinderUiState { + const StoreFinderIdle(); +} + +class StoreFinderLoading extends StoreFinderUiState { + const StoreFinderLoading(); +} + +class StoreFinderSuccess extends StoreFinderUiState { + const StoreFinderSuccess(this.stores); + final List stores; +} + +class StoreFinderError extends StoreFinderUiState { + const StoreFinderError(this.message); + final String message; +} + +class GroceryListScreen extends StatefulWidget { + const GroceryListScreen({ + super.key, + required this.repositories, + required this.showError, + }); + + final AppRepositories repositories; + final VoidCallback showError; + + @override + State createState() => _GroceryListScreenState(); +} + +class _GroceryListScreenState extends State { + static const String _emptyItemsError = + 'Your grocery list is empty or all items are checked.'; + static const String _emptyStoreError = 'Failed to fetch local stores.'; + + final TextEditingController _inputController = TextEditingController(); + final ValueNotifier _storeFinderState = + ValueNotifier(const StoreFinderIdle()); + + List _groceries = const []; + StreamSubscription>? _groceriesSub; + StreamSubscription? _authSub; + String _subscribedUid = ''; + + String get _userId => + widget.repositories.authRepository.currentUser?.uid ?? ''; + + @override + void initState() { + super.initState(); + _loadGroceries(_userId); + _authSub = widget.repositories.authRepository.authStateChanges.listen( + (user) { + final uid = user?.uid ?? ''; + if (uid != _subscribedUid) { + _loadGroceries(uid); + } + }, + ); + } + + @override + void dispose() { + _authSub?.cancel(); + _groceriesSub?.cancel(); + _inputController.dispose(); + _storeFinderState.dispose(); + super.dispose(); + } + + void _loadGroceries(String uid) { + _groceriesSub?.cancel(); + _subscribedUid = uid; + if (uid.isEmpty) { + if (mounted) { + setState(() { + _groceries = const []; + }); + } + return; + } + + _groceriesSub = widget.repositories.databaseRepository + .getGroceriesStream(uid) + .listen( + (items) { + if (!mounted) return; + setState(() { + _groceries = items; + }); + }, + onError: (Object e) { + debugPrint('Error streaming groceries: $e'); + }, + ); + } + + Future _handleAdd() async { + final text = _inputController.text.trim(); + if (text.isEmpty || _userId.isEmpty) return; + _inputController.clear(); + + try { + final item = GroceryItem( + userId: _userId, + name: text, + checked: false, + ); + await widget.repositories.databaseRepository.addGroceryItem(item); + } catch (e) { + debugPrint('Error adding grocery item: $e'); + } + } + + Future _toggleItem(GroceryItem item) async { + try { + await widget.repositories.databaseRepository + .updateGroceryItemChecked(item.id, !item.checked); + } catch (e) { + debugPrint('Error toggling grocery item: $e'); + } + } + + Future _deleteItem(GroceryItem item) async { + try { + await widget.repositories.databaseRepository.deleteGroceryItem(item.id); + } catch (e) { + debugPrint('Error deleting grocery item: $e'); + } + } + + Future _startStoreFinder() async { + if (!kIsWeb) { + final statuses = await [ + Permission.locationWhenInUse, + Permission.location, + ].request(); + final granted = + (statuses[Permission.locationWhenInUse]?.isGranted ?? false) || + (statuses[Permission.location]?.isGranted ?? false); + if (!granted) { + return; + } + } + + if (!mounted) return; + _storeFinderState.value = const StoreFinderIdle(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) { + return _StoreFinderBottomSheet( + uiStateListenable: _storeFinderState, + onRetry: _fetchLocationAndStores, + ); + }, + ).whenComplete(() { + _storeFinderState.value = const StoreFinderIdle(); + }); + + await _fetchLocationAndStores(); + } + + Future _fetchLocationAndStores() async { + _storeFinderState.value = const StoreFinderIdle(); + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + await _findStores(position.latitude, position.longitude); + } catch (e) { + debugPrint('Error getting current location: $e'); + if (mounted) { + Navigator.of(context).maybePop(); + widget.showError(); + } + } + } + + Future _findStores(double latitude, double longitude) async { + final uncheckedIngredients = _groceries + .where((item) => !item.checked) + .map((item) => item.name) + .toList(); + + if (uncheckedIngredients.isEmpty) { + _storeFinderState.value = const StoreFinderError(_emptyItemsError); + return; + } + + _storeFinderState.value = const StoreFinderLoading(); + + try { + final now = DateTime.now(); + final dayOfWeek = DateFormat('EEEE', 'en_US').format(now); + final currentTime = + '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}'; + + final stores = await widget.repositories.aiRepository.findStores( + ingredients: uncheckedIngredients, + latitude: latitude, + longitude: longitude, + currentTime: currentTime, + dayOfWeek: dayOfWeek, + ); + + if (stores.isEmpty) { + _storeFinderState.value = const StoreFinderError(_emptyStoreError); + } else { + _storeFinderState.value = StoreFinderSuccess(stores); + } + } catch (e) { + debugPrint('Error finding stores: $e'); + _storeFinderState.value = const StoreFinderError(_emptyStoreError); + } + } + + @override + Widget build(BuildContext context) { + final unfocusedBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: AppColors.borderColor), + ); + final focusedBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: AppColors.teal, width: 1.5), + ); + + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(68), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + children: [ + const Text( + 'Grocery List', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + SizedBox( + width: 48, + height: 48, + child: IconButton( + onPressed: _startStoreFinder, + tooltip: 'Store Finder', + icon: const Icon( + Icons.location_on_outlined, + color: AppColors.teal, + size: 28, + ), + ), + ), + ], + ), + ), + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 54, + child: TextField( + controller: _inputController, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _handleAdd(), + decoration: InputDecoration( + hintText: 'Add an ingredient…', + hintStyle: const TextStyle(color: Colors.grey), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: unfocusedBorder, + enabledBorder: unfocusedBorder, + focusedBorder: focusedBorder, + ), + ), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 54, + height: 54, + child: ElevatedButton( + onPressed: _handleAdd, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.teal, + foregroundColor: Colors.white, + padding: EdgeInsets.zero, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: const Icon( + Icons.add, + color: Colors.white, + size: 24, + semanticLabel: 'Add', + ), + ), + ), + ], + ), + ), + Expanded( + child: _groceries.isEmpty + ? const Center( + child: Text( + 'Your grocery list is empty', + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.only(bottom: 24), + itemCount: _groceries.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final item = _groceries[index]; + return _GroceryCard( + key: ValueKey(item.id), + item: item, + onToggle: () => _toggleItem(item), + onDelete: () => _deleteItem(item), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _GroceryCard extends StatelessWidget { + const _GroceryCard({ + super.key, + required this.item, + required this.onToggle, + required this.onDelete, + }); + + final GroceryItem item; + final VoidCallback onToggle; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: Colors.white, + elevation: item.checked ? 0 : 2, + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onToggle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: item.checked + ? AppColors.teal + : AppColors.lightTeal, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: item.checked + ? const Icon( + Icons.check, + color: Colors.white, + size: 16, + ) + : Container( + width: 14, + height: 14, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + item.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: item.checked + ? Colors.grey + : AppColors.textColor, + decoration: item.checked + ? TextDecoration.lineThrough + : TextDecoration.none, + height: 22 / 16, + ), + ), + ), + ], + ), + ), + SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + onPressed: onDelete, + tooltip: 'Delete', + icon: const Icon( + Icons.delete_outline, + color: Colors.grey, + size: 20, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _StoreFinderBottomSheet extends StatelessWidget { + const _StoreFinderBottomSheet({ + required this.uiStateListenable, + required this.onRetry, + }); + + final ValueListenable uiStateListenable; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: uiStateListenable, + builder: (context, uiState, _) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Ingredient-to-Store Finder', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + const Padding( + padding: EdgeInsets.only(bottom: 20), + child: Text( + 'Find stores nearby with the ingredients you need.', + style: TextStyle(fontSize: 14), + ), + ), + Flexible( + child: switch (uiState) { + StoreFinderIdle() => const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + color: AppColors.teal, + ), + ), + SizedBox(height: 16), + Text( + 'Determining your location…', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + StoreFinderLoading() => const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + color: AppColors.teal, + ), + ), + SizedBox(height: 16), + Text( + 'Locating nearest stores with Google Maps…', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + StoreFinderError(:final message) => Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + color: Colors.red, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: onRetry, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.teal, + foregroundColor: Colors.white, + ), + child: const Text('Retry'), + ), + ], + ), + ), + ), + StoreFinderSuccess(:final stores) => stores.isEmpty + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Text( + 'No stores found matching your ingredients nearby.', + ), + ), + ) + : ListView.separated( + shrinkWrap: true, + itemCount: stores.length, + separatorBuilder: (_, _) => + const SizedBox(height: 16), + itemBuilder: (context, index) { + return _StoreCard(store: stores[index]); + }, + ), + }, + ), + ], + ), + ), + ); + }, + ); + } +} + +class _StoreCard extends StatelessWidget { + const _StoreCard({required this.store}); + + final StoreSchema store; + + Future _openMapUrl() async { + final rawUrl = store.mapUrl.trim(); + if (rawUrl.isEmpty) return; + + final uri = Uri.tryParse(rawUrl); + if (uri == null) return; + if (uri.scheme != 'https' && uri.scheme != 'http' && uri.scheme != 'geo') { + return; + } + + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (e) { + debugPrint('Could not launch store map URL: $e'); + } + } + + @override + Widget build(BuildContext context) { + final parkingColor = + store.hasParking ? const Color(0xFFE3F2FD) : const Color(0xFFECEFF1); + final parkingTextColor = + store.hasParking ? const Color(0xFF1565C0) : const Color(0xFF455A64); + final parkingLabel = + store.hasParking ? '🅿️ Parking Available' : 'No Parking'; + + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: Colors.white, + elevation: 2, + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: store.mapUrl.trim().isNotEmpty ? _openMapUrl : null, + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + store.name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textColor, + ), + ), + const SizedBox(height: 4), + Text( + store.address, + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFE8F5E9), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.location_on_outlined, + size: 14, + color: Color(0xFF2E7D32), + ), + const SizedBox(width: 4), + Text( + store.distance, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF2E7D32), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (store.openNow) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFFE8F5E9), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: Color(0xFF4CAF50), + shape: BoxShape.circle, + ), + child: SizedBox(width: 8, height: 8), + ), + SizedBox(width: 6), + Text( + 'Open Now', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF2E7D32), + ), + ), + ], + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFEBEE), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: Color(0xFFE57373), + shape: BoxShape.circle, + ), + child: SizedBox(width: 8, height: 8), + ), + SizedBox(width: 6), + Text( + 'Closed', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFFC62828), + ), + ), + ], + ), + ), + if (store.closingSoon) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E0), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: Color(0xFFFF9800), + shape: BoxShape.circle, + ), + child: SizedBox(width: 8, height: 8), + ), + SizedBox(width: 6), + Text( + 'Closing Soon', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFFEF6C00), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: parkingColor, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + parkingLabel, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: parkingTextColor, + ), + ), + ), + ], + ), + if (store.parkingDetails.trim().isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + store.parkingDetails, + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + height: 16 / 12, + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/live/live_assistant_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/live/live_assistant_screen.dart new file mode 100644 index 00000000..474491b6 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/live/live_assistant_screen.dart @@ -0,0 +1,545 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:camera/camera.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; +import 'package:record/record.dart'; + +import '../../data/app_repositories.dart'; +import '../../data/datasource/live_ai_remote_datasource.dart'; +import '../../data/model/grocery_item.dart'; +import '../../data/model/recipe.dart'; +import '../shared/audio_component.dart'; +import '../theme/app_theme.dart'; + +sealed class LiveAssistantUiState { + const LiveAssistantUiState(); +} + +class LiveAssistantLoading extends LiveAssistantUiState { + const LiveAssistantLoading(); +} + +class LiveAssistantSuccess extends LiveAssistantUiState { + const LiveAssistantSuccess(this.recipe); + final Recipe recipe; +} + +class LiveAssistantError extends LiveAssistantUiState { + const LiveAssistantError(this.message); + final String message; +} + +class LiveAssistantScreen extends StatefulWidget { + const LiveAssistantScreen({ + super.key, + required this.recipeId, + required this.repositories, + required this.navigateBack, + required this.showError, + }); + + final String recipeId; + final AppRepositories repositories; + final VoidCallback navigateBack; + final VoidCallback showError; + + @override + State createState() => _LiveAssistantScreenState(); +} + +class _LiveAssistantScreenState extends State { + static const String _mimeType = 'image/jpeg'; + static const String _recipeError = 'Failed to load recipe'; + static const String _connectionError = 'Failed to connect to live assistant'; + + LiveAssistantUiState _uiState = const LiveAssistantLoading(); + + LiveSession? _liveSession; + bool _isConnected = false; + int _lastFrameTimeMs = 0; + bool _encodingFrame = false; + + CameraController? _cameraController; + final AudioRecorder _audioRecorder = AudioRecorder(); + StreamSubscription? _micSubscription; + StreamSubscription? _serverSubscription; + + final BytesBuilder _incomingAudioBuffer = BytesBuilder(copy: false); + final List _playbackQueue = []; + bool _isPlayingAudio = false; + final AudioComponent _audioComponent = AudioComponent(); + + @override + void initState() { + super.initState(); + _loadRecipeAndConnect(); + } + + @override + void dispose() { + _endConversation(); + super.dispose(); + } + + Future _loadRecipeAndConnect() async { + try { + final recipe = await widget.repositories.databaseRepository + .getRecipe(widget.recipeId); + + if (!mounted) return; + if (recipe.title.trim().isEmpty) { + setState(() { + _uiState = const LiveAssistantError(_recipeError); + }); + return; + } + + setState(() { + _uiState = LiveAssistantSuccess(recipe); + }); + await _initializeCamera(); + await _setupLiveSession(recipe); + } catch (e) { + debugPrint('Error in _loadRecipeAndConnect: $e'); + if (!mounted) return; + setState(() { + _uiState = const LiveAssistantError(_recipeError); + }); + } + } + + Future _initializeCamera() async { + try { + final cameras = await availableCameras(); + if (cameras.isEmpty) { + widget.showError(); + return; + } + + final backCamera = cameras.firstWhere( + (c) => c.lensDirection == CameraLensDirection.back, + orElse: () => cameras.first, + ); + + final controller = CameraController( + backCamera, + ResolutionPreset.medium, + enableAudio: false, + imageFormatGroup: ImageFormatGroup.yuv420, + ); + + await controller.initialize(); + if (!mounted) { + await controller.dispose(); + return; + } + + setState(() { + _cameraController = controller; + }); + + await controller.startImageStream(_onCameraFrame); + } catch (e) { + debugPrint('Error initializing camera for Live Assistant: $e'); + widget.showError(); + } + } + + void _onCameraFrame(CameraImage cameraImage) { + if (!_isConnected || _liveSession == null || _encodingFrame) return; + + final nowMs = DateTime.now().millisecondsSinceEpoch; + // Limit sending frames to once per second to conserve bandwidth and processing + if (nowMs - _lastFrameTimeMs < 1000) return; + _lastFrameTimeMs = nowMs; + _encodingFrame = true; + + _convertCameraImageToJpeg(cameraImage).then((jpegBytes) { + if (jpegBytes != null && _isConnected && _liveSession != null) { + _liveSession!.sendVideoRealtime(InlineDataPart(_mimeType, jpegBytes)); + } + }).catchError((Object e) { + debugPrint('Error sending video frame: $e'); + }).whenComplete(() { + _encodingFrame = false; + }); + } + + Future _convertCameraImageToJpeg(CameraImage image) async { + try { + if (image.planes.isEmpty) return null; + final width = image.width; + final height = image.height; + final yPlane = image.planes[0]; + final yBytes = Uint8List.fromList(yPlane.bytes); + final yRowStride = yPlane.bytesPerRow; + + Uint8List? uBytes; + Uint8List? vBytes; + int uvRowStride = 0; + int uvPixelStride = 1; + if (image.planes.length >= 3) { + uBytes = Uint8List.fromList(image.planes[1].bytes); + vBytes = Uint8List.fromList(image.planes[2].bytes); + uvRowStride = image.planes[1].bytesPerRow; + uvPixelStride = image.planes[1].bytesPerPixel ?? 1; + } + + final sensorOrientation = + _cameraController?.description.sensorOrientation ?? 0; + + return compute( + _encodeYuvFrame, + _FrameData( + width: width, + height: height, + yBytes: yBytes, + yRowStride: yRowStride, + uBytes: uBytes, + vBytes: vBytes, + uvRowStride: uvRowStride, + uvPixelStride: uvPixelStride, + rotationDegrees: sensorOrientation, + ), + ); + } catch (e) { + debugPrint('Error converting camera frame: $e'); + return null; + } + } + + static Uint8List _encodeYuvFrame(_FrameData data) { + img.Image buffer = img.Image(width: data.width, height: data.height); + final hasChroma = data.uBytes != null && data.vBytes != null; + + for (int y = 0; y < data.height; y++) { + final yRowOffset = y * data.yRowStride; + final uvRowOffset = (y >> 1) * data.uvRowStride; + for (int x = 0; x < data.width; x++) { + final yIndex = yRowOffset + x; + final yVal = yIndex < data.yBytes.length ? data.yBytes[yIndex] : 0; + + if (!hasChroma) { + buffer.setPixelRgb(x, y, yVal, yVal, yVal); + } else { + final uvIndex = uvRowOffset + (x >> 1) * data.uvPixelStride; + final uVal = + (uvIndex < data.uBytes!.length ? data.uBytes![uvIndex] : 128) - + 128; + final vVal = + (uvIndex < data.vBytes!.length ? data.vBytes![uvIndex] : 128) - + 128; + + final r = (yVal + 1.370705 * vVal).round().clamp(0, 255); + final g = + (yVal - 0.337633 * uVal - 0.698001 * vVal).round().clamp(0, 255); + final b = (yVal + 1.732446 * uVal).round().clamp(0, 255); + buffer.setPixelRgb(x, y, r, g, b); + } + } + } + + if (data.rotationDegrees != 0 && data.rotationDegrees % 90 == 0) { + buffer = img.copyRotate(buffer, angle: data.rotationDegrees); + } + + return Uint8List.fromList(img.encodeJpg(buffer, quality: 80)); + } + + Future _setupLiveSession(Recipe recipe) async { + final session = + await widget.repositories.liveAiRepository.setupLiveSession(recipe); + + if (!mounted) { + await session?.close(); + return; + } + + if (session == null) { + setState(() { + _uiState = const LiveAssistantError(_connectionError); + }); + } else { + _liveSession = session; + _isConnected = true; + await _startConversation(); + } + } + + Future _startConversation() async { + final session = _liveSession; + if (session == null) return; + + _serverSubscription = session.receive().listen( + (response) async { + final message = response.message; + if (message is LiveServerContent) { + if (message.interrupted == true) { + _incomingAudioBuffer.clear(); + _playbackQueue.clear(); + await _audioComponent.stop(); + _isPlayingAudio = false; + return; + } + final parts = message.modelTurn?.parts ?? const []; + for (final part in parts) { + if (part is InlineDataPart && + part.mimeType.startsWith('audio/pcm')) { + _incomingAudioBuffer.add(part.bytes); + } + } + if (message.turnComplete == true || + _incomingAudioBuffer.length >= 48000) { + final bytes = _incomingAudioBuffer.takeBytes(); + if (bytes.isNotEmpty) { + _enqueueAudioPlayback(bytes); + } + } + } else if (message is LiveServerToolCall) { + final calls = message.functionCalls ?? const []; + if (calls.isNotEmpty) { + final responses = []; + for (final call in calls) { + responses.add(await _handleFunctionCall(call)); + } + await session.sendToolResponse(responses); + } + } + }, + onError: (Object error) { + debugPrint('LiveSession receive error: $error'); + }, + ); + + try { + final hasPermission = await _audioRecorder.hasPermission(); + if (hasPermission) { + final micStream = await _audioRecorder.startStream( + const RecordConfig( + encoder: AudioEncoder.pcm16bits, + sampleRate: 16000, + numChannels: 1, + echoCancel: true, + autoGain: true, + ), + ); + _micSubscription = micStream.listen((chunk) { + if (_isConnected && _liveSession != null && chunk.isNotEmpty) { + _liveSession!.sendAudioRealtime( + InlineDataPart('audio/pcm;rate=16000', chunk), + ); + } + }); + } + } catch (e) { + debugPrint('Error starting microphone stream: $e'); + } + } + + void _enqueueAudioPlayback(Uint8List pcmBytes) { + _playbackQueue.add(pcmBytes); + if (!_isPlayingAudio) { + _playNextAudioChunk(); + } + } + + Future _playNextAudioChunk() async { + if (_playbackQueue.isEmpty || !mounted) { + _isPlayingAudio = false; + return; + } + _isPlayingAudio = true; + final nextChunk = _playbackQueue.removeAt(0); + await _audioComponent.play(nextChunk, () { + _playNextAudioChunk(); + }); + } + + Future _handleFunctionCall(FunctionCall functionCall) async { + if (functionCall.name == LiveAIRemoteDataSource.addIngredientsToolName) { + final rawArg = + functionCall.args[LiveAIRemoteDataSource.ingredientFieldName]; + final ingredientName = + rawArg?.toString().trim().replaceAll('"', '') ?? ''; + + if (ingredientName.isNotEmpty) { + final userId = + widget.repositories.authRepository.currentUser?.uid ?? ''; + if (userId.isNotEmpty) { + final item = GroceryItem( + userId: userId, + name: ingredientName, + checked: false, + ); + await widget.repositories.databaseRepository.addGroceryItem(item); + } + } + + return FunctionResponse( + functionCall.name, + {'result': 'Successfully added $ingredientName to grocery list'}, + id: functionCall.id, + ); + } + + return FunctionResponse( + functionCall.name, + const {}, + id: functionCall.id, + ); + } + + Future _endConversation() async { + _isConnected = false; + await _micSubscription?.cancel(); + _micSubscription = null; + try { + await _audioRecorder.stop(); + await _audioRecorder.dispose(); + } catch (_) {} + await _serverSubscription?.cancel(); + _serverSubscription = null; + await _audioComponent.release(); + final cam = _cameraController; + _cameraController = null; + if (cam != null) { + try { + if (cam.value.isStreamingImages) { + await cam.stopImageStream(); + } + await cam.dispose(); + } catch (_) {} + } + final session = _liveSession; + _liveSession = null; + try { + await session?.close(); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: IconButton( + padding: EdgeInsets.zero, + onPressed: widget.navigateBack, + tooltip: 'Back', + icon: const Icon( + Icons.arrow_back, + color: Colors.white, + ), + ), + ), + const SizedBox(width: 16), + const Text( + 'Live Cooking Assistant', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), + Expanded( + child: switch (_uiState) { + LiveAssistantLoading() => const Center( + child: CircularProgressIndicator(color: AppColors.teal), + ), + LiveAssistantError(:final message) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + message, + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ), + ), + LiveAssistantSuccess() => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Stack( + fit: StackFit.expand, + children: [ + if (_cameraController != null && + _cameraController!.value.isInitialized) + CameraPreview(_cameraController!) + else + const ColoredBox(color: Colors.black), + Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + color: Colors.black.withValues(alpha: 0.6), + padding: const EdgeInsets.all(16), + child: const Text( + 'Point your camera at your cooking and ask questions', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ), + ), + }, + ), + ], + ), + ), + ); + } +} + +class _FrameData { + const _FrameData({ + required this.width, + required this.height, + required this.yBytes, + required this.yRowStride, + required this.uBytes, + required this.vBytes, + required this.uvRowStride, + required this.uvPixelStride, + required this.rotationDegrees, + }); + + final int width; + final int height; + final Uint8List yBytes; + final int yRowStride; + final Uint8List? uBytes; + final Uint8List? vBytes; + final int uvRowStride; + final int uvPixelStride; + final int rotationDegrees; +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/recipe/recipe_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/recipe/recipe_screen.dart new file mode 100644 index 00000000..c5086114 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/recipe/recipe_screen.dart @@ -0,0 +1,859 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../data/app_repositories.dart'; +import '../../data/model/like.dart'; +import '../../data/model/recipe.dart'; +import '../../data/model/review.dart'; +import '../shared/audio_component.dart'; +import '../shared/rating_button.dart'; +import '../theme/app_theme.dart'; + +sealed class RecipeAudioState { + const RecipeAudioState(); +} + +class RecipeAudioIdle extends RecipeAudioState { + const RecipeAudioIdle(); +} + +class RecipeAudioLoading extends RecipeAudioState { + const RecipeAudioLoading(); +} + +class RecipeAudioPlaying extends RecipeAudioState { + const RecipeAudioPlaying(); +} + +class RecipeAudioPaused extends RecipeAudioState { + const RecipeAudioPaused(); +} + +class RecipeAudioError extends RecipeAudioState { + const RecipeAudioError(this.message); + final String message; +} + +class RecipeViewState { + const RecipeViewState({ + this.recipeId = '', + this.recipe = const Recipe(), + this.favorite = false, + this.rating = 0, + this.audioState = const RecipeAudioIdle(), + }); + + final String recipeId; + final Recipe recipe; + final bool favorite; + final int rating; + final RecipeAudioState audioState; + + RecipeViewState copyWith({ + String? recipeId, + Recipe? recipe, + bool? favorite, + int? rating, + RecipeAudioState? audioState, + }) { + return RecipeViewState( + recipeId: recipeId ?? this.recipeId, + recipe: recipe ?? this.recipe, + favorite: favorite ?? this.favorite, + rating: rating ?? this.rating, + audioState: audioState ?? this.audioState, + ); + } +} + +class RecipeScreen extends StatefulWidget { + const RecipeScreen({ + super.key, + required this.recipeId, + required this.repositories, + required this.navigateBack, + required this.navigateToLiveAssistant, + }); + + final String recipeId; + final AppRepositories repositories; + final VoidCallback navigateBack; + final ValueChanged navigateToLiveAssistant; + + @override + State createState() => _RecipeScreenState(); +} + +class _RecipeScreenState extends State { + RecipeViewState _recipeViewState = const RecipeViewState(); + final AudioComponent _audioComponent = AudioComponent(); + Uint8List? _cachedAudioData; + String? _cachedPairingText; + + String get _userId => + widget.repositories.authRepository.currentUser?.uid ?? ''; + + @override + void initState() { + super.initState(); + _loadRecipe(); + } + + @override + void dispose() { + _audioComponent.release(); + super.dispose(); + } + + Future _loadRecipe() async { + try { + final results = await Future.wait([ + widget.repositories.databaseRepository.getRecipe(widget.recipeId), + widget.repositories.databaseRepository + .getFavorite(_userId, widget.recipeId), + widget.repositories.databaseRepository + .getRating(_userId, widget.recipeId), + ]); + + if (!mounted) return; + setState(() { + _recipeViewState = RecipeViewState( + recipeId: widget.recipeId, + recipe: results[0] as Recipe, + favorite: results[1] as bool, + rating: results[2] as int, + audioState: const RecipeAudioIdle(), + ); + }); + } catch (e) { + debugPrint('Error loading recipe: $e'); + } + } + + Future _onPairingClick() async { + switch (_recipeViewState.audioState) { + case RecipeAudioPlaying(): + await _audioComponent.pause(); + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioPaused(), + ); + }); + case RecipeAudioPaused(): + await _audioComponent.resume(); + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioPlaying(), + ); + }); + case RecipeAudioLoading(): + break; + case RecipeAudioIdle(): + case RecipeAudioError(): + if (_cachedAudioData != null) { + await _playCachedAudio(); + } else { + await _generatePairingAndAudio(); + } + } + } + + Future _playCachedAudio() async { + final audioData = _cachedAudioData; + if (audioData == null) return; + + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioPlaying(), + ); + }); + + await _audioComponent.play(audioData, () { + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioIdle(), + ); + }); + }); + } + + Future _generatePairingAndAudio() async { + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioLoading(), + ); + }); + + try { + if (_cachedPairingText == null || _cachedPairingText!.trim().isEmpty) { + final recipe = _recipeViewState.recipe; + final generated = await widget.repositories.aiRepository + .craftRecipePairing(recipe.title, recipe.ingredients); + _cachedPairingText = generated; + } + + final audio = await widget.repositories.aiRepository + .generateSpeech(_cachedPairingText); + + if (!mounted) return; + if (audio != null && audio.isNotEmpty) { + _cachedAudioData = audio; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioPlaying(), + ); + }); + await _audioComponent.play(audio, () { + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioIdle(), + ); + }); + }); + } else { + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioError('Could not generate audio'), + ); + }); + } + } catch (e) { + debugPrint('Error generating pairing audio: $e'); + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioError('Could not generate audio'), + ); + }); + } + } + + Future _toggleFavorite() async { + final like = Like(recipeId: widget.recipeId, userId: _userId); + try { + if (_recipeViewState.favorite) { + await widget.repositories.databaseRepository.removeFavorite(like); + } else { + await widget.repositories.databaseRepository.setFavorite(like); + } + + final favorite = await widget.repositories.databaseRepository + .getFavorite(_userId, widget.recipeId); + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith(favorite: favorite); + }); + } catch (e) { + debugPrint('Error toggling favorite: $e'); + } + } + + Future _leaveReview(int rating) async { + try { + await widget.repositories.databaseRepository.setReview( + Review( + userId: _userId, + recipeId: widget.recipeId, + rating: rating, + ), + ); + + final updatedRating = await widget.repositories.databaseRepository + .getRating(_userId, widget.recipeId); + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith(rating: updatedRating); + }); + } catch (e) { + debugPrint('Error leaving review: $e'); + } + } + + Future _onAddIngredientsToGrocery() async { + final ingredients = _recipeViewState.recipe.ingredients; + if (_userId.isEmpty || ingredients.isEmpty) return; + + try { + await widget.repositories.databaseRepository + .addIngredientsToGroceries(_userId, ingredients); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Added to Grocery List!'), + duration: Duration(seconds: 2), + ), + ); + } catch (e) { + debugPrint('Error adding ingredients to grocery list: $e'); + } + } + + Future _onLiveAssistantPressed() async { + if (!kIsWeb) { + final statuses = await [ + Permission.camera, + Permission.microphone, + ].request(); + final cameraGranted = statuses[Permission.camera]?.isGranted ?? false; + final audioGranted = statuses[Permission.microphone]?.isGranted ?? false; + + if (!cameraGranted || !audioGranted) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Camera and Microphone permissions are required to use the Live Assistant.', + ), + ), + ); + return; + } + } + + widget.navigateToLiveAssistant(_recipeViewState.recipeId); + } + + @override + Widget build(BuildContext context) { + final recipe = _recipeViewState.recipe; + final imageUri = recipe.imageUri; + + return Scaffold( + body: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + SizedBox( + width: double.infinity, + height: 300, + child: Stack( + fit: StackFit.expand, + children: [ + Container( + color: Colors.grey.shade400, + child: imageUri != null && imageUri.isNotEmpty + ? Image.network( + imageUri, + fit: BoxFit.cover, + semanticLabel: 'Recipe image', + errorBuilder: (_, _, _) => const Center( + child: Text( + 'Could not load image', + style: TextStyle(color: Colors.white), + ), + ), + ) + : const Center( + child: Text( + 'Could not load image', + style: TextStyle(color: Colors.white), + ), + ), + ), + SafeArea( + bottom: false, + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only( + top: 16, + left: 16, + right: 16, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _FloatingHeaderIconButton( + onPressed: widget.navigateBack, + tooltip: 'Back', + icon: Icons.arrow_back, + ), + _FloatingHeaderIconButton( + onPressed: _toggleFavorite, + tooltip: 'Favorite', + icon: _recipeViewState.favorite + ? Icons.favorite + : Icons.favorite_border, + ), + ], + ), + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + Text( + recipe.title, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + height: 34 / 28, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _onLiveAssistantPressed, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.teal, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.soup_kitchen_outlined, + color: Colors.white, + size: 20, + ), + SizedBox(width: 8), + Text( + 'Live Cooking Assistant', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + _PairingGuideButton( + audioState: _recipeViewState.audioState, + onClick: _onPairingClick, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: _InfoCard( + icon: Icons.timer_outlined, + label: 'Prep Time', + value: recipe.prepTime, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _InfoCard( + icon: Icons.soup_kitchen_outlined, + label: 'Cook Time', + value: recipe.cookTime, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _InfoCard( + icon: Icons.people_outline, + label: 'Servings', + value: recipe.servings, + ), + ), + ], + ), + const SizedBox(height: 24), + Card( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Ingredients', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + const SizedBox(height: 12), + ...recipe.ingredients + .map((item) => _IngredientRow(text: item)), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: _onAddIngredientsToGrocery, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.lightTeal, + foregroundColor: AppColors.teal, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check, + color: AppColors.teal, + size: 18, + ), + SizedBox(width: 8), + Text( + 'Add ingredients to Grocery List', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + Card( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Instructions', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + const SizedBox(height: 12), + MarkdownBody( + data: recipe.instructions, + styleSheet: MarkdownStyleSheet.fromTheme( + Theme.of(context), + ).copyWith( + p: const TextStyle( + fontSize: 15, + color: AppColors.textColor, + height: 1.5, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + Card( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Rate this recipe', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (int index = 0; index < 5; index++) + RatingButton( + rating: index + 1, + isSelected: + (index + 1) == _recipeViewState.rating, + isFilled: + (index + 1) < _recipeViewState.rating, + onClick: () => _leaveReview(index + 1), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _FloatingHeaderIconButton extends StatelessWidget { + const _FloatingHeaderIconButton({ + required this.onPressed, + required this.tooltip, + required this.icon, + }); + + final VoidCallback onPressed; + final String tooltip; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.8), + shape: BoxShape.circle, + ), + child: IconButton( + padding: EdgeInsets.zero, + onPressed: onPressed, + tooltip: tooltip, + icon: Icon( + icon, + color: AppColors.textColor, + size: 22, + ), + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + const _InfoCard({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), + decoration: BoxDecoration( + color: AppColors.lightTeal, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Icon( + icon, + color: AppColors.teal, + size: 20, + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 2), + Text( + value, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.textColor, + ), + ), + ], + ), + ); + } +} + +class _IngredientRow extends StatelessWidget { + const _IngredientRow({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: const BoxDecoration( + color: AppColors.lightTeal, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: const Icon( + Icons.check, + size: 14, + color: AppColors.teal, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: const TextStyle( + fontSize: 15, + color: AppColors.textColor, + ), + ), + ), + ], + ), + ); + } +} + +class _PairingGuideButton extends StatelessWidget { + const _PairingGuideButton({ + required this.audioState, + required this.onClick, + }); + + final RecipeAudioState audioState; + final VoidCallback onClick; + + @override + Widget build(BuildContext context) { + final Color containerColor = switch (audioState) { + RecipeAudioPlaying() || RecipeAudioPaused() => AppColors.teal, + RecipeAudioLoading() || RecipeAudioIdle() => AppColors.lightTeal, + RecipeAudioError() => const Color(0xFFFFEBEE), + }; + + final Color contentColor = switch (audioState) { + RecipeAudioPlaying() || RecipeAudioPaused() => Colors.white, + RecipeAudioError() => Colors.red, + RecipeAudioLoading() || RecipeAudioIdle() => AppColors.teal, + }; + + return SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: onClick, + style: ElevatedButton.styleFrom( + backgroundColor: containerColor, + foregroundColor: contentColor, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: switch (audioState) { + RecipeAudioLoading() => const [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: AppColors.teal, + strokeWidth: 2, + ), + ), + SizedBox(width: 10), + Text( + 'Loading audio…', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + ], + RecipeAudioPlaying() => const [ + Icon(Icons.pause, color: Colors.white, size: 20), + SizedBox(width: 10), + Text( + 'Playing pairing guide • Tap to pause', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + RecipeAudioPaused() => const [ + Icon(Icons.play_arrow, color: Colors.white, size: 20), + SizedBox(width: 10), + Text( + 'Paused • Tap to resume', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + RecipeAudioError() => const [ + Text( + 'Error playing audio • Tap to retry', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + RecipeAudioIdle() => const [ + Icon(Icons.wine_bar_outlined, color: AppColors.teal, size: 20), + SizedBox(width: 10), + Text( + 'Pair this dish with French wine', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + ], + }, + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_options.dart b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_options.dart new file mode 100644 index 00000000..9aa7132b --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_options.dart @@ -0,0 +1,45 @@ +enum SortByFilter { + defaultSort('Default'), + rating('Rating'), + alphabetical('Alphabetical'), + popularity('Popularity'); + + const SortByFilter(this.title); + final String title; +} + +class FilterOptions { + const FilterOptions({ + this.recipeTitle = '', + this.searchQuery = '', + this.filterByMine = false, + this.rating = 0, + this.selectedTags = const [], + this.sortBy = SortByFilter.defaultSort, + }); + + final String recipeTitle; + final String searchQuery; + final bool filterByMine; + final int rating; + final List selectedTags; + final SortByFilter sortBy; + + FilterOptions copyWith({ + String? recipeTitle, + String? searchQuery, + bool? filterByMine, + int? rating, + List? selectedTags, + SortByFilter? sortBy, + }) { + return FilterOptions( + recipeTitle: recipeTitle ?? this.recipeTitle, + searchQuery: searchQuery ?? this.searchQuery, + filterByMine: filterByMine ?? this.filterByMine, + rating: rating ?? this.rating, + selectedTags: selectedTags ?? this.selectedTags, + sortBy: sortBy ?? this.sortBy, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_screen.dart new file mode 100644 index 00000000..3974e7f7 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_screen.dart @@ -0,0 +1,409 @@ +import 'package:flutter/material.dart'; + +import '../shared/rating_button.dart'; +import '../theme/app_theme.dart'; +import 'filter_options.dart'; +import 'recipe_list_controller.dart'; + +class FilterScreen extends StatefulWidget { + const FilterScreen({ + super.key, + required this.controller, + required this.navigateBack, + }); + + final RecipeListController controller; + final VoidCallback navigateBack; + + @override + State createState() => _FilterScreenState(); +} + +class _FilterScreenState extends State { + late final TextEditingController _searchController; + late final TextEditingController _recipeTitleController; + + @override + void initState() { + super.initState(); + final filter = widget.controller.filterOptions; + _searchController = TextEditingController(text: filter.searchQuery); + _recipeTitleController = TextEditingController(text: filter.recipeTitle); + } + + @override + void dispose() { + _searchController.dispose(); + _recipeTitleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final outlineBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.borderColor), + ); + + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + final filterOptions = widget.controller.filterOptions; + final tags = widget.controller.tags; + + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text( + 'Filter Recipes', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + leading: IconButton( + onPressed: widget.navigateBack, + tooltip: 'Back', + icon: const Icon(Icons.arrow_back), + ), + ), + body: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Search', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + TextField( + controller: _searchController, + onChanged: widget.controller.updateSearchQuery, + decoration: InputDecoration( + hintText: 'Search recipes…', + hintStyle: const TextStyle(color: Colors.grey), + border: outlineBorder, + enabledBorder: outlineBorder, + focusedBorder: outlineBorder, + ), + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Recipe Title', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + TextField( + controller: _recipeTitleController, + onChanged: widget.controller.updateRecipeTitle, + decoration: InputDecoration( + hintText: 'e.g. Arrabbiata sauce', + hintStyle: const TextStyle(color: Colors.grey), + border: outlineBorder, + enabledBorder: outlineBorder, + focusedBorder: outlineBorder, + ), + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'My recipes only', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Switch( + value: filterOptions.filterByMine, + onChanged: (_) => + widget.controller.updateFilterByMine(), + activeThumbColor: Colors.white, + activeTrackColor: AppColors.teal, + inactiveThumbColor: AppColors.charcoal, + inactiveTrackColor: AppColors.lightTeal, + trackOutlineColor: + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return Colors.transparent; + } + return AppColors.teal; + }), + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Rating', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Row( + children: [ + for (int index = 0; index < 5; index++) ...[ + if (index > 0) const SizedBox(width: 12), + RatingButton( + rating: index + 1, + isSelected: (index + 1) == filterOptions.rating, + isFilled: (index + 1) < filterOptions.rating, + onClick: () => + widget.controller.updateRating(index + 1), + ), + ], + ], + ), + if (tags.isNotEmpty) ...[ + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Tags', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: tags.map((tagName) { + final isSelected = filterOptions.selectedTags + .contains(tagName); + return _TagFilterChip( + text: tagName, + isSelected: isSelected, + onClick: () { + if (isSelected) { + widget.controller.removeTag(tagName); + } else { + widget.controller.addTag(tagName); + } + }, + ); + }).toList(), + ), + ], + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Sort By', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + RadioGroup( + groupValue: filterOptions.sortBy, + onChanged: (SortByFilter? value) { + if (value != null) { + widget.controller.updateSortBy(value); + } + }, + child: Column( + children: [ + Row( + children: [ + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => widget.controller + .updateSortBy(SortByFilter.defaultSort), + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Radio( + value: SortByFilter.defaultSort, + activeColor: AppColors.teal, + ), + Text(SortByFilter.defaultSort.title), + ], + ), + ), + ), + const SizedBox(width: 16), + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => widget.controller + .updateSortBy(SortByFilter.rating), + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Radio( + value: SortByFilter.rating, + activeColor: AppColors.teal, + ), + Text(SortByFilter.rating.title), + ], + ), + ), + ), + ], + ), + Row( + children: [ + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => widget.controller.updateSortBy( + SortByFilter.alphabetical, + ), + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Radio( + value: SortByFilter.alphabetical, + activeColor: AppColors.teal, + ), + Text(SortByFilter.alphabetical.title), + ], + ), + ), + ), + const SizedBox(width: 16), + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => widget.controller + .updateSortBy(SortByFilter.popularity), + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Radio( + value: SortByFilter.popularity, + activeColor: AppColors.teal, + ), + Text(SortByFilter.popularity.title), + ], + ), + ), + ), + ], + ), + ], + ), + ), + const Spacer(), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: SizedBox( + height: 56, + child: OutlinedButton( + onPressed: () { + _searchController.clear(); + _recipeTitleController.clear(); + widget.navigateBack(); + widget.controller.resetFilters(); + }, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.teal, + side: const BorderSide( + color: AppColors.teal, + width: 1, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Reset', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: SizedBox( + height: 56, + child: ElevatedButton( + onPressed: () { + widget.navigateBack(); + widget.controller.applyFilters(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.teal, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Apply Filters', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + }, + ), + ); + }, + ); + } +} + +class _TagFilterChip extends StatelessWidget { + const _TagFilterChip({ + required this.text, + required this.isSelected, + required this.onClick, + }); + + final String text; + final bool isSelected; + final VoidCallback onClick; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onClick, + borderRadius: BorderRadius.circular(24), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSelected ? AppColors.lightTeal : Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all( + width: 1, + color: isSelected ? AppColors.lightTeal : AppColors.borderColor, + ), + ), + child: Text( + text, + style: TextStyle( + color: isSelected ? AppColors.teal : AppColors.textColor, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_controller.dart b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_controller.dart new file mode 100644 index 00000000..45d2b33b --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_controller.dart @@ -0,0 +1,115 @@ +import 'package:flutter/foundation.dart'; + +import '../../data/app_repositories.dart'; +import '../../data/model/recipe.dart'; +import 'filter_options.dart'; + +class RecipeListController extends ChangeNotifier { + RecipeListController(this._repositories); + + final AppRepositories _repositories; + + FilterOptions _filterOptions = const FilterOptions(); + FilterOptions get filterOptions => _filterOptions; + + List _tags = const []; + List get tags => _tags; + + List _recipes = const []; + List get recipes => _recipes; + + bool _loading = false; + bool get loading => _loading; + + Future initialize() async { + _loading = true; + notifyListeners(); + try { + final results = await Future.wait([ + _repositories.databaseRepository.getAllRecipes(), + _repositories.databaseRepository.getPopularTags(), + ]); + _recipes = results[0] as List; + _tags = results[1] as List; + } catch (e) { + debugPrint('Error loading recipes and popular tags: $e'); + } finally { + _loading = false; + notifyListeners(); + } + } + + void updateRecipeTitle(String recipeName) { + _filterOptions = _filterOptions.copyWith(recipeTitle: recipeName); + notifyListeners(); + } + + void updateSearchQuery(String query) { + _filterOptions = _filterOptions.copyWith(searchQuery: query); + notifyListeners(); + } + + void updateFilterByMine() { + _filterOptions = _filterOptions.copyWith( + filterByMine: !_filterOptions.filterByMine, + ); + notifyListeners(); + } + + void updateRating(int rating) { + _filterOptions = _filterOptions.copyWith(rating: rating); + notifyListeners(); + } + + void removeTag(String tag) { + _filterOptions = _filterOptions.copyWith( + selectedTags: + _filterOptions.selectedTags.where((item) => item != tag).toList(), + ); + notifyListeners(); + } + + void addTag(String tag) { + _filterOptions = _filterOptions.copyWith( + selectedTags: [..._filterOptions.selectedTags, tag], + ); + notifyListeners(); + } + + void updateSortBy(SortByFilter sortByFilter) { + _filterOptions = _filterOptions.copyWith(sortBy: sortByFilter); + notifyListeners(); + } + + Future resetFilters() async { + _filterOptions = const FilterOptions(); + _loading = true; + notifyListeners(); + try { + _recipes = await _repositories.databaseRepository.getAllRecipes(); + } catch (e) { + debugPrint('Error resetting recipe filters: $e'); + } finally { + _loading = false; + notifyListeners(); + } + } + + Future applyFilters() async { + final filter = _filterOptions; + final userId = _repositories.authRepository.currentUser?.uid ?? ''; + _loading = true; + notifyListeners(); + try { + _recipes = await _repositories.databaseRepository.getFilteredRecipes( + filter, + userId, + ); + } catch (e) { + debugPrint('Error applying recipe filters: $e'); + } finally { + _loading = false; + notifyListeners(); + } + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_screen.dart new file mode 100644 index 00000000..626d4b0c --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_screen.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; + +import '../../data/model/recipe.dart'; +import '../shared/loading_indicator.dart'; +import '../theme/app_theme.dart'; +import 'recipe_list_controller.dart'; + +class RecipeListScreen extends StatefulWidget { + const RecipeListScreen({ + super.key, + required this.controller, + required this.openRecipeScreen, + required this.openFilterScreen, + }); + + final RecipeListController controller; + final ValueChanged openRecipeScreen; + final VoidCallback openFilterScreen; + + @override + State createState() => _RecipeListScreenState(); +} + +class _RecipeListScreenState extends State { + @override + void initState() { + super.initState(); + if (widget.controller.recipes.isEmpty && !widget.controller.loading) { + widget.controller.initialize(); + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + final recipes = widget.controller.recipes; + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(68), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'All Recipes', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + onPressed: widget.openFilterScreen, + tooltip: 'Filter', + icon: const Icon(Icons.filter_list), + ), + ], + ), + ), + ), + ), + body: widget.controller.loading && recipes.isEmpty + ? const Center(child: LoadingIndicator()) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + itemCount: recipes.length, + separatorBuilder: (_, _) => const SizedBox(height: 16), + itemBuilder: (context, index) { + final recipe = recipes[index]; + return RecipeCard( + key: ValueKey(recipe.id), + openRecipeScreen: widget.openRecipeScreen, + recipe: recipe, + ); + }, + ), + ); + }, + ); + } +} + +class RecipeCard extends StatelessWidget { + const RecipeCard({ + super.key, + required this.openRecipeScreen, + required this.recipe, + }); + + final ValueChanged openRecipeScreen; + final RecipeListItem recipe; + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + color: Colors.white, + elevation: 2, + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => openRecipeScreen(recipe.id), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Container( + width: 80, + height: 80, + color: AppColors.lightTeal, + child: recipe.imageUri != null && recipe.imageUri!.isNotEmpty + ? Image.network( + recipe.imageUri!, + fit: BoxFit.cover, + width: 80, + height: 80, + semanticLabel: 'Recipe image', + errorBuilder: (_, _, _) => Image.asset( + 'assets/images/ic_launcher_no_bg.png', + fit: BoxFit.cover, + semanticLabel: 'App icon', + ), + ) + : Image.asset( + 'assets/images/ic_launcher_no_bg.png', + fit: BoxFit.cover, + semanticLabel: 'App icon', + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textColor, + height: 20 / 16, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + for (int index = 1; index <= 5; index++) + _AverageRatingIcon( + index: index, + rating: recipe.averageRating, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AverageRatingIcon extends StatelessWidget { + const _AverageRatingIcon({ + required this.index, + required this.rating, + }); + + final int index; + final double rating; + + @override + Widget build(BuildContext context) { + final IconData iconData; + final Color iconTint; + + if (rating >= index) { + iconData = Icons.star; + iconTint = AppColors.selectedStarColor; + } else if (rating > index - 1) { + iconData = Icons.star_half; + iconTint = AppColors.selectedStarColor; + } else { + iconData = Icons.star; + iconTint = AppColors.unselectedStarColor; + } + + return Icon( + iconData, + size: 16, + color: iconTint, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/scan_meal/scan_meal_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/scan_meal/scan_meal_screen.dart new file mode 100644 index 00000000..2b3f3154 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/scan_meal/scan_meal_screen.dart @@ -0,0 +1,329 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../../data/app_repositories.dart'; +import '../../data/datasource/storage_remote_datasource.dart'; +import '../../data/schema/meal_schema.dart'; +import '../shared/camera_component.dart'; +import '../shared/loading_indicator.dart'; +import '../theme/app_theme.dart'; + +class ScanMealViewState { + const ScanMealViewState({ + this.imageBytes, + this.mealSchema = const MealSchema(), + this.scanLoading = false, + }); + + final Uint8List? imageBytes; + final MealSchema mealSchema; + final bool scanLoading; + + ScanMealViewState copyWith({ + Uint8List? imageBytes, + MealSchema? mealSchema, + bool? scanLoading, + }) { + return ScanMealViewState( + imageBytes: imageBytes ?? this.imageBytes, + mealSchema: mealSchema ?? this.mealSchema, + scanLoading: scanLoading ?? this.scanLoading, + ); + } +} + +class ScanMealScreen extends StatefulWidget { + const ScanMealScreen({ + super.key, + required this.repositories, + required this.showError, + }); + + final AppRepositories repositories; + final VoidCallback showError; + + @override + State createState() => _ScanMealScreenState(); +} + +class _ScanMealScreenState extends State { + ScanMealViewState _viewState = const ScanMealViewState(); + + Future _onImageTaken( + Uint8List? imageBytes, + VoidCallback showError, + ) async { + if (imageBytes == null) { + showError(); + return; + } + + setState(() { + _viewState = _viewState.copyWith( + scanLoading: true, + imageBytes: imageBytes, + ); + }); + + try { + final base64Image = await imageBytesToBase64(imageBytes); + final mealSchema = + await widget.repositories.aiRepository.scanMeal(base64Image); + + if (!mounted) return; + if (mealSchema == null) { + setState(() { + _viewState = _viewState.copyWith(scanLoading: false); + }); + showError(); + return; + } + + setState(() { + _viewState = _viewState.copyWith( + scanLoading: false, + mealSchema: mealSchema, + ); + }); + } catch (e) { + debugPrint('Error scanning meal: $e'); + if (!mounted) return; + setState(() { + _viewState = _viewState.copyWith(scanLoading: false); + }); + showError(); + } + } + + @override + Widget build(BuildContext context) { + final imageBytes = _viewState.imageBytes; + + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(68), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Scan Meal', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + CameraComponent( + onImageTaken: _onImageTaken, + showError: widget.showError, + isTopBarIcon: true, + ), + ], + ), + ), + ), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Container( + width: double.infinity, + height: 275, + color: Colors.grey.shade400, + child: imageBytes != null + ? Image.memory( + imageBytes, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + semanticLabel: 'Recipe image', + ) + : const Center( + child: Text( + 'Could not load image', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 16), + child: Text( + 'Nutritional Facts', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + Row( + children: [ + Expanded( + child: _NutrientCard( + label: 'Protein', + value: _viewState.mealSchema.protein, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _NutrientCard( + label: 'Fat', + value: _viewState.mealSchema.fat, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _NutrientCard( + label: 'Carbs', + value: _viewState.mealSchema.carbs, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _NutrientCard( + label: 'Sugar', + value: _viewState.mealSchema.sugar, + ), + ), + ], + ), + const SizedBox(height: 24), + if (_viewState.scanLoading) ...[ + const SizedBox(height: 48), + const LoadingIndicator(), + ], + if (_viewState.mealSchema.ingredients.isNotEmpty) + _IngredientsCard(ingredients: _viewState.mealSchema.ingredients), + ], + ), + ); + } +} + +class _NutrientCard extends StatelessWidget { + const _NutrientCard({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + decoration: BoxDecoration( + color: AppColors.lightTeal, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Text( + label, + style: const TextStyle( + color: AppColors.teal, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + color: AppColors.textColor, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} + +class _IngredientsCard extends StatelessWidget { + const _IngredientsCard({required this.ingredients}); + + final List ingredients; + + @override + Widget build(BuildContext context) { + return Card( + color: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Identified Ingredients', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + const SizedBox(height: 12), + ...ingredients.map((item) => _IngredientRow(text: item)), + ], + ), + ), + ); + } +} + +class _IngredientRow extends StatelessWidget { + const _IngredientRow({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: const BoxDecoration( + color: AppColors.lightTeal, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: const Icon( + Icons.check, + size: 14, + color: AppColors.teal, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: const TextStyle( + fontSize: 15, + color: AppColors.textColor, + ), + ), + ), + ], + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/shared/audio_component.dart b/firebase-ai-friendly-meals/flutter/lib/ui/shared/audio_component.dart new file mode 100644 index 00000000..3d13c32d --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/shared/audio_component.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; + +class AudioComponent { + AudioPlayer? _player; + StreamSubscription? _completionSub; + + Future play(Uint8List pcmData, VoidCallback onCompletion) async { + await stop(); + + if (pcmData.isEmpty) { + onCompletion(); + return; + } + + try { + final wavBytes = pcmToWav( + pcmData, + sampleRate: 24000, + numChannels: 1, + bitsPerSample: 16, + ); + + final player = AudioPlayer(); + _player = player; + _completionSub = player.onPlayerComplete.listen((_) { + onCompletion(); + }); + + await player.play(BytesSource(wavBytes, mimeType: 'audio/wav')); + } catch (e) { + debugPrint('Failed to initialize or play AudioComponent: $e'); + onCompletion(); + } + } + + Future pause() async { + try { + await _player?.pause(); + } catch (e) { + debugPrint('Error pausing AudioComponent: $e'); + } + } + + Future resume() async { + try { + await _player?.resume(); + } catch (e) { + debugPrint('Error resuming AudioComponent: $e'); + } + } + + Future stop() async { + try { + await _completionSub?.cancel(); + _completionSub = null; + final player = _player; + _player = null; + if (player != null) { + await player.stop(); + await player.dispose(); + } + } catch (e) { + debugPrint('Error stopping AudioComponent: $e'); + } + } + + Future release() async { + await stop(); + } + + /// Wraps raw PCM 16-bit little-endian samples in a standard 44-byte RIFF/WAVE header. + static Uint8List pcmToWav( + Uint8List pcmBytes, { + int sampleRate = 24000, + int numChannels = 1, + int bitsPerSample = 16, + }) { + final byteRate = sampleRate * numChannels * (bitsPerSample ~/ 8); + final blockAlign = numChannels * (bitsPerSample ~/ 8); + final dataLength = pcmBytes.length; + final totalLength = 36 + dataLength; + + final header = ByteData(44); + // "RIFF" + header.setUint8(0, 0x52); + header.setUint8(1, 0x49); + header.setUint8(2, 0x46); + header.setUint8(3, 0x46); + header.setUint32(4, totalLength, Endian.little); + // "WAVE" + header.setUint8(8, 0x57); + header.setUint8(9, 0x41); + header.setUint8(10, 0x56); + header.setUint8(11, 0x45); + // "fmt " + header.setUint8(12, 0x66); + header.setUint8(13, 0x6D); + header.setUint8(14, 0x74); + header.setUint8(15, 0x20); + header.setUint32(16, 16, Endian.little); // Subchunk1Size for PCM + header.setUint16(20, 1, Endian.little); // AudioFormat = 1 (PCM) + header.setUint16(22, numChannels, Endian.little); + header.setUint32(24, sampleRate, Endian.little); + header.setUint32(28, byteRate, Endian.little); + header.setUint16(32, blockAlign, Endian.little); + header.setUint16(34, bitsPerSample, Endian.little); + // "data" + header.setUint8(36, 0x64); + header.setUint8(37, 0x61); + header.setUint8(38, 0x74); + header.setUint8(39, 0x61); + header.setUint32(40, dataLength, Endian.little); + + final wavBytes = Uint8List(44 + dataLength); + wavBytes.setRange(0, 44, header.buffer.asUint8List()); + wavBytes.setRange(44, 44 + dataLength, pcmBytes); + return wavBytes; + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/shared/bottom_nav_bar.dart b/firebase-ai-friendly-meals/flutter/lib/ui/shared/bottom_nav_bar.dart new file mode 100644 index 00000000..b707a774 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/shared/bottom_nav_bar.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +enum BottomNavTab { + scanMeal('Scan Meal', Icons.photo_camera_outlined), + generate('Generate', Icons.auto_awesome_outlined), + recipeList('Recipes', Icons.restaurant_outlined), + groceryList('Grocery List', Icons.fact_check_outlined); + + const BottomNavTab(this.label, this.icon); + final String label; + final IconData icon; +} + +class BottomNavBar extends StatelessWidget { + const BottomNavBar({ + super.key, + required this.selectedTab, + required this.onTabSelected, + }); + + final BottomNavTab selectedTab; + final ValueChanged onTabSelected; + + @override + Widget build(BuildContext context) { + return NavigationBar( + selectedIndex: selectedTab.index, + indicatorColor: Colors.transparent, + onDestinationSelected: (index) { + onTabSelected(BottomNavTab.values[index]); + }, + destinations: BottomNavTab.values.map((tab) { + final isSelected = tab == selectedTab; + return NavigationDestination( + icon: Icon( + tab.icon, + color: isSelected ? AppColors.teal : null, + ), + selectedIcon: Icon( + tab.icon, + color: AppColors.teal, + ), + label: tab.label, + ); + }).toList(), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/shared/camera_component.dart b/firebase-ai-friendly-meals/flutter/lib/ui/shared/camera_component.dart new file mode 100644 index 00000000..35956fb4 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/shared/camera_component.dart @@ -0,0 +1,158 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; +import 'package:image_picker/image_picker.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../theme/app_theme.dart'; + +class CameraComponent extends StatelessWidget { + const CameraComponent({ + super.key, + required this.onImageTaken, + required this.showError, + this.isTopBarIcon = true, + }); + + final void Function(Uint8List? imageBytes, VoidCallback showError) + onImageTaken; + final VoidCallback showError; + final bool isTopBarIcon; + + static final ImagePicker _picker = ImagePicker(); + + Future _takePicture() async { + try { + if (!kIsWeb) { + final status = await Permission.camera.request(); + if (!status.isGranted) { + return; + } + } + + final XFile? photo = await _picker.pickImage( + source: ImageSource.camera, + preferredCameraDevice: CameraDevice.rear, + ); + if (photo == null) { + return; + } + + final rawBytes = await photo.readAsBytes(); + final bakedBytes = await compute(_bakeOrientation, rawBytes); + onImageTaken(bakedBytes, showError); + } catch (e) { + debugPrint('Error taking picture: $e'); + showError(); + } + } + + static Uint8List _bakeOrientation(Uint8List bytes) { + final decoded = img.decodeImage(bytes); + if (decoded == null) return bytes; + final oriented = img.bakeOrientation(decoded); + return Uint8List.fromList(img.encodeJpg(oriented, quality: 90)); + } + + @override + Widget build(BuildContext context) { + if (isTopBarIcon) { + return IconButton( + onPressed: _takePicture, + tooltip: 'Camera', + icon: const Icon(Icons.photo_camera_outlined), + ); + } + + return CustomPaint( + painter: _DashedRoundedRectPainter( + color: Colors.grey.shade400, + strokeWidth: 2, + radius: 16, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: _takePicture, + child: const SizedBox( + width: double.infinity, + height: 56, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.photo_camera_outlined, + color: AppColors.teal, + size: 24, + ), + SizedBox(width: 8), + Text( + 'Take a picture of ingredients', + style: TextStyle( + color: AppColors.teal, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _DashedRoundedRectPainter extends CustomPainter { + _DashedRoundedRectPainter({ + required this.color, + required this.strokeWidth, + required this.radius, + }); + + final Color color; + final double strokeWidth; + final double radius; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke; + + final rrect = RRect.fromRectAndRadius( + Offset.zero & size, + Radius.circular(radius), + ); + final path = Path()..addRRect(rrect); + final dashedPath = _createDashedPath(path, dashWidth: 10, dashSpace: 8); + canvas.drawPath(dashedPath, paint); + } + + Path _createDashedPath( + Path source, { + required double dashWidth, + required double dashSpace, + }) { + final dest = Path(); + for (final ui.PathMetric metric in source.computeMetrics()) { + double distance = 0; + while (distance < metric.length) { + final next = (distance + dashWidth).clamp(0.0, metric.length); + dest.addPath(metric.extractPath(distance, next), Offset.zero); + distance += dashWidth + dashSpace; + } + } + return dest; + } + + @override + bool shouldRepaint(covariant _DashedRoundedRectPainter oldDelegate) { + return oldDelegate.color != color || + oldDelegate.strokeWidth != strokeWidth || + oldDelegate.radius != radius; + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/shared/loading_indicator.dart b/firebase-ai-friendly-meals/flutter/lib/ui/shared/loading_indicator.dart new file mode 100644 index 00000000..2e4e6b81 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/shared/loading_indicator.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +class LoadingIndicator extends StatelessWidget { + const LoadingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only(bottom: 24), + child: SizedBox( + width: double.infinity, + child: Center( + child: SizedBox( + width: 64, + height: 64, + child: CircularProgressIndicator( + color: AppColors.teal, + backgroundColor: AppColors.lightTeal, + ), + ), + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/shared/rating_button.dart b/firebase-ai-friendly-meals/flutter/lib/ui/shared/rating_button.dart new file mode 100644 index 00000000..58ee82c4 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/shared/rating_button.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +class RatingButton extends StatelessWidget { + const RatingButton({ + super.key, + required this.rating, + required this.isSelected, + required this.isFilled, + required this.onClick, + }); + + final int rating; + final bool isSelected; + final bool isFilled; + final VoidCallback onClick; + + @override + Widget build(BuildContext context) { + final Color iconTint; + if (isSelected) { + iconTint = Colors.white; + } else if (isFilled) { + iconTint = AppColors.selectedStarColor; + } else { + iconTint = AppColors.unselectedStarColor; + } + + return Semantics( + label: '$rating Stars', + button: true, + child: InkWell( + onTap: onClick, + customBorder: const CircleBorder(), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? AppColors.teal : Colors.white, + border: Border.all( + width: 1, + color: isSelected ? AppColors.teal : AppColors.borderColor, + ), + ), + alignment: Alignment.center, + child: Icon( + Icons.star, + size: 24, + color: iconTint, + ), + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/theme/app_theme.dart b/firebase-ai-friendly-meals/flutter/lib/ui/theme/app_theme.dart new file mode 100644 index 00000000..607736d1 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/theme/app_theme.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const Color teal = Color(0xFF1EB980); + static const Color lightTeal = Color(0xFFE0F2F1); + static const Color lightGray = Color(0xFFF3F4F6); + static const Color charcoal = Color(0xFF36454F); + static const Color textColor = Color(0xFF1F2937); + static const Color lightTextColor = Color(0xFFF8F9FA); + static const Color selectedStarColor = Color(0xFFFFC107); + static const Color unselectedStarColor = Color(0xFF9CA3AF); + static const Color borderColor = Color(0xFFE5E7EB); +} + +class FriendlyMealsTheme { + static ThemeData get lightTheme { + const colorScheme = ColorScheme.light( + primary: AppColors.teal, + secondary: AppColors.lightTeal, + surface: AppColors.lightGray, + onPrimary: Colors.white, + onSecondary: Colors.black, + onSurface: AppColors.textColor, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + primaryColor: AppColors.teal, + scaffoldBackgroundColor: AppColors.lightGray, + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.lightGray, + foregroundColor: AppColors.textColor, + elevation: 0, + scrolledUnderElevation: 0, + ), + textTheme: const TextTheme( + bodyLarge: TextStyle( + fontSize: 16, + height: 24 / 16, + letterSpacing: 0.5, + color: AppColors.textColor, + ), + bodyMedium: TextStyle( + color: AppColors.textColor, + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/pubspec.lock b/firebase-ai-friendly-meals/flutter/pubspec.lock index ee427427..fbdf3ff0 100644 --- a/firebase-ai-friendly-meals/flutter/pubspec.lock +++ b/firebase-ai-friendly-meals/flutter/pubspec.lock @@ -5,10 +5,18 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: bb84ee51e527053dd8e25ecc9f97a6abfdc19130fb4d883e4e8585e23e7e6dd8 + sha256: f6966125633a34f82d9be2ee895b755cff3554087b1d00a9eb884e5947f4bca9 url: "https://pub.dev" source: hosted - version: "1.3.60" + version: "1.3.77" + archive: + dependency: transitive + description: + name: archive + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" + url: "https://pub.dev" + source: hosted + version: "4.5.2" args: dependency: transitive description: @@ -25,6 +33,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.dev" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.dev" + source: hosted + version: "4.4.1" boolean_selector: dependency: transitive description: @@ -33,14 +97,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + camera: + dependency: "direct main" + description: + name: camera + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" + url: "https://pub.dev" + source: hosted + version: "0.11.4" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" + url: "https://pub.dev" + source: hosted + version: "0.6.30" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" + url: "https://pub.dev" + source: hosted + version: "0.9.23+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: b3083b183ef1daa408d5f0c98c8c7ac0325b490695fb4557ff4210fcf04c5984 + url: "https://pub.dev" + source: hosted + version: "2.15.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "5aecf1ac4dd4a949c5a7820e590020acb2bc35425384ab87a030e1739bfe12e2" + url: "https://pub.dev" + source: hosted + version: "0.3.5+6" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -49,6 +153,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + sha256: db785e4d0a995654781835b78a30b8bb203d967e123843bb918df0cdb6a7ec69 + url: "https://pub.dev" + source: hosted + version: "6.10.0" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + sha256: ef985a8239846187de0eccce2092a940eaf281a1357c6325e1739ec0b6c230e2 + url: "https://pub.dev" + source: hosted + version: "8.0.7" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + sha256: b30b3b44937345d50b195f85ef328f1d71c2a79965cb1645a479d029d45f636a + url: "https://pub.dev" + source: hosted + version: "5.7.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -57,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -73,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" fake_async: dependency: transitive description: @@ -81,86 +233,214 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" firebase_ai: dependency: "direct main" description: name: firebase_ai - sha256: b32d38bc0c335d6161baa7240bac70073891e9434839f9a7f8d95fc06225b1ad + sha256: ac2105dc4fd48f4978006cf7b99477e51b36b4d53a84bce588a5817550368e43 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.15.0" firebase_app_check: - dependency: transitive + dependency: "direct main" description: name: firebase_app_check - sha256: dae47c346b1b8725292da6effcdf15036e9fb7e30a50b14b4493c5c6b30d5275 + sha256: cb69814b369e421e249bd0095705c843c7607502b3f2a41ac99b9addc8b04ffc url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.8" firebase_app_check_platform_interface: dependency: transitive description: name: firebase_app_check_platform_interface - sha256: "2ef7b3e309d9de4c62884f421773a95a55324c7ddde4f057f2ed31e808439d8c" + sha256: "87909de3a8952e69ab0d8b65b89311f3aebf3adb89acbfb0e65c00e57f78eb7e" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.4.2+1" firebase_app_check_web: dependency: transitive description: name: firebase_app_check_web - sha256: "2c6d9bb52a53a6e43980954f4174a3add692cde4f6fff380070bd3debf6e205c" + sha256: e1072f46b66c1530820ec617909576924a685e2cf7b78e62acd6af82b50609c4 url: "https://pub.dev" source: hosted - version: "0.2.0+15" + version: "0.2.6+1" firebase_auth: - dependency: transitive + dependency: "direct main" description: name: firebase_auth - sha256: abd15b7287285390c4ac144fed7825a1877496366e0d15ffc0ddfa9774deb3f3 + sha256: "144fa22926b629e797ecc757db5d31b145aeedf6018160296dae945d98a98fb5" url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.7.0" firebase_auth_platform_interface: dependency: transitive description: name: firebase_auth_platform_interface - sha256: c1d8a2f40980fbae191598e80431dd1228ddaeba17f96f4fe14babab721bdf3d + sha256: "9959a6e7338df4f725707a71fd9f727d8dfafd7c46cbaf59fcf7c8e94fb92d11" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "9.1.0" firebase_auth_web: dependency: transitive description: name: firebase_auth_web - sha256: "337e01f3bb30de85b74634c33712c57f76cc15f7be9d35b24aab9e95e451a4e2" + sha256: "11861819c667e9f47269fa1e7fca8d0e5229f80d4d3008d44b2108b8c93bfd94" url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.3.0" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "6b343e6f7b72a4f32d7ce8df8c9a28d8f54b4ac20d7c6500f3e8b3969afca457" + sha256: "5fbbaa2aeb08a52feee559f92a62f5a6b04c9bdff81c8f608636771cf0d7b680" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.15.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: "5dbc900677dcbe5873d22ad7fbd64b047750124f1f9b7ebe2a33b9ddccc838eb" + sha256: "9bfbc85faca09346471ac56fbcee00757ebcfd85887c6f602764eff0001902d8" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "8.1.1" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "5d28b14dd32282fb7ce2b22b897362453755b6b8541d491127dc72b755bb7b16" + sha256: "8ed84f5001dbd7a0390e2818d46bc858ceebb970edededdfc8d6552c76fc2f6e" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.12.0" + firebase_performance: + dependency: "direct main" + description: + name: firebase_performance + sha256: fec5be59b101c17c091f5beb8c849b5999d5e84c254c31472a3058af0eb3f192 + url: "https://pub.dev" + source: hosted + version: "0.11.6" + firebase_performance_platform_interface: + dependency: transitive + description: + name: firebase_performance_platform_interface + sha256: b598cea7051fb9f3abd3eddbcabc7dd9d2c6a7ab06e264c8c2d2c935a20c12aa + url: "https://pub.dev" + source: hosted + version: "0.2.0+7" + firebase_performance_web: + dependency: transitive + description: + name: firebase_performance_web + sha256: "424d695ea235b4aad3902cabe66f7c6197b4a9e66f0f151c14850100fb4fa45d" + url: "https://pub.dev" + source: hosted + version: "0.1.8+13" + firebase_remote_config: + dependency: "direct main" + description: + name: firebase_remote_config + sha256: fa518445638fa3903e359ac544bd3243e72ada025728c8dbfa4b28ae939e8b38 + url: "https://pub.dev" + source: hosted + version: "6.7.0" + firebase_remote_config_platform_interface: + dependency: transitive + description: + name: firebase_remote_config_platform_interface + sha256: dc5a9713031f2ca744cb266d96a58609e929db19f3d91a1dd6bb8f8a0d77acad + url: "https://pub.dev" + source: hosted + version: "3.0.7" + firebase_remote_config_web: + dependency: transitive + description: + name: firebase_remote_config_web + sha256: "500abf695c370c76a486ad7480a6df7caf55d2d35ccc5c66b9fc9c1c47f2bc14" + url: "https://pub.dev" + source: hosted + version: "1.10.14" + firebase_storage: + dependency: "direct main" + description: + name: firebase_storage + sha256: f936f99ea1f49f644d5e83a495ed71bd89b040358e548f9dee14f10458d49ea0 + url: "https://pub.dev" + source: hosted + version: "13.6.0" + firebase_storage_platform_interface: + dependency: transitive + description: + name: firebase_storage_platform_interface + sha256: "249a8cfa965f0bfb5ed6b6e14fa9b63428ba3c71ffe73b0e360da8d2fde2ef92" + url: "https://pub.dev" + source: hosted + version: "6.0.7" + firebase_storage_web: + dependency: transitive + description: + name: firebase_storage_web + sha256: b8a2e1f8a013d192f7b6e09ad40ec50e13d0bc97d2a3462369b2ba3f368d9c78 + url: "https://pub.dev" + source: hosted + version: "3.11.13" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -182,6 +462,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7+1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_test: dependency: "direct dev" description: flutter @@ -192,6 +480,86 @@ packages: description: flutter source: sdk version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + url: "https://pub.dev" + source: hosted + version: "14.0.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "1be90ea8aaaf9db8e7816b4fe0387f082ee8b5cf03355912dad90fb0a75717fa" + url: "https://pub.dev" + source: hosted + version: "5.1.1+1" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: "3b3189beba8b59b2a0eee970b466e991e4ac1564a9034f82a825a3c45c4be1fa" + url: "https://pub.dev" + source: hosted + version: "2.3.15" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: b640b6b687b14c66f51fe1130b1c2e8babbd408e0158052b92006b6675ca77fc + url: "https://pub.dev" + source: hosted + version: "4.4.0" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http: dependency: transitive description: @@ -208,30 +576,134 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: e71b1bd9e40124041128d60fc3034c7b1598e1bba4e570bba0c70301c817013a + url: "https://pub.dev" + source: hosted + version: "1.2.4" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "8a7823d5d3e884f7780829d85d92110d072bdcd9ed48e2be158d2abe91a912d4" + url: "https://pub.dev" + source: hosted + version: "0.8.13+25" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "4fb88d4350ffc208c2a09fc321db1f80282688716b8cfe10c8c80acc5ae8934a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "2dee4b4e3be429db71472c9c559c981498070a61af1ba82e0e4986fc5dadc042" + url: "https://pub.dev" + source: hosted + version: "2.11.2" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -240,6 +712,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" markdown: dependency: transitive description: @@ -252,26 +732,66 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "2.1.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -280,6 +800,118 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 + url: "https://pub.dev" + source: hosted + version: "12.0.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "066bbbc60031b63b916a3871e7bb4b6a8c5e7cec8146dafec53d1da304eff0a5" + url: "https://pub.dev" + source: hosted + version: "9.6.2" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: ed86a61c190258fdd65de395ea0632822e3415c1faec38eae0c31b479c28a531 + url: "https://pub.dev" + source: hosted + version: "4.4.1" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" plugin_platform_interface: dependency: transitive description: @@ -288,6 +920,94 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record: + dependency: "direct main" + description: + name: record + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" sky_engine: dependency: transitive description: flutter @@ -317,6 +1037,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" string_scanner: dependency: transitive description: @@ -325,6 +1053,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 + url: "https://pub.dev" + source: hosted + version: "3.5.0" term_glyph: dependency: transitive description: @@ -337,10 +1073,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.11" typed_data: dependency: transitive description: @@ -349,14 +1085,86 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -389,6 +1197,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" sdks: - dart: ">=3.8.0-278.1.beta <4.0.0" - flutter: ">=3.22.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/firebase-ai-friendly-meals/flutter/pubspec.yaml b/firebase-ai-friendly-meals/flutter/pubspec.yaml index 3d2b9741..f363aa76 100644 --- a/firebase-ai-friendly-meals/flutter/pubspec.yaml +++ b/firebase-ai-friendly-meals/flutter/pubspec.yaml @@ -1,92 +1,43 @@ -name: myapp -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +name: friendly_meals +description: "FriendlyMeals Flutter sample app." +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. version: 1.0.0+1 environment: - sdk: ^3.8.0-278.1.beta + sdk: ^3.8.0 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 flutter_markdown: ^0.7.7+1 firebase_core: ^4.0.0 - firebase_ai: ^3.1.0 + firebase_ai: ^3.13.1 + firebase_auth: ^6.0.0 + cloud_firestore: ^6.0.0 + firebase_storage: ^13.0.0 + firebase_remote_config: ^6.0.0 + firebase_app_check: ^0.4.0 + firebase_performance: ^0.11.0 + image_picker: ^1.2.0 + camera: ^0.11.0 + record: ^6.0.0 + audioplayers: ^6.0.0 + geolocator: ^14.0.0 + permission_handler: ^12.0.0 + url_launcher: ^6.3.0 + uuid: ^4.5.0 + image: ^4.5.0 + intl: ^0.20.0 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^5.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + assets: + - assets/images/ic_launcher_no_bg.png diff --git a/firebase-ai-friendly-meals/flutter/test/widget_test.dart b/firebase-ai-friendly-meals/flutter/test/widget_test.dart index 38decd37..12191fbe 100644 --- a/firebase-ai-friendly-meals/flutter/test/widget_test.dart +++ b/firebase-ai-friendly-meals/flutter/test/widget_test.dart @@ -1,30 +1,112 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:myapp/main.dart'; +import 'package:friendly_meals/data/model/grocery_item.dart'; +import 'package:friendly_meals/data/model/recipe.dart'; +import 'package:friendly_meals/data/schema/meal_schema.dart'; +import 'package:friendly_meals/data/schema/recipe_schema.dart'; +import 'package:friendly_meals/data/schema/store_schema.dart'; +import 'package:friendly_meals/ui/shared/bottom_nav_bar.dart'; +import 'package:friendly_meals/ui/shared/rating_button.dart'; +import 'package:friendly_meals/ui/theme/app_theme.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('BottomNavBar renders all 4 FriendlyMeals tabs and switches tabs', + (WidgetTester tester) async { + BottomNavTab currentTab = BottomNavTab.generate; + + await tester.pumpWidget( + MaterialApp( + theme: FriendlyMealsTheme.lightTheme, + home: StatefulBuilder( + builder: (context, setState) { + return Scaffold( + bottomNavigationBar: BottomNavBar( + selectedTab: currentTab, + onTabSelected: (tab) => setState(() => currentTab = tab), + ), + ); + }, + ), + ), + ); + + expect(find.text('Scan Meal'), findsOneWidget); + expect(find.text('Generate'), findsOneWidget); + expect(find.text('Recipes'), findsOneWidget); + expect(find.text('Grocery List'), findsOneWidget); + + await tester.tap(find.text('Grocery List')); + await tester.pump(); + expect(currentTab, BottomNavTab.groceryList); + }); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + testWidgets('RatingButton renders and triggers callback', + (WidgetTester tester) async { + int? tappedRating; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RatingButton( + rating: 4, + isSelected: true, + isFilled: false, + onClick: () => tappedRating = 4, + ), + ), + ), + ); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); + await tester.tap(find.byIcon(Icons.star)); await tester.pump(); + expect(tappedRating, 4); + }); + + test('Schemas and models serialize and deserialize accurately', () { + final recipeSchema = RecipeSchema.fromJson({ + 'title': 'Pasta Carbonara', + 'prepTime': '10 mins', + 'cookTime': '15 mins', + 'servings': '2', + 'ingredients': ['Pasta', 'Eggs', 'Pecorino'], + 'instructions': 'Boil pasta and mix.', + 'tags': ['Italian', 'Quick'], + }); + expect(recipeSchema.title, 'Pasta Carbonara'); + expect(recipeSchema.tags, ['Italian', 'Quick']); + + final mealSchema = MealSchema.fromJson({ + 'ingredients': ['Chicken', 'Rice'], + 'fat': '12g', + 'carbs': '45g', + 'sugar': '2g', + 'protein': '35g', + }); + expect(mealSchema.protein, '35g'); + + final storeResult = StoreFinderResult.fromJson({ + 'stores': [ + { + 'name': 'Fresh Market', + 'address': '123 Main St', + 'distance': '0.5 mi', + 'openNow': true, + 'closingSoon': false, + 'hasParking': true, + 'parkingDetails': 'Free lot', + 'mapUrl': 'https://maps.google.com', + } + ], + }); + expect(storeResult.stores.single.name, 'Fresh Market'); + + final recipe = Recipe.fromMap({'title': recipeSchema.title}); + expect(recipe.title, 'Pasta Carbonara'); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + final grocery = GroceryItem.fromMap( + {'userId': 'u1', 'name': 'Tomatoes', 'checked': true}, + docId: 'g1', + ); + expect(grocery.id, 'g1'); + expect(grocery.checked, true); }); }