From 2602c87edcdc6f4bd3445faab872736d2fafbf81 Mon Sep 17 00:00:00 2001 From: Jim Bisenius Date: Sat, 18 May 2024 05:38:37 -0400 Subject: [PATCH] Basic calorie counting --- .gitignore | 1 + Calorie Counter.xcodeproj/project.pbxproj | 16 ++- Calorie Counter/Components/AI.swift | 116 ++++++++++++++++++ .../Components/Miscellaneous.swift | 6 +- .../{Logic.swift => Utilities.swift} | 0 Calorie Counter/Screens/AddView.swift | 63 +++++++++- Calorie Counter/Screens/MainTabView.swift | 2 +- Calorie Counter/Screens/SettingsView.swift | 41 ++++--- Calorie Counter/Screens/SummaryView.swift | 2 +- 9 files changed, 213 insertions(+), 34 deletions(-) create mode 100644 .gitignore create mode 100644 Calorie Counter/Components/AI.swift rename Calorie Counter/Components/{Logic.swift => Utilities.swift} (100%) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5c0289 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +Calorie Counter/Components/Keys.swift diff --git a/Calorie Counter.xcodeproj/project.pbxproj b/Calorie Counter.xcodeproj/project.pbxproj index 63a21a3..213cee4 100644 --- a/Calorie Counter.xcodeproj/project.pbxproj +++ b/Calorie Counter.xcodeproj/project.pbxproj @@ -7,12 +7,14 @@ objects = { /* Begin PBXBuildFile section */ - 3853E41F2BF80FEE00958AB4 /* Logic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3853E41E2BF80FEE00958AB4 /* Logic.swift */; }; + 3853E41F2BF80FEE00958AB4 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3853E41E2BF80FEE00958AB4 /* Utilities.swift */; }; 3853E4212BF8129900958AB4 /* MealProcessingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3853E4202BF8129900958AB4 /* MealProcessingView.swift */; }; 3853E4232BF8463B00958AB4 /* Weight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3853E4222BF8463B00958AB4 /* Weight.swift */; }; 3853E4252BF84AD300958AB4 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3853E4242BF84AD300958AB4 /* SettingsView.swift */; }; 3853E4272BF85AC500958AB4 /* Miscellaneous.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3853E4262BF85AC500958AB4 /* Miscellaneous.swift */; }; 3853E4292BF85CC800958AB4 /* AddView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3853E4282BF85CC800958AB4 /* AddView.swift */; }; + 3853E42B2BF88E4B00958AB4 /* AI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3853E42A2BF88E4B00958AB4 /* AI.swift */; }; + 3853E42D2BF8906B00958AB4 /* Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3853E42C2BF8906B00958AB4 /* Keys.swift */; }; 38C2ED562BF4680E00B2C7C8 /* Calorie_CounterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38C2ED552BF4680E00B2C7C8 /* Calorie_CounterApp.swift */; }; 38C2ED5C2BF4681000B2C7C8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 38C2ED5B2BF4681000B2C7C8 /* Assets.xcassets */; }; 38C2ED5F2BF4681000B2C7C8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 38C2ED5E2BF4681000B2C7C8 /* Preview Assets.xcassets */; }; @@ -27,12 +29,14 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 3853E41E2BF80FEE00958AB4 /* Logic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logic.swift; sourceTree = ""; }; + 3853E41E2BF80FEE00958AB4 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; 3853E4202BF8129900958AB4 /* MealProcessingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealProcessingView.swift; sourceTree = ""; }; 3853E4222BF8463B00958AB4 /* Weight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weight.swift; sourceTree = ""; }; 3853E4242BF84AD300958AB4 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 3853E4262BF85AC500958AB4 /* Miscellaneous.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Miscellaneous.swift; sourceTree = ""; }; 3853E4282BF85CC800958AB4 /* AddView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddView.swift; sourceTree = ""; }; + 3853E42A2BF88E4B00958AB4 /* AI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AI.swift; sourceTree = ""; }; + 3853E42C2BF8906B00958AB4 /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = ""; }; 38C2ED522BF4680E00B2C7C8 /* Calorie Counter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Calorie Counter.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 38C2ED552BF4680E00B2C7C8 /* Calorie_CounterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Calorie_CounterApp.swift; sourceTree = ""; }; 38C2ED5B2BF4681000B2C7C8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -113,9 +117,11 @@ 38C2ED6D2BF47EDB00B2C7C8 /* CircularProgressView.swift */, 38C2ED712BF4815500B2C7C8 /* MacrosView.swift */, 38C2ED6B2BF4771600B2C7C8 /* LargeCardView.swift */, - 3853E41E2BF80FEE00958AB4 /* Logic.swift */, + 3853E41E2BF80FEE00958AB4 /* Utilities.swift */, 3853E4202BF8129900958AB4 /* MealProcessingView.swift */, 3853E4262BF85AC500958AB4 /* Miscellaneous.swift */, + 3853E42A2BF88E4B00958AB4 /* AI.swift */, + 3853E42C2BF8906B00958AB4 /* Keys.swift */, ); path = Components; sourceTree = ""; @@ -203,10 +209,11 @@ buildActionMask = 2147483647; files = ( 38C2ED722BF4815500B2C7C8 /* MacrosView.swift in Sources */, + 3853E42D2BF8906B00958AB4 /* Keys.swift in Sources */, 38C2ED742BF494D900B2C7C8 /* MealCardView.swift in Sources */, 3853E4292BF85CC800958AB4 /* AddView.swift in Sources */, 38C2ED662BF4685200B2C7C8 /* Meal.swift in Sources */, - 3853E41F2BF80FEE00958AB4 /* Logic.swift in Sources */, + 3853E41F2BF80FEE00958AB4 /* Utilities.swift in Sources */, 38C2ED702BF4804800B2C7C8 /* MainTabView.swift in Sources */, 38C2ED762BF4972E00B2C7C8 /* WelcomeView.swift in Sources */, 3853E4212BF8129900958AB4 /* MealProcessingView.swift in Sources */, @@ -215,6 +222,7 @@ 38C2ED6C2BF4771600B2C7C8 /* LargeCardView.swift in Sources */, 38C2ED6E2BF47EDB00B2C7C8 /* CircularProgressView.swift in Sources */, 38C2ED562BF4680E00B2C7C8 /* Calorie_CounterApp.swift in Sources */, + 3853E42B2BF88E4B00958AB4 /* AI.swift in Sources */, 3853E4232BF8463B00958AB4 /* Weight.swift in Sources */, 3853E4252BF84AD300958AB4 /* SettingsView.swift in Sources */, ); diff --git a/Calorie Counter/Components/AI.swift b/Calorie Counter/Components/AI.swift new file mode 100644 index 0000000..ac34fd3 --- /dev/null +++ b/Calorie Counter/Components/AI.swift @@ -0,0 +1,116 @@ +// +// AI.swift +// Calorie Counter +// +// Created by Jim Bisenius on 5/18/24. +// + +import Foundation + +struct OpenAIResponse: Codable { + struct Choice: Codable { + struct Message: Codable { + let role: String + let content: String + } + let message: Message + } + let choices: [Choice] +} + +func callOpenAIAPIForMealDescription(mealDescription: String, completion: @escaping (Result<[String: Any], Error>) -> Void) { + let prompt = """ + You are a nutritionist AI assistant. Based on the description of a meal provided, your task is to estimate its nutritional content. Provide a short label (16 characters or less) that best describes the meal, alongisde an emoji that describes the meal, and then estimate the calories, protein, carbohydrates, and fats in JSON format. + + Description: \(mealDescription) + + Response format: + { + "label": "Short label", + "emoji": "A single emoji that best represents this meal", + "calories": "Calorie estimate", + "protein": "Protein estimate in grams", + "carbohydrates": "Carbohydrates estimate in grams", + "fats": "Fats estimate in grams" + } + + Example: + Description: Grilled chicken breast with quinoa and steamed broccoli + Response: + { + "label": "Chicken Meal", + "emoji": "🍗", + "calories": 450, + "protein": 35, + "carbohydrates": 40, + "fats": 15 + } + + Now, process the following meal description: + + Description: \(mealDescription) + """ + + guard let url = URL(string: "https://api.openai.com/v1/chat/completions") else { + print("Error: Invalid URL") + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(Keys.sandboxKey)", forHTTPHeaderField: "Authorization") // Replace with your actual API key + + let parameters: [String: Any] = [ + "model": "gpt-4o", // Replace with your desired model + "messages": [ + ["role": "system", "content": "You are a helpful assistant."], + ["role": "user", "content": prompt] + ], + "max_tokens": 256, + "temperature": 1.0, + "top_p": 1.0, + "frequency_penalty": 0, + "presence_penalty": 0 + ] + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: []) + } catch { + print("Error encoding JSON body: \(error.localizedDescription)") + completion(.failure(error)) + return + } + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + print("Error: \(error.localizedDescription)") + completion(.failure(error)) + return + } + + guard let data = data else { + print("Error: No data received!") + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data received"]))) + return + } + + do { + let response = try JSONDecoder().decode(OpenAIResponse.self, from: data) + if let firstChoice = response.choices.first { + let responseData = firstChoice.message.content.data(using: .utf8)! + let jsonResponse = try JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any] + completion(.success(jsonResponse ?? [:])) + } else { + print("Error: No choices in response!") + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No choices in response"]))) + } + } catch { + print("Error decoding JSON response: \(error.localizedDescription)") + completion(.failure(error)) + } + } + + task.resume() +} diff --git a/Calorie Counter/Components/Miscellaneous.swift b/Calorie Counter/Components/Miscellaneous.swift index 0a6369e..0d6f769 100644 --- a/Calorie Counter/Components/Miscellaneous.swift +++ b/Calorie Counter/Components/Miscellaneous.swift @@ -31,8 +31,10 @@ func settingsSection(header: String, @ViewBuilder content: () -> } @ViewBuilder -func settingsRow(title: String, value: String? = nil, imageName: String? = nil, lastRow: Bool?, gray: Bool?, danger: Bool?) -> some View { - Button(action: { handleTap(option: title) }) { +func settingsRow(title: String, value: String? = nil, imageName: String? = nil, lastRow: Bool?, gray: Bool?, danger: Bool?, onTap: ((String) -> Void)?) -> some View { + Button(action: { if onTap != nil { + onTap?(title) + }}) { HStack { Text(title) .fontWeight(.medium) diff --git a/Calorie Counter/Components/Logic.swift b/Calorie Counter/Components/Utilities.swift similarity index 100% rename from Calorie Counter/Components/Logic.swift rename to Calorie Counter/Components/Utilities.swift diff --git a/Calorie Counter/Screens/AddView.swift b/Calorie Counter/Screens/AddView.swift index 27eb8cd..a77d49d 100644 --- a/Calorie Counter/Screens/AddView.swift +++ b/Calorie Counter/Screens/AddView.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI struct AddView: View { + @Environment(\.modelContext) private var modelContext @State private var caloriesGoal: String = "2,000 calories / day" @State private var proteinGoal: String = "23g / day" @State private var weightGoal: String = "192 lbs" @@ -16,21 +17,71 @@ struct AddView: View { @State private var mealsToShow: String = "3" @State private var mealReminders: Bool = false @State private var sendAnonymousData: Bool = true + @State private var showingDescribeMealAlert: Bool = false + @State private var mealDescription: String = "" + @Binding var selection: String var body: some View { NavigationView { ScrollView { VStack(spacing: 20) { settingsSection(header: "Weight") { - settingsRow(title: "Enter your weight manually", imageName: "Received", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Pair smart-scale", imageName: "Scale", lastRow: true, gray: nil, danger: nil) + settingsRow(title: "Enter your weight manually", imageName: "Received", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Pair smart-scale", imageName: "Scale", lastRow: true, gray: nil, danger: nil, onTap: nil) } settingsSection(header: "Meal") { - settingsRow(title: "Take a photo of your meal", imageName: "Camera", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Select meal photo from camera roll", imageName: "Photo", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Describe your meal", imageName: "Chat", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Scan barcode", imageName: "Barcode", lastRow: true, gray: nil, danger: nil) + settingsRow(title: "Take a photo of your meal", imageName: "Camera", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Select meal photo from camera roll", imageName: "Photo", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Describe your meal", imageName: "Chat", lastRow: nil, gray: nil, danger: nil, onTap: {_ in + showingDescribeMealAlert.toggle() + } + ) + .alert("Describe your meal", isPresented: $showingDescribeMealAlert) { + TextField("ex: A 12ct chicken nugget meal from Chickfila", text: $mealDescription) + Button("Save meal", action: { + let desiredMealDescription = $mealDescription.wrappedValue + if desiredMealDescription != "" { + // TODO: Move to Processing page? + print("Prompt sent to OpenAI") + callOpenAIAPIForMealDescription(mealDescription: desiredMealDescription) { result in + switch result { + case .success(let response): + print("OpenAI Success") + print(response) + let meal = Meal( + emoji: response["emoji"] as? String, + createdAt: Date(), + label: response["label"] as? String, + details: desiredMealDescription, + reviewedAt: Date(), + calories: response["calories"] as? Int, + protein: response["protein"] as? Int, + carbohydrates: response["carbohydrates"] as? Int, + fats: response["fats"] as? Int + ) + modelContext.insert(meal) + mealDescription = "" + do { + try modelContext.save() + // TODO: Redirect to Meal detail page rather than Summary + selection = "Summary" + } catch { + print(error) + } + case .failure(let error): + print("Error: \(error.localizedDescription)") + } + } + } + }) + Button("Cancel", action: { + showingDescribeMealAlert.toggle() + }) + } message: { + Text("Enter as much information as you can. ex: What and how much you ate, from where, modifications, etc") + } + settingsRow(title: "Scan barcode", imageName: "Barcode", lastRow: true, gray: nil, danger: nil, onTap: nil) } } .padding() diff --git a/Calorie Counter/Screens/MainTabView.swift b/Calorie Counter/Screens/MainTabView.swift index ba104ba..744443d 100644 --- a/Calorie Counter/Screens/MainTabView.swift +++ b/Calorie Counter/Screens/MainTabView.swift @@ -28,7 +28,7 @@ struct MainTabView: View { Text("Summary") }.tag("Summary") - AddView() + AddView(selection: $selection) .tabItem { Image(systemName: "plus.circle.fill") Text("Add new") diff --git a/Calorie Counter/Screens/SettingsView.swift b/Calorie Counter/Screens/SettingsView.swift index 95bd643..ba89208 100644 --- a/Calorie Counter/Screens/SettingsView.swift +++ b/Calorie Counter/Screens/SettingsView.swift @@ -24,46 +24,47 @@ struct SettingsView: View { VStack(spacing: 20) { settingsSection(header: "Favorites") { - settingsRow(title: "Calories", value: nil, imageName: "Bars", lastRow: nil, gray: true, danger: nil) + settingsRow(title: "Calories", value: nil, imageName: "Bars", lastRow: nil, gray: true, danger: nil, onTap: nil) /*.onDrag { print("Dragging item: Calories") return NSItemProvider() }*/ - settingsRow(title: "Weight", value: nil, imageName: "Bars", lastRow: nil, gray: true, danger: nil) + settingsRow(title: "Weight", value: nil, imageName: "Bars", lastRow: nil, gray: true, danger: nil, onTap: nil) /*.onDrag { print("Dragging item: Weight") return NSItemProvider() }*/ - settingsRow(title: "Macros", value: nil, imageName: "Bars", lastRow: nil, gray: true, danger: nil) - settingsRow(title: "Meals", value: nil, imageName: "Bars", lastRow: true, gray: true, danger: nil) + settingsRow(title: "Macros", value: nil, imageName: "Bars", lastRow: true, gray: true, danger: nil, onTap: nil) } settingsSection(header: "Goals") { - settingsRow(title: "Calories", value: caloriesGoal, lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Protein", value: proteinGoal, lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Weight", value: weightGoal, lastRow: true, gray: nil, danger: nil) + settingsRow(title: "Calories", value: caloriesGoal, lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Protein", value: proteinGoal, lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Carbohydrates", value: proteinGoal, lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Fats", value: proteinGoal, lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Weight", value: weightGoal, lastRow: true, gray: nil, danger: nil, onTap: nil) } settingsSection(header: "Preferences") { - settingsRow(title: "Ask for description on meal photos?", value: askForDescription ? "Yes" : "No", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Meals to show by default?", value: mealsToShow, lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Meal reminders?", value: mealReminders ? "Enabled" : "Disabled", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Send anonymous usage data?", value: sendAnonymousData ? "Enabled" : "Disabled", lastRow: true, gray: nil, danger: nil) + settingsRow(title: "Ask for description on meal photos?", value: askForDescription ? "Yes" : "No", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Meals to show by default?", value: mealsToShow, lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Meal reminders?", value: mealReminders ? "Enabled" : "Disabled", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Send anonymous usage data?", value: sendAnonymousData ? "Enabled" : "Disabled", lastRow: true, gray: nil, danger: nil, onTap: nil) } settingsSection(header: "Community") { - settingsRow(title: "Join our Discord Community", imageName: "Discord", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Refer a friend, get $5!", imageName: "Gift", lastRow: true, gray: nil, danger: nil) + settingsRow(title: "Join our Discord Community", imageName: "Discord", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Refer a friend, get $5!", imageName: "Gift", lastRow: true, gray: nil, danger: nil, onTap: nil) } settingsSection(header: "Support") { - settingsRow(title: "Restore purchases", imageName: "Bag", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Upgrade and unlock full access", imageName: "Lightning", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Export data", imageName: "Export", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Report bug", imageName: "Bug", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Contact support", imageName: "Raft", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "View Privacy Policy / EULA", imageName: "Building", lastRow: nil, gray: nil, danger: nil) - settingsRow(title: "Delete all data", imageName: "Garbage", lastRow: true, gray: nil, danger: true) + settingsRow(title: "Restore purchases", imageName: "Bag", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Upgrade and unlock full access", imageName: "Lightning", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Export data", imageName: "Export", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Report bug", imageName: "Bug", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Contact support", imageName: "Raft", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "View Privacy Policy / EULA", imageName: "Building", lastRow: nil, gray: nil, danger: nil, onTap: nil) + settingsRow(title: "Delete all data", imageName: "Garbage", lastRow: true, gray: nil, danger: true, onTap: nil) } } .padding() diff --git a/Calorie Counter/Screens/SummaryView.swift b/Calorie Counter/Screens/SummaryView.swift index 806f9f6..881b20c 100644 --- a/Calorie Counter/Screens/SummaryView.swift +++ b/Calorie Counter/Screens/SummaryView.swift @@ -120,7 +120,7 @@ struct SummaryView: View { } // Meals Section - VStack(alignment: .leading, spacing: 15) { + VStack(alignment: .leading, spacing: 10) { HStack { Text("Meals") .font(.title2)