Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -34,6 +35,7 @@
88C8FB7A2E13DF62001B86C4 /* FirebaseCore in Frameworks */,
88662FF02ECF66EF001DA000 /* ConversationKit in Frameworks */,
88BE941E2E1413D000C81FF5 /* FirebaseAI in Frameworks */,
88AD6DDF2ED1EAF3004F8BAE /* FirebaseRemoteConfig in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -88,6 +90,7 @@
88BE941D2E1413D000C81FF5 /* FirebaseAI */,
884B2CF12E144BA7007B2E0E /* MarkdownUI */,
88662FEF2ECF66EF001DA000 /* ConversationKit */,
88AD6DDE2ED1EAF3004F8BAE /* FirebaseRemoteConfig */,
);
productName = FriendlyMeals;
productReference = 88C8FB652E13D879001B86C4 /* FriendlyMeals.app */;
Expand Down Expand Up @@ -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" */;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// FriendlyMeals
//
// 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: @retroactive 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: @retroactive 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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// FriendlyMeals
//
// 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 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()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// FriendlyMeals
//
// 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

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 resetCounter() {
userDefaults.set(Date(), forKey: lastGenerationDateKey)
userDefaults.set(0, forKey: generationCountKey)
}

private func resetCountIfNeeded() {
guard let lastGenerationDate = userDefaults.object(forKey: lastGenerationDateKey) as? Date else {
resetCounter()
return
}

if !Calendar.current.isDateInToday(lastGenerationDate) {
resetCounter()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//
// FriendlyMeals
//
// 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 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()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// FriendlyMeals
//
// 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

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]
}
Loading