diff --git a/FriendlyMeals/FriendlyMeals.xcodeproj/project.pbxproj b/FriendlyMeals/FriendlyMeals.xcodeproj/project.pbxproj index 17006eb..7b7e856 100644 --- a/FriendlyMeals/FriendlyMeals.xcodeproj/project.pbxproj +++ b/FriendlyMeals/FriendlyMeals.xcodeproj/project.pbxproj @@ -8,13 +8,18 @@ /* Begin PBXBuildFile section */ 884B2CF22E144BA7007B2E0E /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 884B2CF12E144BA7007B2E0E /* MarkdownUI */; }; - 884ECEF72E8ABF9400E3B0CB /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 884ECEF62E8ABF9400E3B0CB /* FirebaseFirestore */; }; 887565682E86CCEE0041DDEF /* ConversationKit in Frameworks */ = {isa = PBXBuildFile; productRef = 887565672E86CCEE0041DDEF /* ConversationKit */; }; - 887565922E86D6D80041DDEF /* FirebaseRemoteConfig in Frameworks */ = {isa = PBXBuildFile; productRef = 887565912E86D6D80041DDEF /* FirebaseRemoteConfig */; }; - 88BE941E2E1413D000C81FF5 /* FirebaseAI in Frameworks */ = {isa = PBXBuildFile; productRef = 88BE941D2E1413D000C81FF5 /* FirebaseAI */; }; - 88C8FB7A2E13DF62001B86C4 /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 88C8FB792E13DF62001B86C4 /* FirebaseCore */; }; - 8DAB5BA32F04A0FE000D464B /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 8DAB5BA22F04A0FE000D464B /* FirebaseAuth */; }; - 8DAB5BA82F107BA3000D464B /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 8DAB5BA72F107BA3000D464B /* FirebaseStorage */; }; + 8D3574952F997F29003F1C27 /* FirebaseAILogic in Frameworks */ = {isa = PBXBuildFile; productRef = 8D3574942F997F29003F1C27 /* FirebaseAILogic */; }; + 8D3574972F997F29003F1C27 /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 8D3574962F997F29003F1C27 /* FirebaseAuth */; }; + 8D3574992F997F29003F1C27 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 8D3574982F997F29003F1C27 /* FirebaseFirestore */; }; + 8D35749B2F997F29003F1C27 /* FirebaseRemoteConfig in Frameworks */ = {isa = PBXBuildFile; productRef = 8D35749A2F997F29003F1C27 /* FirebaseRemoteConfig */; }; + 8D35749D2F997F29003F1C27 /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 8D35749C2F997F29003F1C27 /* FirebaseStorage */; }; + 8D772B902F8039530054270D /* FoundationModels.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8D772B8F2F8039530054270D /* FoundationModels.framework */; }; + 8D772B932F803C350054270D /* FirebaseAILogic in Frameworks */ = {isa = PBXBuildFile; productRef = 8D772B922F803C350054270D /* FirebaseAILogic */; }; + 8D772B952F803C350054270D /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 8D772B942F803C350054270D /* FirebaseAuth */; }; + 8D772B972F803C350054270D /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 8D772B962F803C350054270D /* FirebaseFirestore */; }; + 8D772B992F803C350054270D /* FirebaseRemoteConfig in Frameworks */ = {isa = PBXBuildFile; productRef = 8D772B982F803C350054270D /* FirebaseRemoteConfig */; }; + 8D772B9B2F803C350054270D /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 8D772B9A2F803C350054270D /* FirebaseStorage */; }; 8DBC83FA2F17076D00DAC906 /* ConversationKit in Frameworks */ = {isa = PBXBuildFile; productRef = 88665A882E1D2DB00039990B /* ConversationKit */; }; 8DBC83FB2F17076D00DAC906 /* ConversationKit in Frameworks */ = {isa = PBXBuildFile; productRef = 883F6FA82E86969900519CE3 /* ConversationKit */; }; 8DBC83FC2F17076D00DAC906 /* ConversationKit in Frameworks */ = {isa = PBXBuildFile; productRef = 88C207D72E8AA5C6004CD918 /* ConversationKit */; }; @@ -23,6 +28,7 @@ /* Begin PBXFileReference section */ 88C8FB652E13D879001B86C4 /* FriendlyMeals.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FriendlyMeals.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8D772B8F2F8039530054270D /* FoundationModels.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FoundationModels.framework; path = System/Library/Frameworks/FoundationModels.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -38,18 +44,23 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 8D772B992F803C350054270D /* FirebaseRemoteConfig in Frameworks */, + 8D772B932F803C350054270D /* FirebaseAILogic in Frameworks */, 8DBC83FD2F17076D00DAC906 /* ConversationKit in Frameworks */, 8DBC83FC2F17076D00DAC906 /* ConversationKit in Frameworks */, + 8D772B972F803C350054270D /* FirebaseFirestore in Frameworks */, + 8D3574972F997F29003F1C27 /* FirebaseAuth in Frameworks */, + 8D772B9B2F803C350054270D /* FirebaseStorage in Frameworks */, + 8D35749B2F997F29003F1C27 /* FirebaseRemoteConfig in Frameworks */, 8DBC83FB2F17076D00DAC906 /* ConversationKit in Frameworks */, + 8D3574992F997F29003F1C27 /* FirebaseFirestore in Frameworks */, + 8D772B952F803C350054270D /* FirebaseAuth in Frameworks */, + 8D3574952F997F29003F1C27 /* FirebaseAILogic in Frameworks */, + 8D35749D2F997F29003F1C27 /* FirebaseStorage in Frameworks */, 8DBC83FA2F17076D00DAC906 /* ConversationKit in Frameworks */, 887565682E86CCEE0041DDEF /* ConversationKit in Frameworks */, 884B2CF22E144BA7007B2E0E /* MarkdownUI in Frameworks */, - 88C8FB7A2E13DF62001B86C4 /* FirebaseCore in Frameworks */, - 887565922E86D6D80041DDEF /* FirebaseRemoteConfig in Frameworks */, - 884ECEF72E8ABF9400E3B0CB /* FirebaseFirestore in Frameworks */, - 8DAB5BA82F107BA3000D464B /* FirebaseStorage in Frameworks */, - 8DAB5BA32F04A0FE000D464B /* FirebaseAuth in Frameworks */, - 88BE941E2E1413D000C81FF5 /* FirebaseAI in Frameworks */, + 8D772B902F8039530054270D /* FoundationModels.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -76,6 +87,7 @@ 88C8FB782E13DF62001B86C4 /* Frameworks */ = { isa = PBXGroup; children = ( + 8D772B8F2F8039530054270D /* FoundationModels.framework */, ); name = Frameworks; sourceTree = ""; @@ -100,18 +112,22 @@ ); name = FriendlyMeals; packageProductDependencies = ( - 88C8FB792E13DF62001B86C4 /* FirebaseCore */, - 88BE941D2E1413D000C81FF5 /* FirebaseAI */, 884B2CF12E144BA7007B2E0E /* MarkdownUI */, 88665A882E1D2DB00039990B /* ConversationKit */, 883F6FA82E86969900519CE3 /* ConversationKit */, 887565672E86CCEE0041DDEF /* ConversationKit */, - 887565912E86D6D80041DDEF /* FirebaseRemoteConfig */, 88C207D72E8AA5C6004CD918 /* ConversationKit */, - 884ECEF62E8ABF9400E3B0CB /* FirebaseFirestore */, 88FF5D532ECF579900646871 /* ConversationKit */, - 8DAB5BA22F04A0FE000D464B /* FirebaseAuth */, - 8DAB5BA72F107BA3000D464B /* FirebaseStorage */, + 8D772B922F803C350054270D /* FirebaseAILogic */, + 8D772B942F803C350054270D /* FirebaseAuth */, + 8D772B962F803C350054270D /* FirebaseFirestore */, + 8D772B982F803C350054270D /* FirebaseRemoteConfig */, + 8D772B9A2F803C350054270D /* FirebaseStorage */, + 8D3574942F997F29003F1C27 /* FirebaseAILogic */, + 8D3574962F997F29003F1C27 /* FirebaseAuth */, + 8D3574982F997F29003F1C27 /* FirebaseFirestore */, + 8D35749A2F997F29003F1C27 /* FirebaseRemoteConfig */, + 8D35749C2F997F29003F1C27 /* FirebaseStorage */, ); productName = FriendlyMeals; productReference = 88C8FB652E13D879001B86C4 /* FriendlyMeals.app */; @@ -142,9 +158,9 @@ mainGroup = 88C8FB5C2E13D879001B86C4; minimizedProjectReferenceProxies = 1; packageReferences = ( - 88C8FB772E13DEE1001B86C4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, 884B2CF02E144BA7007B2E0E /* XCRemoteSwiftPackageReference "swift-markdown-ui" */, 88FF5D522ECF579900646871 /* XCRemoteSwiftPackageReference "ConversationKit" */, + 8D3574932F997F29003F1C27 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); preferredProjectObjectVersion = 77; productRefGroup = 88C8FB662E13D879001B86C4 /* Products */; @@ -394,20 +410,20 @@ minimumVersion = 2.4.1; }; }; - 88C8FB772E13DEE1001B86C4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + 88FF5D522ECF579900646871 /* XCRemoteSwiftPackageReference "ConversationKit" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + repositoryURL = "https://github.com/peterfriese/ConversationKit"; requirement = { - kind = revision; - revision = 1f7389bad09aa93c9eeb508e70013207813a4df2; + kind = upToNextMajorVersion; + minimumVersion = 0.0.3; }; }; - 88FF5D522ECF579900646871 /* XCRemoteSwiftPackageReference "ConversationKit" */ = { + 8D3574932F997F29003F1C27 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/peterfriese/ConversationKit"; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.0.3; + minimumVersion = 12.12.1; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -422,11 +438,6 @@ package = 884B2CF02E144BA7007B2E0E /* XCRemoteSwiftPackageReference "swift-markdown-ui" */; productName = MarkdownUI; }; - 884ECEF62E8ABF9400E3B0CB /* FirebaseFirestore */ = { - isa = XCSwiftPackageProductDependency; - package = 88C8FB772E13DEE1001B86C4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseFirestore; - }; 88665A882E1D2DB00039990B /* ConversationKit */ = { isa = XCSwiftPackageProductDependency; productName = ConversationKit; @@ -435,38 +446,58 @@ isa = XCSwiftPackageProductDependency; productName = ConversationKit; }; - 887565912E86D6D80041DDEF /* FirebaseRemoteConfig */ = { + 88C207D72E8AA5C6004CD918 /* ConversationKit */ = { isa = XCSwiftPackageProductDependency; - package = 88C8FB772E13DEE1001B86C4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseRemoteConfig; + productName = ConversationKit; }; - 88BE941D2E1413D000C81FF5 /* FirebaseAI */ = { + 88FF5D532ECF579900646871 /* ConversationKit */ = { isa = XCSwiftPackageProductDependency; - package = 88C8FB772E13DEE1001B86C4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAI; + package = 88FF5D522ECF579900646871 /* XCRemoteSwiftPackageReference "ConversationKit" */; + productName = ConversationKit; }; - 88C207D72E8AA5C6004CD918 /* ConversationKit */ = { + 8D3574942F997F29003F1C27 /* FirebaseAILogic */ = { isa = XCSwiftPackageProductDependency; - productName = ConversationKit; + package = 8D3574932F997F29003F1C27 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAILogic; }; - 88C8FB792E13DF62001B86C4 /* FirebaseCore */ = { + 8D3574962F997F29003F1C27 /* FirebaseAuth */ = { isa = XCSwiftPackageProductDependency; - package = 88C8FB772E13DEE1001B86C4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseCore; + package = 8D3574932F997F29003F1C27 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAuth; }; - 88FF5D532ECF579900646871 /* ConversationKit */ = { + 8D3574982F997F29003F1C27 /* FirebaseFirestore */ = { isa = XCSwiftPackageProductDependency; - package = 88FF5D522ECF579900646871 /* XCRemoteSwiftPackageReference "ConversationKit" */; - productName = ConversationKit; + package = 8D3574932F997F29003F1C27 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseFirestore; }; - 8DAB5BA22F04A0FE000D464B /* FirebaseAuth */ = { + 8D35749A2F997F29003F1C27 /* FirebaseRemoteConfig */ = { + isa = XCSwiftPackageProductDependency; + package = 8D3574932F997F29003F1C27 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseRemoteConfig; + }; + 8D35749C2F997F29003F1C27 /* FirebaseStorage */ = { + isa = XCSwiftPackageProductDependency; + package = 8D3574932F997F29003F1C27 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseStorage; + }; + 8D772B922F803C350054270D /* FirebaseAILogic */ = { + isa = XCSwiftPackageProductDependency; + productName = FirebaseAILogic; + }; + 8D772B942F803C350054270D /* FirebaseAuth */ = { isa = XCSwiftPackageProductDependency; - package = 88C8FB772E13DEE1001B86C4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseAuth; }; - 8DAB5BA72F107BA3000D464B /* FirebaseStorage */ = { + 8D772B962F803C350054270D /* FirebaseFirestore */ = { + isa = XCSwiftPackageProductDependency; + productName = FirebaseFirestore; + }; + 8D772B982F803C350054270D /* FirebaseRemoteConfig */ = { + isa = XCSwiftPackageProductDependency; + productName = FirebaseRemoteConfig; + }; + 8D772B9A2F803C350054270D /* FirebaseStorage */ = { isa = XCSwiftPackageProductDependency; - package = 88C8FB772E13DEE1001B86C4 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseStorage; }; /* End XCSwiftPackageProductDependency section */ diff --git a/FriendlyMeals/FriendlyMeals/Features/Cookbook/Views/FilterView.swift b/FriendlyMeals/FriendlyMeals/Features/Cookbook/Views/FilterView.swift index 23c186e..6150a98 100644 --- a/FriendlyMeals/FriendlyMeals/Features/Cookbook/Views/FilterView.swift +++ b/FriendlyMeals/FriendlyMeals/Features/Cookbook/Views/FilterView.swift @@ -32,6 +32,8 @@ struct FilterConfiguration { var recipeTitle = "" + var recipeInstructions = "" + var minimumRating: Double = 0 var selectedTags: Set = [] @@ -42,6 +44,8 @@ struct FilterConfiguration { struct FilterView: View { + @Environment(\.dismiss) private var dismiss + init( tags: [String], configuration: FilterConfiguration? = FilterConfiguration(), @@ -76,68 +80,102 @@ struct FilterView: View { @State private var configuration: FilterConfiguration var body: some View { - VStack(alignment: .leading) { - Text("Filters") - .font(.largeTitle) + NavigationStack { + Form { + Section { + Toggle("View only my recipes", isOn: $configuration.shouldShowOnlyOwnRecipes) + } header: { + Label("General", systemImage: "slider.horizontal.3") + } - Toggle(isOn: $configuration.shouldShowOnlyOwnRecipes) { - Text("View only my recipes") - } + Section { + LabeledContent("Title") { + TextField("Scallops", text: $configuration.recipeTitle) + .multilineTextAlignment(.trailing) + } + LabeledContent("Instructions") { + TextField("Bake for 30 minutes", text: $configuration.recipeInstructions) + .multilineTextAlignment(.trailing) + } + } header: { + Label("Search", systemImage: "magnifyingglass") + } - Text("Filter by title") - TextField("Scallops", text: $configuration.recipeTitle) - - Text("Minimum rating: \(configuration.minimumRating.formatted())") - Slider( - value: $configuration.minimumRating, - in: 0...5, - step: 0.25 - ) { - Text("Minimum rating") - } minimumValueLabel: { - Text("0") - } maximumValueLabel: { - Text("5") - } onEditingChanged: { _ in - // do nothing - } + Section { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(configuration.minimumRating.formatted()) + .font(.headline) + Spacer() + HStack(spacing: 2) { + let rating = configuration.minimumRating + ForEach(0..<5) { index in + let starName = index < Int(rating) ? "star.fill" : (index < Int(rating.rounded(.up)) ? "star.leadinghalf.filled" : "star") + Image(systemName: starName) + .foregroundColor(.yellow) + } + } + } + Slider(value: $configuration.minimumRating, in: 0...5, step: 0.25) + .tint(.blue) + } + .padding(.vertical, 4) + } header: { + Label("Minimum Rating", systemImage: "star.fill") + } - Text("Tags") - ScrollView(.horizontal, showsIndicators: true) { - HStack { - ForEach(0 ..< tags.count, id: \.self) { index in - let tag = tags[index] - let isSelected = tagSelections[index] - Toggle(tag, isOn: $tagSelections[index]) - .toggleStyle(.button) - .tint(isSelected ? .blue : .secondary) + Section { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(0 ..< tags.count, id: \.self) { index in + let tag = tags[index] + let isSelected = tagSelections[index] + Toggle(tag, isOn: $tagSelections[index]) + .toggleStyle(.button) + .tint(isSelected ? .blue : .secondary) + .clipShape(Capsule()) + } + } + .padding(.vertical, 4) } + } header: { + Label("Tags", systemImage: "tag.fill") } - } - Text("Sort by") - Picker("Choose a sort method", selection: $configuration.sortOption) { - ForEach(FilterConfiguration.sortOptions, id: \.self) { option in - Text(option.rawValue).tag(option.rawValue) + Section { + Picker("Sort method", selection: $configuration.sortOption) { + ForEach(FilterConfiguration.sortOptions, id: \.self) { option in + Text(option.rawValue).tag(option) + } + } + .pickerStyle(.menu) + } header: { + Label("Sort By", systemImage: "arrow.up.arrow.down") } } - - HStack { - Button("Remove filters") { - configuration = FilterConfiguration() - tagSelections = tagSelections.map { _ in false } + .navigationTitle("Filters") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Reset") { + configuration = FilterConfiguration() + tagSelections = tagSelections.map { _ in false } + } + .foregroundColor(.red) } - Button("Apply filters") { - let selectedTags = tags.indices - .filter { tagSelections[$0] } - .map { tags[$0] } - configuration.selectedTags = Set(selectedTags) - applyFilters(configuration) + ToolbarItem(placement: .confirmationAction) { + Button("Apply") { + let selectedTags = tags.indices + .filter { tagSelections[$0] } + .map { tags[$0] } + configuration.selectedTags = Set(selectedTags) + applyFilters(configuration) + dismiss() + } + .fontWeight(.bold) } } - Spacer() } - .padding(16) } } diff --git a/FriendlyMeals/FriendlyMeals/Features/Cookbook/Views/RecipeListView.swift b/FriendlyMeals/FriendlyMeals/Features/Cookbook/Views/RecipeListView.swift index e05fd79..624f89a 100644 --- a/FriendlyMeals/FriendlyMeals/Features/Cookbook/Views/RecipeListView.swift +++ b/FriendlyMeals/FriendlyMeals/Features/Cookbook/Views/RecipeListView.swift @@ -38,6 +38,11 @@ struct RecipeListView: View { .font(.headline) } Spacer() + if let likes = recipe.likes, likes > 0 { + Text("\(likes)") + .font(.caption) + .foregroundStyle(.secondary) + } if likesStore.isLiked(recipe) { Image(systemName: "heart.fill") .foregroundColor(.pink) diff --git a/FriendlyMeals/FriendlyMeals/Features/MealPlanner/ViewModels/MealPlannerChatViewModel.swift b/FriendlyMeals/FriendlyMeals/Features/MealPlanner/ViewModels/MealPlannerChatViewModel.swift index 85ab877..9cc00ed 100644 --- a/FriendlyMeals/FriendlyMeals/Features/MealPlanner/ViewModels/MealPlannerChatViewModel.swift +++ b/FriendlyMeals/FriendlyMeals/Features/MealPlanner/ViewModels/MealPlannerChatViewModel.swift @@ -16,7 +16,7 @@ // limitations under the License. import ConversationKit -import FirebaseAI +import FirebaseAILogic import SwiftUI @Observable diff --git a/FriendlyMeals/FriendlyMeals/Features/MealPlanner/ViewModels/MealPlannerSuggestionViewModel.swift b/FriendlyMeals/FriendlyMeals/Features/MealPlanner/ViewModels/MealPlannerSuggestionViewModel.swift index 39b6537..1235e41 100644 --- a/FriendlyMeals/FriendlyMeals/Features/MealPlanner/ViewModels/MealPlannerSuggestionViewModel.swift +++ b/FriendlyMeals/FriendlyMeals/Features/MealPlanner/ViewModels/MealPlannerSuggestionViewModel.swift @@ -15,7 +15,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import FirebaseAuth import FirebaseRemoteConfig import SwiftUI diff --git a/FriendlyMeals/FriendlyMeals/Features/MealPlanner/Views/MealPlannerChatView.swift b/FriendlyMeals/FriendlyMeals/Features/MealPlanner/Views/MealPlannerChatView.swift index 5c9163d..352ce25 100644 --- a/FriendlyMeals/FriendlyMeals/Features/MealPlanner/Views/MealPlannerChatView.swift +++ b/FriendlyMeals/FriendlyMeals/Features/MealPlanner/Views/MealPlannerChatView.swift @@ -17,7 +17,7 @@ import SwiftUI import ConversationKit -import FirebaseAI +import FirebaseAILogic struct MealPlannerChatView: View { @State private var viewModel = MealPlannerChatViewModel() diff --git a/FriendlyMeals/FriendlyMeals/Features/Nutrition/ViewModels/NutritionViewModel.swift b/FriendlyMeals/FriendlyMeals/Features/Nutrition/ViewModels/NutritionViewModel.swift index 6edf7a4..dfc2df9 100644 --- a/FriendlyMeals/FriendlyMeals/Features/Nutrition/ViewModels/NutritionViewModel.swift +++ b/FriendlyMeals/FriendlyMeals/Features/Nutrition/ViewModels/NutritionViewModel.swift @@ -15,7 +15,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import FirebaseAI +import FirebaseAILogic import Foundation import SwiftUI diff --git a/FriendlyMeals/FriendlyMeals/Shared/Models/Recipe.swift b/FriendlyMeals/FriendlyMeals/Shared/Models/Recipe.swift index 95017d1..d229739 100644 --- a/FriendlyMeals/FriendlyMeals/Shared/Models/Recipe.swift +++ b/FriendlyMeals/FriendlyMeals/Shared/Models/Recipe.swift @@ -30,7 +30,6 @@ struct Recipe: Codable, Identifiable, Hashable, RecipeRepresentable { // These are plain strings var tags: [String] - var averageRating: Double var imageUri: String? // These are display strings @@ -38,6 +37,8 @@ struct Recipe: Codable, Identifiable, Hashable, RecipeRepresentable { var cookTime: String var servings: String + var likes: Int? + } extension Recipe { @@ -48,11 +49,48 @@ extension Recipe { ingredients: representable.ingredients, authorId: authorID ?? "anonymous", tags: representable.tags, - averageRating: 0, imageUri: representable.imageUri, prepTime: representable.prepTime, cookTime: representable.cookTime, - servings: representable.servings + servings: representable.servings, + likes: nil + ) + } + + init(from result: PipelineResult) throws { + let imageURL = result.data["imageUri"] as? String + guard let title = result.data["title"] as? String, + let instructions = result.data["instructions"] as? String, + let ingredients = result.data["ingredients"] as? [String], + let authorID = result.data["authorId"] as? String, + let tags = result.data["tags"] as? [String], + let prepTime = result.data["prepTime"] as? String, + let cookTime = result.data["cookTime"] as? String, + let servings = result.data["servings"] as? String, + let documentID = result.id else { + let errorMessage = "Unable to initialize recipes from data: \(result.data)" + throw RecipeDecodingError.missingKeys(errorMessage) + } + + let likes = result.data["likes"] as? Int + + self.init( + title: title, + instructions: instructions, + ingredients: ingredients, + authorId: authorID, + tags: tags, + imageUri: imageURL, + prepTime: prepTime, + cookTime: cookTime, + servings: servings, + likes: likes ) + + self.id = documentID + } + + enum RecipeDecodingError: Error { + case missingKeys(String) } } diff --git a/FriendlyMeals/FriendlyMeals/Shared/Services/GenerationConfig+Decodable.swift b/FriendlyMeals/FriendlyMeals/Shared/Services/GenerationConfig+Decodable.swift index a715707..3d3d504 100644 --- a/FriendlyMeals/FriendlyMeals/Shared/Services/GenerationConfig+Decodable.swift +++ b/FriendlyMeals/FriendlyMeals/Shared/Services/GenerationConfig+Decodable.swift @@ -16,7 +16,7 @@ // limitations under the License. import Foundation -import FirebaseAI +import FirebaseAILogic extension ResponseModality: @retroactive Decodable { public init(from decoder: Decoder) throws { diff --git a/FriendlyMeals/FriendlyMeals/Shared/Services/RecipeStore.swift b/FriendlyMeals/FriendlyMeals/Shared/Services/RecipeStore.swift index ce38574..966ce85 100644 --- a/FriendlyMeals/FriendlyMeals/Shared/Services/RecipeStore.swift +++ b/FriendlyMeals/FriendlyMeals/Shared/Services/RecipeStore.swift @@ -23,7 +23,6 @@ import FirebaseAuth enum RecipeStoreError: Error { case missingRecipeID case likeDecodingError(String) - case recipeDecodingError(String) case reviewDecodingError(String) } @@ -40,6 +39,15 @@ class RecipeStore { private static func defaultFilter(_ store: Firestore) -> Pipeline { return store.pipeline().collection(recipeCollection) + .define([Field("__name__").documentId().as("parentRecipeId")]) + .addFields([ + store.pipeline() + .collection("likes") + .where(Field("recipeId").equal(Variable("parentRecipeId"))) + .aggregate([CountAll().as("count")]) + .toScalarExpression() + .as("likes") + ]) } private var activeQuery: Pipeline { @@ -51,8 +59,24 @@ class RecipeStore { private func applyConfiguration(_ configuration: FilterConfiguration, to pipeline: Pipeline, + using db: Firestore, currentUserID: String? = Auth.auth().currentUser?.uid) -> Pipeline { var filters = pipeline + + if !configuration.recipeInstructions.isEmpty { + // Search must be the first stage in a pipeline, so don't add any + // stages before this. + filters = filters.search( + query: "\(configuration.recipeInstructions)", + addFields: [ + Score().as("searchScore") + ] + ) + } + + let shouldAddAverageRating = configuration.minimumRating > 0 || + configuration.sortOption == .rating + if let id = currentUserID, configuration.shouldShowOnlyOwnRecipes { filters = filters.where(Field("authorId").equal(id)) } @@ -61,14 +85,35 @@ class RecipeStore { filters = filters.where(Field("title").like("%\(configuration.recipeTitle)%")) } - if configuration.minimumRating > 0 { - filters = filters.where(Field("averageRating").greaterThanOrEqual(configuration.minimumRating)) - } - if !configuration.selectedTags.isEmpty { filters = filters.where(Field("tags").arrayContainsAny(Array(configuration.selectedTags))) } + // Always add likes + filters = filters.define([Field("__name__").documentId().as("parentRecipeId")]).addFields([ + db.pipeline() + .collection("likes") + .where(Field("recipeId").equal(Variable("parentRecipeId"))) + .aggregate([CountAll().as("count")]) + .toScalarExpression() + .as("likes") + ]) + // Add rating sometimes + if shouldAddAverageRating { + filters = filters.addFields([ + Subcollection(RecipeStore.reviewsSubcollection) + .aggregate([Field("rating").average().as("averageRating")]) + .toScalarExpression() + .as("averageRating") + ]) + } + + if configuration.minimumRating > 0 { + filters = filters.where( + Field("averageRating").exists() && Field("averageRating").greaterThanOrEqual(configuration.minimumRating) + ) + } + switch configuration.sortOption { case .alphabetical: filters = filters.sort([Field("title").ascending()]) @@ -77,6 +122,10 @@ class RecipeStore { case .popularity: filters = filters.sort([Field("likes").descending()]) case .none: + // If no existing filter, sort by search score if it exists. + if !configuration.recipeInstructions.isEmpty { + filters = filters.sort([Field("searchScore").descending()]) + } break @unknown default: break @@ -88,7 +137,8 @@ class RecipeStore { func applyConfiguration(_ configuration: FilterConfiguration) { filterConfiguration = configuration let output = { (store: Firestore) -> Pipeline in - return self.applyConfiguration(configuration, to: store.pipeline().collection(RecipeStore.recipeCollection)) + let pipeline = store.pipeline().collection(RecipeStore.recipeCollection) + return self.applyConfiguration(configuration, to: pipeline, using: store) } activeFilters = output } @@ -98,44 +148,13 @@ class RecipeStore { try collection.addDocument(from: recipe) } - func fetchRecipes(withUserID userID: String? = Auth.auth().currentUser?.uid) async throws { + func fetchRecipes() async throws { let query = activeQuery let snapshot = try await query.execute() self.recipes = try snapshot.results.compactMap { result in - let imageURL = result.data["imageUri"] as? String - - guard let title = result.data["title"] as? String, - let instructions = result.data["instructions"] as? String, - let ingredients = result.data["ingredients"] as? [String], - let authorID = result.data["authorId"] as? String, - let tags = result.data["tags"] as? [String], - let averageRating = result.data["averageRating"] as? Double, - let prepTime = result.data["prepTime"] as? String, - let cookTime = result.data["cookTime"] as? String, - let servings = result.data["servings"] as? String, - let documentID = result.id else { - let errorMessage = "Unable to initialize recipes from data: \(result.data)" - throw RecipeStoreError.recipeDecodingError(errorMessage) - } - - var recipe = Recipe( - title: title, - instructions: instructions, - ingredients: ingredients, - authorId: authorID, - tags: tags, - averageRating: averageRating, - imageUri: imageURL, - prepTime: prepTime, - cookTime: cookTime, - servings: servings - ) - - recipe.id = documentID - return recipe + return try Recipe(from: result) } - } @discardableResult @@ -180,7 +199,7 @@ class RecipeStore { // Reviews extension RecipeStore { - private static let reviewsSubcollection = "reviews" + fileprivate static let reviewsSubcollection = "reviews" func fetchReview(userID: String, recipeID: String) async throws -> Review? { let compositeID = "\(recipeID)_\(userID)" diff --git a/FriendlyMeals/FriendlyMeals/Shared/Services/RemoteConfigService.swift b/FriendlyMeals/FriendlyMeals/Shared/Services/RemoteConfigService.swift index 5385977..0439110 100644 --- a/FriendlyMeals/FriendlyMeals/Shared/Services/RemoteConfigService.swift +++ b/FriendlyMeals/FriendlyMeals/Shared/Services/RemoteConfigService.swift @@ -17,7 +17,7 @@ import Foundation import FirebaseRemoteConfig -import FirebaseAI +import FirebaseAILogic fileprivate enum RemoteConfigKey: String { case maxImagesPerDay = "max_images_per_day" diff --git a/FriendlyMeals/FriendlyMeals/Shared/Views/RecipeDetailsView.swift b/FriendlyMeals/FriendlyMeals/Shared/Views/RecipeDetailsView.swift index 9816080..d917e1d 100644 --- a/FriendlyMeals/FriendlyMeals/Shared/Views/RecipeDetailsView.swift +++ b/FriendlyMeals/FriendlyMeals/Shared/Views/RecipeDetailsView.swift @@ -92,6 +92,15 @@ extension RecipeDetailsView: View { if remainder > 0 { partialStar(percentage: remainder) } + + if let likes = recipe.likes { + Spacer() + Image(systemName: "heart.fill") + .foregroundColor(.pink) + Text("\(likes) likes") + .font(.subheadline) + .foregroundStyle(.secondary) + } } .task { do { @@ -232,7 +241,6 @@ extension RecipeDetailsView: View { ], authorId: "no author", tags: [], - averageRating: 4.5, imageUri: "https://www.gstatic.com/devrel-devsite/prod/ve08add287a6b4bdf8961ab8a1be50bf551be3816cdd70b7cc934114ff3ad5f10/firebase/images/lockup.svg", prepTime: "30 minutes", cookTime: "10 minutes",