From 74779b0816cc00e499403d58ede0b2b84afe8db1 Mon Sep 17 00:00:00 2001 From: peterfriese Date: Sat, 22 Nov 2025 14:31:38 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Add=20remote=20config=20and=20s?= =?UTF-8?q?tructure=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FriendlyMeals.xcodeproj/project.pbxproj | 8 ++ .../Services/GenerationConfig+Decodable.swift | 48 ++++++++ .../Services/RemoteConfigService.swift | 91 ++++++++++++++ .../Services/UsageTrackingService.swift | 56 +++++++++ .../Features/SuggestRecipe/PaywallView.swift | 113 +++++++++++++++++ .../Features/SuggestRecipe/Recipe.swift | 32 +++++ .../SuggestRecipeDetailsView.swift | 116 +++++++++++++----- .../SuggestRecipe/SuggestRecipeView.swift | 13 +- .../SuggestRecipeViewModel.swift | 81 ++++++++---- .../FriendlyMeals/FriendlyMealsApp.swift | 14 ++- .../remote_config_defaults.plist | 18 +++ .../FriendlyMeals/remote_config_template.json | 22 ++++ 12 files changed, 556 insertions(+), 56 deletions(-) create mode 100644 firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/GenerationConfig+Decodable.swift create mode 100644 firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/RemoteConfigService.swift create mode 100644 firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift create mode 100644 firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/PaywallView.swift create mode 100644 firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/Recipe.swift create mode 100644 firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/remote_config_defaults.plist create mode 100644 firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/remote_config_template.json diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals.xcodeproj/project.pbxproj b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals.xcodeproj/project.pbxproj index 00511e2..7c712b8 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals.xcodeproj/project.pbxproj +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 884B2CF22E144BA7007B2E0E /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 884B2CF12E144BA7007B2E0E /* MarkdownUI */; }; 88662FF02ECF66EF001DA000 /* ConversationKit in Frameworks */ = {isa = PBXBuildFile; productRef = 88662FEF2ECF66EF001DA000 /* ConversationKit */; }; + 88AD6DDF2ED1EAF3004F8BAE /* FirebaseRemoteConfig in Frameworks */ = {isa = PBXBuildFile; productRef = 88AD6DDE2ED1EAF3004F8BAE /* FirebaseRemoteConfig */; }; 88BE941E2E1413D000C81FF5 /* FirebaseAI in Frameworks */ = {isa = PBXBuildFile; productRef = 88BE941D2E1413D000C81FF5 /* FirebaseAI */; }; 88C8FB7A2E13DF62001B86C4 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 88C8FB792E13DF62001B86C4 /* FirebaseCore */; }; /* End PBXBuildFile section */ @@ -34,6 +35,7 @@ 88C8FB7A2E13DF62001B86C4 /* FirebaseCore in Frameworks */, 88662FF02ECF66EF001DA000 /* ConversationKit in Frameworks */, 88BE941E2E1413D000C81FF5 /* FirebaseAI in Frameworks */, + 88AD6DDF2ED1EAF3004F8BAE /* FirebaseRemoteConfig in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -88,6 +90,7 @@ 88BE941D2E1413D000C81FF5 /* FirebaseAI */, 884B2CF12E144BA7007B2E0E /* MarkdownUI */, 88662FEF2ECF66EF001DA000 /* ConversationKit */, + 88AD6DDE2ED1EAF3004F8BAE /* FirebaseRemoteConfig */, ); productName = FriendlyMeals; productReference = 88C8FB652E13D879001B86C4 /* FriendlyMeals.app */; @@ -397,6 +400,11 @@ package = 88662FEE2ECF66EF001DA000 /* XCRemoteSwiftPackageReference "ConversationKit" */; productName = ConversationKit; }; + 88AD6DDE2ED1EAF3004F8BAE /* FirebaseRemoteConfig */ = { + isa = XCSwiftPackageProductDependency; + package = 88C8FB772E13DEE1001B86C4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseRemoteConfig; + }; 88BE941D2E1413D000C81FF5 /* FirebaseAI */ = { isa = XCSwiftPackageProductDependency; package = 88C8FB772E13DEE1001B86C4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/GenerationConfig+Decodable.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/GenerationConfig+Decodable.swift new file mode 100644 index 0000000..e446754 --- /dev/null +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/GenerationConfig+Decodable.swift @@ -0,0 +1,48 @@ +// +// GenerationConfig+Decodable.swift +// FriendlyMeals +// +// Created by Peter Friese on 26.09.25. +// + +import Foundation +import FirebaseAI + +extension ResponseModality: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + switch rawValue { + case "TEXT": + self = .text + case "IMAGE": + self = .image + default: + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Invalid ResponseModality raw value '\(rawValue)'" + ) + } + } +} + +extension GenerationConfig: Decodable { + private enum CodingKeys: String, CodingKey { + case temperature + case topP + case topK + case maxOutputTokens + case responseModalities + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let temperature = try container.decodeIfPresent(Float.self, forKey: .temperature) + let topP = try container.decodeIfPresent(Float.self, forKey: .topP) + let topK = try container.decodeIfPresent(Int.self, forKey: .topK) + let maxOutputTokens = try container.decodeIfPresent(Int.self, forKey: .maxOutputTokens) + let responseModalities = try container.decodeIfPresent([ResponseModality].self, forKey: .responseModalities) ?? [] + + self.init(temperature: temperature, topP: topP, topK: topK, maxOutputTokens: maxOutputTokens, responseModalities: responseModalities) + } +} diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/RemoteConfigService.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/RemoteConfigService.swift new file mode 100644 index 0000000..3158063 --- /dev/null +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/RemoteConfigService.swift @@ -0,0 +1,91 @@ +// +// RemoteConfigService.swift +// FriendlyMeals +// +// Created by Peter Friese on 26.09.25. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import FirebaseRemoteConfig +import FirebaseAI + +fileprivate enum RemoteConfigKey: String { + case maxImagesPerDay = "max_images_per_day" + case modelName = "model_name" + case generationConfig = "generation_config" +} + +@Observable +class RemoteConfigService { + static let shared = RemoteConfigService() + + var maxImagesPerDay: Int = 5 + var modelName: String = "gemini-2.0-flash-preview-image-generation" + var generationConfig: GenerationConfig? + + private var remoteConfig: RemoteConfig + + private init() { + remoteConfig = RemoteConfig.remoteConfig() + let settings = RemoteConfigSettings() + settings.minimumFetchInterval = 0 + remoteConfig.configSettings = settings + setDefaults() + listenForUpdates() + } + + private func setDefaults() { + remoteConfig.setDefaults(fromPlist: "remote_config_defaults") + } + + private func listenForUpdates() { + remoteConfig.addOnConfigUpdateListener { [weak self] configUpdate, error in + guard let self = self else { return } + if let error = error { + print("Error listening for config updates: \(error.localizedDescription)") + return + } + + print("Updated keys: \(String(describing: configUpdate?.updatedKeys))") + Task { @MainActor in + do { + let changed = try await self.remoteConfig.activate() + if changed { + self.updateParameters() + } + } + catch { + print("Error activating config: \(error.localizedDescription)") + } + } + } + } + + private func updateParameters() { + maxImagesPerDay = remoteConfig[RemoteConfigKey.maxImagesPerDay.rawValue].numberValue.intValue + modelName = remoteConfig[RemoteConfigKey.modelName.rawValue].stringValue + do { + generationConfig = try remoteConfig[RemoteConfigKey.generationConfig.rawValue].decoded(asType: GenerationConfig.self) + } + catch { + print("Error decoding generation config: \(error.localizedDescription)") + } + } + + func fetchConfig() async throws { + try await remoteConfig.fetch() + try await remoteConfig.activate() + self.updateParameters() + } +} diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift new file mode 100644 index 0000000..b0536da --- /dev/null +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift @@ -0,0 +1,56 @@ +// +// UsageTrackingService.swift +// FriendlyMeals +// +// Created by Peter Friese on 26.09.25. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +class UsageTrackingService { + static let shared = UsageTrackingService() + + private let userDefaults = UserDefaults.standard + private let generationCountKey = "generationCount" + private let lastGenerationDateKey = "lastGenerationDate" + + private init() {} + + func canGenerate() -> Bool { + resetCountIfNeeded() + let count = userDefaults.integer(forKey: generationCountKey) + let maxImagesPerDay = RemoteConfigService.shared.maxImagesPerDay + print("Checking if user can generate images. Count: \(count), Max: \(maxImagesPerDay)") + return count < maxImagesPerDay + } + + func incrementGenerationCount() { + resetCountIfNeeded() + let count = userDefaults.integer(forKey: generationCountKey) + userDefaults.set(count + 1, forKey: generationCountKey) + } + + private func resetCountIfNeeded() { + guard let lastGenerationDate = userDefaults.object(forKey: lastGenerationDateKey) as? Date else { + userDefaults.set(Date(), forKey: lastGenerationDateKey) + userDefaults.set(0, forKey: generationCountKey) + return + } + + if !Calendar.current.isDateInToday(lastGenerationDate) { + userDefaults.set(Date(), forKey: lastGenerationDateKey) + userDefaults.set(0, forKey: generationCountKey) + } + } +} diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/PaywallView.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/PaywallView.swift new file mode 100644 index 0000000..fb384c7 --- /dev/null +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/PaywallView.swift @@ -0,0 +1,113 @@ +// +// PaywallView.swift +// FriendlyMeals +// +// Created by Peter Friese on 26.09.25. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +struct PaywallView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + ZStack(alignment: .topTrailing) { + // Close Button + Button(action: { dismiss() }) { + Image(systemName: "xmark.circle.fill") + .font(.title) + .foregroundColor(.gray.opacity(0.6)) + } + .padding() + + VStack(spacing: 20) { + Spacer() + + // Icon + Image(systemName: "crown.fill") + .font(.system(size: 60)) + .foregroundColor(.yellow) + + // Title and Subtitle + Text("Upgrade to Premium") + .font(.largeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Text("Unlock unlimited recipe generations and more!") + .font(.headline) + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + + Spacer() + + // Feature List + VStack(alignment: .leading, spacing: 15) { + FeatureView(text: "Unlimited recipe suggestions") + FeatureView(text: "Generate images for every recipe") + FeatureView(text: "Save your favorite recipes") + FeatureView(text: "Access exclusive meal plans") + } + .padding(.horizontal) + + Spacer() + + // Call to Action Button + Button(action: { + // Mock action + print("Upgrade button tapped!") + dismiss() + }) { + Text("Unlock Premium") + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .cornerRadius(12) + } + .padding(.horizontal, 40) + + // Restore Purchases Button + Button(action: { + // Mock action + print("Restore purchases tapped!") + }) { + Text("Restore Purchases") + .font(.footnote) + .foregroundColor(.secondary) + } + .padding(.bottom) + } + .padding() + } + } +} + +struct FeatureView: View { + let text: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + Text(text) + } + } +} + +#Preview { + PaywallView() +} diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/Recipe.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/Recipe.swift new file mode 100644 index 0000000..bc9a4da --- /dev/null +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/Recipe.swift @@ -0,0 +1,32 @@ +// +// Recipe.swift +// FriendlyMeals +// +// Created by Peter Friese on 01.07.25. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +struct Recipe: Decodable { + struct Ingredient: Decodable { + var name: String + var amount: String + } + + var title: String + var description: String + var cookingTime: Int + var ingredients: [Ingredient] + var instructions: [String] +} diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeDetailsView.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeDetailsView.swift index 006bcca..df1e7f5 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeDetailsView.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeDetailsView.swift @@ -15,20 +15,26 @@ // See the License for the specific language governing permissions and // limitations under the License. -import MarkdownUI import SwiftUI struct SuggestRecipeDetailsView { @Environment(\.dismiss) private var dismiss - let recipe: String + let recipe: Recipe? let image: UIImage? + let errorMessage: String? + + init(recipe: Recipe?, image: UIImage?, errorMessage: String? = nil) { + self.recipe = recipe + self.image = image + self.errorMessage = errorMessage + } } extension SuggestRecipeDetailsView: View { var body: some View { ScrollView { - VStack { + VStack(alignment: .leading) { if let image { Image(uiImage: image) .resizable() @@ -36,40 +42,88 @@ extension SuggestRecipeDetailsView: View { .cornerRadius(8) .padding(.horizontal) } - Markdown(recipe) - .frame( - maxWidth: .infinity, - maxHeight: .infinity, - alignment: .topLeading - ) - .padding(.horizontal) + if let recipe { + VStack(alignment: .leading, spacing: 16) { + Text(recipe.title) + .font(.largeTitle) + .bold() + + Text(recipe.description) + .font(.body) + + HStack { + Image(systemName: "clock") + Text("Cooking time: \(recipe.cookingTime) minutes") + } + .font(.subheadline) + .foregroundStyle(.secondary) + + Section("Ingredients") { + ForEach(recipe.ingredients, id: \.name) { ingredient in + HStack(alignment: .top) { + Text("•") + Text("\(ingredient.amount) \(ingredient.name)") + } + } + } + + Section("Instructions") { + ForEach(Array(recipe.instructions.enumerated()), id: \.offset) { index, instruction in + HStack(alignment: .top) { + Text("\(index + 1).") + Text(instruction) + } + } + } + } + .padding() + } + if let errorMessage { + Text(errorMessage) + .font(.body) + .padding() + } } } } } -#Preview("Direct View") { +#Preview("With Recipe") { SuggestRecipeDetailsView( - recipe: """ - # Chicken Alfredo Pasta - - ## Ingredients - - 8 oz fettuccine pasta - - 2 boneless chicken breasts - - 2 tbsp butter - - 1 cup heavy cream - - 1 cup grated parmesan cheese - - Salt and pepper to taste - - ## Instructions - 1. Cook pasta according to package instructions - 2. Season and cook chicken until golden - 3. Slice chicken and set aside - 4. In the same pan, melt butter and add cream - 5. Stir in parmesan until smooth - 6. Add chicken and pasta to sauce - 7. Toss to coat and serve hot - """, + recipe: Recipe( + title: "Mushroom Risotto", + description: "A creamy and delicious risotto with mushrooms.", + cookingTime: 45, + ingredients: [ + .init(name: "Arborio rice", amount: "1 cup"), + .init(name: "Mushrooms", amount: "200g"), + .init(name: "Vegetable broth", amount: "4 cups"), + .init(name: "Onion", amount: "1"), + .init(name: "Parmesan cheese", amount: "1/2 cup"), + .init(name: "White wine", amount: "1/4 cup"), + .init(name: "Olive oil", amount: "2 tbsp"), + .init(name: "Garlic", amount: "2 cloves"), + .init(name: "Butter", amount: "2 tbsp"), + .init(name: "Salt and pepper", amount: "to taste") + ], + instructions: [ + "In a large pot, heat olive oil over medium heat. Add chopped onion and garlic and cook until softened.", + "Add the rice and stir for 1 minute until toasted.", + "Pour in the white wine and cook until it has been absorbed, stirring constantly.", + "Add the vegetable broth, one ladle at a time, waiting until it is absorbed before adding more.", + "In a separate pan, cook the mushrooms with butter until browned.", + "Once the rice is cooked, stir in the mushrooms, parmesan cheese, salt, and pepper.", + "Serve immediately." + ] + ), image: UIImage(systemName: "photo") ) } + +#Preview("With Error") { + SuggestRecipeDetailsView( + recipe: nil, + image: nil, + errorMessage: "An error occurred while generating the recipe: The operation couldn’t be completed. (GoogleGenerativeAI.GenerateContentError error 1.)" + ) +} diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeView.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeView.swift index 290b5b0..f3cbfde 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeView.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeView.swift @@ -41,8 +41,14 @@ struct SuggestRecipeView: View { Section { Button(action: { Task { - await viewModel.generateRecipe() + do { + try await viewModel.generateRecipe() + } + catch { + print(error.localizedDescription) + } } + }) { if viewModel.isGenerating { ProgressView() @@ -59,7 +65,7 @@ struct SuggestRecipeView: View { .navigationTitle("Suggest a recipe") .sheet(isPresented: $viewModel.isPresentingRecipe) { NavigationStack { - SuggestRecipeDetailsView(recipe: viewModel.recipe, image: viewModel.recipeImage) + SuggestRecipeDetailsView(recipe: viewModel.recipe, image: viewModel.recipeImage, errorMessage: viewModel.errorMessage) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { viewModel.isPresentingRecipe.toggle() }) { @@ -69,6 +75,9 @@ struct SuggestRecipeView: View { } } } + .sheet(isPresented: $viewModel.isPresentingPaywall) { + PaywallView() + } } } diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift index 6405cf3..a24f20e 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift @@ -16,6 +16,7 @@ // limitations under the License. import FirebaseAI +import FirebaseRemoteConfig import SwiftUI import UIKit @@ -26,40 +27,61 @@ class SuggestRecipeViewModel { var ingredients = "Chopped tomatoes, aubergines, courgettes, parmesan cheese, garlic, olive oil" var notes = "Italian" - var recipe = "" + var recipe: Recipe? + var isPresentingPaywall = false var recipeImage: UIImage? + var errorMessage: String? - private var model: GenerativeModel = { + private var model: GenerativeModel { let generationConfig = GenerationConfig( temperature: 0.9, topP: 0.1, topK: 16, maxOutputTokens: 4096, - responseModalities: [.text, .image] + responseMIMEType: "application/json", + responseSchema: .object( + properties: [ + "title": .string(), + "description": .string(), + "cookingTime": .integer(), + "ingredients": .array(items: .object(properties: [ + "name": .string(), + "amount": .string() + ])), + "instructions": .array(items: .string()) + ] + ), + responseModalities: [.text], ) - let firebaseAI = FirebaseAI.firebaseAI(backend: .vertexAI(location: "global")) + let firebaseAI = FirebaseAI.firebaseAI(backend: .googleAI()) return firebaseAI.generativeModel( - modelName: "gemini-2.5-flash-image", + modelName: "gemini-2.5-flash", generationConfig: generationConfig ) - }() + } - func generateRecipe() async { + private var imageModel: GenerativeModel { + let firebaseAI = FirebaseAI.firebaseAI(backend: .googleAI()) + return firebaseAI.generativeModel( + modelName: "gemini-2.5-flash-image", + generationConfig: GenerationConfig(responseModalities: [.image]) + ) + } + + func generateRecipe() async throws { + if !UsageTrackingService.shared.canGenerate() { + isPresentingPaywall = true + return + } + isGenerating = true defer { isGenerating = false } recipeImage = nil + recipe = nil + errorMessage = nil var prompt = """ Create a recipe using the following ingredients: \(ingredients). - - Also generate an image that shows what the final dish will look like. - - Please include: - 1. A creative title that describes the dish - 2. A brief, appetizing description - 3. Estimated cooking time in minutes - 4. List of ingredients with measurements - 5. Step-by-step cooking instructions """ if !notes.isEmpty { @@ -70,17 +92,34 @@ class SuggestRecipeViewModel { """ ) } - + do { let response = try await model.generateContent(prompt) - recipe = response.text ?? "" - if let inlineDataPart = response.inlineDataParts.first { - recipeImage = UIImage(data: inlineDataPart.data) + if let jsonString = response.text { + let jsonData = Data(jsonString.utf8) + let decoder = JSONDecoder() + let recipe = try decoder.decode(Recipe.self, from: jsonData) + self.recipe = recipe + await generateImage(for: recipe) } + UsageTrackingService.shared.incrementGenerationCount() } catch { - recipe = "An error occurred while generating the recipe: \(error.localizedDescription)." + errorMessage = "An error occurred while generating the recipe: \(error.localizedDescription)." } isPresentingRecipe = true } + func generateImage(for recipe: Recipe) async { + let prompt = "A photo of \(recipe.title), \(recipe.description)" + do { + let response = try await imageModel.generateContent(prompt) + if let inlineDataPart = response.inlineDataParts.first { + recipeImage = UIImage(data: inlineDataPart.data) + } + } + catch { + print("Error generating image: \(error.localizedDescription)") + } + } + } diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/FriendlyMealsApp.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/FriendlyMealsApp.swift index dfc365b..24487a1 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/FriendlyMealsApp.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/FriendlyMealsApp.swift @@ -1,7 +1,8 @@ // +// FriendlyMealsApp.swift // FriendlyMeals // -// Copyright © 2025 Google LLC. +// Created by Peter Friese on 01.07.25. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -23,6 +24,7 @@ struct FriendlyMealsApp: App { init () { FirebaseApp.configure() } + var body: some Scene { WindowGroup { TabView { @@ -30,12 +32,20 @@ struct FriendlyMealsApp: App { .tabItem { Label("Suggest Recipes", systemImage: "fork.knife") } - + MealPlannerChatView() .tabItem { Label("Meal Planner", systemImage: "message") } } + .task { + do { + try await RemoteConfigService.shared.fetchConfig() + } + catch { + print(error) + } + } } } } diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/remote_config_defaults.plist b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/remote_config_defaults.plist new file mode 100644 index 0000000..6cbec0f --- /dev/null +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/remote_config_defaults.plist @@ -0,0 +1,18 @@ + + + + + max_images_per_day + 5 + model_name + gemini-2.0-flash-preview-image-generation + generation_config + { + "temperature": 0.9, + "topP": 0.1, + "topK": 16, + "maxOutputTokens": 4096, + "responseModalities": ["text", "image"] + } + + diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/remote_config_template.json b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/remote_config_template.json new file mode 100644 index 0000000..2bda9ea --- /dev/null +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/remote_config_template.json @@ -0,0 +1,22 @@ +{ + "parameters": { + "max_images_per_day": { + "defaultValue": { + "value": "7" + }, + "valueType": "STRING" + }, + "model_name": { + "defaultValue": { + "value": "gemini-2.5-flash-image-preview" + }, + "valueType": "STRING" + }, + "generation_config": { + "defaultValue": { + "value": "{\"temperature\":0.9,\"topP\":0.1,\"topK\":16,\"maxOutputTokens\":4096,\"responseModalities\":[\"TEXT\",\"IMAGE\"]}" + }, + "valueType": "JSON" + } + } +} From 511035d4fbb9c625f2f26431f2047df8257486e0 Mon Sep 17 00:00:00 2001 From: peterfriese Date: Sat, 22 Nov 2025 14:34:36 +0100 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20Fix=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/GenerationConfig+Decodable.swift | 20 ++++++++++++++----- .../Services/RemoteConfigService.swift | 3 +-- .../Services/UsageTrackingService.swift | 3 +-- .../Features/SuggestRecipe/PaywallView.swift | 3 +-- .../Features/SuggestRecipe/Recipe.swift | 3 +-- .../FriendlyMeals/FriendlyMealsApp.swift | 3 +-- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/GenerationConfig+Decodable.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/GenerationConfig+Decodable.swift index e446754..a715707 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/GenerationConfig+Decodable.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/GenerationConfig+Decodable.swift @@ -1,14 +1,24 @@ // -// GenerationConfig+Decodable.swift -// FriendlyMeals +// FriendlyMeals // -// Created by Peter Friese on 26.09.25. +// Copyright © 2025 Google LLC. // +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. import Foundation import FirebaseAI -extension ResponseModality: Decodable { +extension ResponseModality: @retroactive Decodable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let rawValue = try container.decode(String.self) @@ -26,7 +36,7 @@ extension ResponseModality: Decodable { } } -extension GenerationConfig: Decodable { +extension GenerationConfig: @retroactive Decodable { private enum CodingKeys: String, CodingKey { case temperature case topP diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/RemoteConfigService.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/RemoteConfigService.swift index 3158063..247dfa8 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/RemoteConfigService.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/RemoteConfigService.swift @@ -1,8 +1,7 @@ // -// RemoteConfigService.swift // FriendlyMeals // -// Created by Peter Friese on 26.09.25. +// Copyright © 2025 Google LLC. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift index b0536da..470ffc3 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift @@ -1,8 +1,7 @@ // -// UsageTrackingService.swift // FriendlyMeals // -// Created by Peter Friese on 26.09.25. +// Copyright © 2025 Google LLC. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/PaywallView.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/PaywallView.swift index fb384c7..9c5acd0 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/PaywallView.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/PaywallView.swift @@ -1,8 +1,7 @@ // -// PaywallView.swift // FriendlyMeals // -// Created by Peter Friese on 26.09.25. +// Copyright © 2025 Google LLC. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/Recipe.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/Recipe.swift index bc9a4da..0fe2cf4 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/Recipe.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/Recipe.swift @@ -1,8 +1,7 @@ // -// Recipe.swift // FriendlyMeals // -// Created by Peter Friese on 01.07.25. +// Copyright © 2025 Google LLC. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/FriendlyMealsApp.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/FriendlyMealsApp.swift index 24487a1..244f998 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/FriendlyMealsApp.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/FriendlyMealsApp.swift @@ -1,8 +1,7 @@ // -// FriendlyMealsApp.swift // FriendlyMeals // -// Created by Peter Friese on 01.07.25. +// Copyright © 2025 Google LLC. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. From d5da8c2991b16d75d2ee5e9de0634e140aed3759 Mon Sep 17 00:00:00 2001 From: peterfriese Date: Sat, 22 Nov 2025 14:51:50 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20Use=20RC=20for=20the=20image=20?= =?UTF-8?q?model=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/SuggestRecipe/SuggestRecipeViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift index a24f20e..e029674 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift @@ -63,8 +63,8 @@ class SuggestRecipeViewModel { private var imageModel: GenerativeModel { let firebaseAI = FirebaseAI.firebaseAI(backend: .googleAI()) return firebaseAI.generativeModel( - modelName: "gemini-2.5-flash-image", - generationConfig: GenerationConfig(responseModalities: [.image]) + modelName: RemoteConfigService.shared.modelName, + generationConfig: RemoteConfigService.shared.generationConfig ) } From 690b44d47773373a0a260ba16621aba8a11f7f8e Mon Sep 17 00:00:00 2001 From: peterfriese Date: Sat, 22 Nov 2025 14:56:26 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20Address=20feedback=20from=20?= =?UTF-8?q?review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Services/UsageTrackingService.swift | 11 +++++++---- .../SuggestRecipe/SuggestRecipeViewModel.swift | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift index 470ffc3..a5d26e5 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/Services/UsageTrackingService.swift @@ -40,16 +40,19 @@ class UsageTrackingService { userDefaults.set(count + 1, forKey: generationCountKey) } + private func resetCounter() { + userDefaults.set(Date(), forKey: lastGenerationDateKey) + userDefaults.set(0, forKey: generationCountKey) + } + private func resetCountIfNeeded() { guard let lastGenerationDate = userDefaults.object(forKey: lastGenerationDateKey) as? Date else { - userDefaults.set(Date(), forKey: lastGenerationDateKey) - userDefaults.set(0, forKey: generationCountKey) + resetCounter() return } if !Calendar.current.isDateInToday(lastGenerationDate) { - userDefaults.set(Date(), forKey: lastGenerationDateKey) - userDefaults.set(0, forKey: generationCountKey) + resetCounter() } } } diff --git a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift index e029674..6a86fd9 100644 --- a/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift +++ b/firebase-ai-friendly-meals/apple/FriendlyMeals/FriendlyMeals/Features/SuggestRecipe/SuggestRecipeViewModel.swift @@ -101,8 +101,8 @@ class SuggestRecipeViewModel { let recipe = try decoder.decode(Recipe.self, from: jsonData) self.recipe = recipe await generateImage(for: recipe) + UsageTrackingService.shared.incrementGenerationCount() } - UsageTrackingService.shared.incrementGenerationCount() } catch { errorMessage = "An error occurred while generating the recipe: \(error.localizedDescription)." }