├── AIExpenseTracker
├── Assets.xcassets
│ ├── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── Info.plist
├── AIExpenseTracker.entitlements
├── Utils.swift
├── ReceiptScanner
│ ├── Receipt+ExpenseLog.swift
│ ├── AddReceiptToExpenseConfirmationViewModel.swift
│ ├── ExpenseReceiptScannerView.swift
│ └── AddReceiptToExpenseConfirmationView.swift
├── Models
│ ├── Sort.swift
│ ├── ExpenseLog.swift
│ └── Category.swift
├── Views
│ ├── CategoryImageView.swift
│ ├── LogListContainerView.swift
│ ├── LogItemView.swift
│ ├── SelectSortOrderView.swift
│ ├── ChartView.swift
│ ├── FilterCategoriesView.swift
│ ├── LogListView.swift
│ └── LogFormView.swift
├── AIExpenseTrackerApp.swift
├── AIAssistant
│ ├── Date+Extension.swift
│ ├── Models
│ │ ├── FunctionResponse.swift
│ │ ├── FunctionArguments.swift
│ │ └── FunctionTools.swift
│ ├── Views
│ │ ├── AIAssistantView.swift
│ │ └── AIAssistantResponseView.swift
│ ├── ViewModels
│ │ ├── AIAssistantVoiceChatViewModel.swift
│ │ └── AIAssistantTextChatViewModel.swift
│ └── FunctionsManager.swift
├── DatabaseManager.swift
├── ViewModels
│ ├── LogListViewModel.swift
│ └── LogFormViewModel.swift
├── AppDelegate.swift
└── ContentView.swift
├── .gitignore
├── AIExpenseTracker.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ │ ├── IDEWorkspaceChecks.plist
│ │ └── swiftpm
│ │ └── Package.resolved
└── project.pbxproj
└── README.md
/AIExpenseTracker/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/config/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 | GoogleService-Info.plist
10 |
--------------------------------------------------------------------------------
/AIExpenseTracker.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/AIExpenseTracker.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSAppTransportSecurity
6 |
7 | NSAllowsArbitraryLoads
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/AIExpenseTracker/AIExpenseTracker.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.device.audio-input
8 |
9 | com.apple.security.files.user-selected.read-only
10 |
11 | com.apple.security.network.client
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Utils.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Utils.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import Foundation
9 |
10 | struct Utils {
11 |
12 | static let dateFormatter: DateFormatter = {
13 | let formatter = DateFormatter()
14 | formatter.dateFormat = "dd/MM"
15 | return formatter
16 | }()
17 |
18 | static let numberFormatter: NumberFormatter = {
19 | let formatter = NumberFormatter()
20 | formatter.isLenient = true
21 | formatter.numberStyle = .currency
22 | return formatter
23 | }()
24 |
25 | }
26 |
--------------------------------------------------------------------------------
/AIExpenseTracker/ReceiptScanner/Receipt+ExpenseLog.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Receipt+ExpenseLogs.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 07/07/24.
6 | //
7 |
8 | import AIReceiptScanner
9 | import Foundation
10 |
11 |
12 | extension Receipt {
13 |
14 | var expenseLogs: [ExpenseLog] {
15 | (items ?? []).map {
16 | .init(id: $0.id.uuidString,
17 | name: "\($0.quantity > 1 ? "\(Int($0.quantity)) x " : "")\($0.name)",
18 | category: $0.category,
19 | amount: $0.price,
20 | currency: currency ?? "USD",
21 | date: date ?? .now)
22 |
23 | }
24 | }
25 |
26 | }
27 |
28 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Models/Sort.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Sort.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import Foundation
9 |
10 | enum SortType: String, Identifiable, CaseIterable {
11 |
12 | var id: Self { self }
13 | case date, amount, name
14 |
15 | var systemNameIcon: String {
16 | switch self {
17 | case .date:
18 | return "calendar"
19 | case .amount:
20 | return "dollarsign.circle"
21 | case .name:
22 | return "a"
23 | }
24 | }
25 |
26 | }
27 |
28 | enum SortOrder: String, Identifiable, CaseIterable {
29 |
30 | var id: Self { self }
31 | case ascending, descending
32 |
33 | }
34 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Views/CategoryImageView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoryImageView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CategoryImageView: View {
11 |
12 | let category: Category
13 |
14 | var body: some View {
15 | Image(systemName: category.systemNameIcon)
16 | .resizable()
17 | .aspectRatio(contentMode: .fit)
18 | .frame(width: 20, height: 20)
19 | .padding(.all, 8)
20 | .foregroundColor(category.color)
21 | .background(category.color.opacity(0.1))
22 | .cornerRadius(18)
23 | }
24 | }
25 |
26 | #Preview {
27 | CategoryImageView(category: .utilities)
28 | }
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # XCA AI Expense Tracker SwiftUI App
2 |
3 | 
4 |
5 | Realtime Expense Tracker SwiftUI App
6 |
7 | ## Fetures
8 | - List Expense logs.
9 | - Filter by multiple categories.
10 | - Sorty by date, amount, name. Ascending or Descending.
11 | - Add, Edit, Delete Expense Log.
12 | - Supports iOS/iPadOS, macOS, visionOS.
13 | - AI Assistant: Chat by Text/Voice.
14 | - AI Receipt Scanner.
15 |
16 | ## Requirements
17 | - Xcode 15
18 | - Replace the bundleID for the App with your own.
19 | - Firebase iOS Project, download `GoogleService-info.plist` to your Xcode project target.
20 |
21 | ## YouTube Tutorial
22 | You can check the full video tutorial on building this from scratch.
23 |
24 | [YouTube](https://youtu.be/tU81xrWx6uY)
--------------------------------------------------------------------------------
/AIExpenseTracker/AIExpenseTrackerApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AIExpenseTrackerApp.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @main
11 | struct AIExpenseTrackerApp: App {
12 |
13 | #if os(macOS)
14 | @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate
15 | #else
16 | @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
17 | #endif
18 |
19 | var body: some Scene {
20 | WindowGroup {
21 | ContentView()
22 | #if os(macOS)
23 | .frame(minWidth: 729, minHeight: 480)
24 | #endif
25 | }
26 | #if os(macOS)
27 | .windowResizability(.contentMinSize)
28 | #endif
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/AIExpenseTracker/AIAssistant/Date+Extension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Date+Extension.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/06/24.
6 | //
7 |
8 | import Foundation
9 |
10 |
11 | extension Date {
12 |
13 | var startOfDay: Date {
14 | let calendar = Calendar.current
15 | let startDate = calendar.startOfDay(for: self)
16 | return startDate
17 | }
18 |
19 | var endOfDay: Date {
20 | let calendar = Calendar.current
21 | var components = DateComponents()
22 | components.day = 1
23 |
24 | let startOfNextDay = calendar.date(byAdding: components, to: calendar.startOfDay(for: self))!
25 | let endOfDay = startOfNextDay.addingTimeInterval(-1)
26 | return endOfDay
27 | }
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/AIExpenseTracker/AIAssistant/Models/FunctionResponse.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FunctionResponse.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/06/24.
6 | //
7 |
8 | import Foundation
9 |
10 | typealias AddExpenseLogConfirmationCallback = ((Bool, AddExpenseLogViewProperties) -> Void)
11 |
12 | enum UserConfirmation {
13 | case pending, confirmed, cancelled
14 | }
15 |
16 | struct AddExpenseLogViewProperties {
17 | let log: ExpenseLog
18 | let messageID: UUID?
19 | let userConfirmation: UserConfirmation
20 | let confirmationCallback: AddExpenseLogConfirmationCallback?
21 | }
22 |
23 | struct AIAssistantResponse {
24 | let text: String
25 | let type: AIAssistantResponseFunctionType
26 | }
27 |
28 | enum AIAssistantResponseFunctionType {
29 | case addExpenseLog(AddExpenseLogViewProperties)
30 | case listExpenses([ExpenseLog])
31 | case visualizeExpenses(ChartType, [Option])
32 | case contentText
33 | }
34 |
--------------------------------------------------------------------------------
/AIExpenseTracker/DatabaseManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatabaseManager.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import FirebaseFirestore
9 | import Foundation
10 |
11 | class DatabaseManager {
12 |
13 | static let shared = DatabaseManager()
14 |
15 | private init() {}
16 |
17 | private (set) lazy var logsCollection: CollectionReference = {
18 | Firestore.firestore().collection("logs")
19 | }()
20 |
21 | func add(log: ExpenseLog) throws {
22 | try logsCollection.document(log.id).setData(from: log)
23 | }
24 |
25 | func update(log: ExpenseLog) {
26 | logsCollection.document(log.id).updateData([
27 | "name": log.name,
28 | "amount": log.amount,
29 | "category": log.category,
30 | "date": log.date
31 | ])
32 | }
33 |
34 | func delete(log: ExpenseLog) {
35 | logsCollection.document(log.id).delete()
36 | }
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/AIExpenseTracker/ViewModels/LogListViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LogListViewModel.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import FirebaseFirestore
9 | import Foundation
10 | import Observation
11 |
12 | @Observable
13 | class LogListViewModel {
14 |
15 | let db = DatabaseManager.shared
16 |
17 | var sortType = SortType.date
18 | var sortOrder = SortOrder.descending
19 | var selectedCategories = Set()
20 |
21 | var isLogFormPresented = false
22 | var logToEdit: ExpenseLog?
23 |
24 |
25 | var predicates: [QueryPredicate] {
26 | var predicates = [QueryPredicate]()
27 | if selectedCategories.count > 0 {
28 | predicates.append(.whereField("category", isIn: Array(selectedCategories).map { $0.rawValue }))
29 | }
30 |
31 | predicates.append(.order(by: sortType.rawValue, descending: sortOrder == .descending ? true : false))
32 | return predicates
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/AIExpenseTracker/AIAssistant/Models/FunctionArguments.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FunctionArguments.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/06/24.
6 | //
7 |
8 | import Foundation
9 |
10 | struct AddExpenseLogArgs: Codable {
11 |
12 | let title: String
13 | let amount: Double
14 | let category: String
15 | let currency: String?
16 | let date: Date?
17 | }
18 |
19 | struct ListExpenseArgs: Codable {
20 |
21 | let date: Date?
22 | let startDate: Date?
23 | let endDate: Date?
24 | let category: String?
25 | let sortOrder: String?
26 | let quantityOfLogs: Int?
27 |
28 | var isDateFilterExists: Bool {
29 | (startDate != nil && endDate != nil) || date != nil
30 | }
31 | }
32 |
33 | struct VisualizeExpenseArgs: Codable {
34 |
35 | let date: Date?
36 | let startDate: Date?
37 | let endDate: Date?
38 |
39 | let chartType: String
40 |
41 | var chartTypeEnum: ChartType {
42 | ChartType(rawValue: chartType) ?? .pie
43 | }
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Models/ExpenseLog.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ExpenseLog.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import Foundation
9 |
10 | struct ExpenseLog: Codable, Identifiable, Equatable {
11 |
12 | let id: String
13 | var name: String
14 | var category: String
15 | var amount: Double
16 | var currency: String
17 | var date: Date
18 |
19 | var categoryEnum: Category {
20 | Category(rawValue: category) ?? .utilities
21 | }
22 |
23 | init(id: String, name: String, category: String, amount: Double, currency: String = "USD", date: Date) {
24 | self.id = id
25 | self.name = name
26 | self.category = category
27 | self.amount = amount
28 | self.currency = currency
29 | self.date = date
30 | }
31 |
32 | }
33 |
34 | extension ExpenseLog {
35 |
36 | var dateText: String {
37 | Utils.dateFormatter.string(from: date)
38 | }
39 |
40 | var amountText: String {
41 | Utils.numberFormatter.currencySymbol = currency
42 | return Utils.numberFormatter.string(from: NSNumber(value: amount))
43 | ?? "\(amount)"
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/AIExpenseTracker/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import Firebase
9 | import FirebaseFirestore
10 | import Foundation
11 |
12 | #if os(macOS)
13 | import Cocoa
14 |
15 | class AppDelegate: NSObject, NSApplicationDelegate {
16 |
17 | func applicationWillFinishLaunching(_ notification: Notification) {
18 | setupFirebase()
19 | }
20 |
21 | }
22 |
23 | #else
24 | import UIKit
25 |
26 | class AppDelegate: NSObject, UIApplicationDelegate {
27 |
28 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
29 | setupFirebase()
30 | return true
31 | }
32 |
33 | }
34 |
35 | #endif
36 |
37 | fileprivate func isPreviewRuntime() -> Bool {
38 | ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
39 | }
40 |
41 | fileprivate func setupFirebase() {
42 | FirebaseApp.configure()
43 | if isPreviewRuntime() {
44 | let settings = Firestore.firestore().settings
45 | settings.host = "localhost:8080"
46 | settings.isPersistenceEnabled = false
47 | settings.isSSLEnabled = false
48 | Firestore.firestore().settings = settings
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | },
8 | {
9 | "idiom" : "mac",
10 | "scale" : "1x",
11 | "size" : "16x16"
12 | },
13 | {
14 | "idiom" : "mac",
15 | "scale" : "2x",
16 | "size" : "16x16"
17 | },
18 | {
19 | "idiom" : "mac",
20 | "scale" : "1x",
21 | "size" : "32x32"
22 | },
23 | {
24 | "idiom" : "mac",
25 | "scale" : "2x",
26 | "size" : "32x32"
27 | },
28 | {
29 | "idiom" : "mac",
30 | "scale" : "1x",
31 | "size" : "128x128"
32 | },
33 | {
34 | "idiom" : "mac",
35 | "scale" : "2x",
36 | "size" : "128x128"
37 | },
38 | {
39 | "idiom" : "mac",
40 | "scale" : "1x",
41 | "size" : "256x256"
42 | },
43 | {
44 | "idiom" : "mac",
45 | "scale" : "2x",
46 | "size" : "256x256"
47 | },
48 | {
49 | "idiom" : "mac",
50 | "scale" : "1x",
51 | "size" : "512x512"
52 | },
53 | {
54 | "idiom" : "mac",
55 | "scale" : "2x",
56 | "size" : "512x512"
57 | }
58 | ],
59 | "info" : {
60 | "author" : "xcode",
61 | "version" : 1
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Views/LogListContainerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LogListContainerView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct LogListContainerView: View {
11 |
12 | @Binding var vm: LogListViewModel
13 |
14 | var body: some View {
15 | VStack(spacing: 0) {
16 | FilterCategoriesView(selectedCategories: $vm.selectedCategories)
17 | Divider()
18 | SelectSortOrderView(sortType: $vm.sortType, sortOrder: $vm.sortOrder)
19 | Divider()
20 | LogListView(vm: $vm)
21 | }
22 | .toolbar {
23 | ToolbarItem {
24 | Button {
25 | vm.isLogFormPresented = true
26 | } label: {
27 | #if os(macOS)
28 | HStack {
29 | Image(systemName: "plus")
30 | .symbolRenderingMode(.monochrome)
31 | .tint(.accentColor)
32 | Text("Add Expense Log")
33 | }
34 | .foregroundStyle(Color.accentColor)
35 | #else
36 | Text("Add")
37 | #endif
38 | }
39 |
40 | }
41 |
42 | }
43 | .sheet(isPresented: $vm.isLogFormPresented) {
44 | LogFormView(vm: .init())
45 | }
46 | #if !os(macOS)
47 | .navigationBarTitle("XCA AI Expense Tracker", displayMode: .inline)
48 | #endif
49 | }
50 | }
51 |
52 | #Preview {
53 | @State var vm = LogListViewModel()
54 | return NavigationStack {
55 | LogListContainerView(vm: $vm)
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/AIExpenseTracker/AIAssistant/Views/AIAssistantView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AIAssistantView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/06/24.
6 | //
7 |
8 | import ChatGPTUI
9 | import SwiftUI
10 |
11 | let apiKey = "YOUR_API_KEY"
12 | let _senderImage = "https://imagizer.imageshack.com/img923/732/0xV2bC.jpg"
13 | let _botImage = "https://freepnglogo.com/images/all_img/1690998192chatgpt-logo-png.png"
14 |
15 | enum ChatType: String, Identifiable, CaseIterable {
16 | case text = "Text"
17 | case voice = "Voice"
18 | var id: Self { self }
19 | }
20 |
21 | struct AIAssistantView: View {
22 |
23 | @State var textChatVM = AIAssistantTextChatViewModel(apiKey: apiKey)
24 | @State var voiceChatVM = AIAssistantVoiceChatViewModel(apiKey: apiKey)
25 | @State var chatType = ChatType.text
26 |
27 | var body: some View {
28 | VStack(spacing: 0) {
29 | Picker(selection: $chatType, label: Text("Chat Type").font(.system(size: 12, weight: .bold))) {
30 | ForEach(ChatType.allCases) { type in
31 | Text(type.rawValue).tag(type)
32 | }
33 | }
34 | .pickerStyle(SegmentedPickerStyle())
35 | .padding(.horizontal)
36 |
37 | #if !os(iOS)
38 | .padding(.vertical)
39 | #endif
40 |
41 | Divider()
42 |
43 | ZStack {
44 | switch chatType {
45 | case .text:
46 | TextChatView(customContentVM: textChatVM)
47 | case .voice:
48 | VoiceChatView(customContentVM: voiceChatVM)
49 | }
50 | }.frame(maxWidth: 1024, alignment: .center)
51 | }
52 | #if !os(macOS)
53 | .navigationBarTitle("XCA AI Expense Assistant", displayMode: .inline)
54 | #endif
55 | }
56 | }
57 |
58 | #Preview {
59 | AIAssistantView()
60 | }
61 |
--------------------------------------------------------------------------------
/AIExpenseTracker/ReceiptScanner/AddReceiptToExpenseConfirmationViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddReceiptToExpenseConfirmationViewModel.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 07/07/24.
6 | //
7 |
8 | import AIReceiptScanner
9 | import Observation
10 | import Foundation
11 |
12 | @Observable
13 | class AddReceiptToExpenseConfirmationViewModel {
14 |
15 | let db = DatabaseManager.shared
16 | let scanResult: SuccessScanResult
17 | let scanResultExpenseLogs: [ExpenseLog]
18 |
19 | var date: Date
20 | var currencyCode: String {
21 | willSet {
22 | self.numberFormatter.currencyCode = newValue
23 | }
24 | }
25 | var expenseLogs: [ExpenseLog]
26 | var isEdited: Bool {
27 | !(scanResult.receipt.date == date && expenseLogs == scanResultExpenseLogs)
28 | }
29 |
30 | let numberFormatter: NumberFormatter = {
31 | let formatter = NumberFormatter()
32 | formatter.isLenient = true
33 | formatter.numberStyle = .currency
34 | formatter.currencyCode = "USD"
35 | return formatter
36 | }()
37 |
38 | init(scanResult: SuccessScanResult) {
39 | self.scanResult = scanResult
40 | self.scanResultExpenseLogs = scanResult.receipt.expenseLogs
41 | self.expenseLogs = self.scanResultExpenseLogs
42 | self.date = scanResult.receipt.date ?? .now
43 | self.currencyCode = scanResult.receipt.currency ?? "USD"
44 | self.numberFormatter.currencyCode = self.currencyCode
45 | }
46 |
47 | func save() {
48 | expenseLogs.forEach { log in
49 | var _log = log
50 | _log.date = self.date
51 | _log.currency = self.currencyCode
52 | try? db.add(log: _log)
53 | }
54 | }
55 |
56 | func resetChanges() {
57 | self.expenseLogs = self.scanResultExpenseLogs
58 | self.date = scanResult.receipt.date ?? .now
59 | self.currencyCode = scanResult.receipt.currency ?? "USD"
60 | }
61 |
62 | }
63 |
64 |
--------------------------------------------------------------------------------
/AIExpenseTracker/ReceiptScanner/ExpenseReceiptScannerView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ExpenseReceiptScannerView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 07/07/24.
6 | //
7 |
8 | import AIReceiptScanner
9 | import SwiftUI
10 |
11 | struct ExpenseReceiptScannerView: View {
12 |
13 | @State var scanStatus: ScanStatus = .idle
14 | @State var addReceiptToExpenseSheetItem: SuccessScanResult?
15 | @Environment(\.horizontalSizeClass) var horizontalSizeClass
16 |
17 | var body: some View {
18 | ReceiptPickerScannerView(apiKey: apiKey, scanStatus: $scanStatus)
19 | .sheet(item: $addReceiptToExpenseSheetItem) {
20 | AddReceiptToExpenseConfirmationView(vm: .init(scanResult: $0))
21 | .frame(minWidth: horizontalSizeClass == .regular ? 960 : nil, minHeight: horizontalSizeClass == .regular ? 512 : nil)
22 | }
23 | .navigationTitle("XCA AI Receipt Scanner")
24 | #if !os(macOS)
25 | .navigationBarTitleDisplayMode(.inline)
26 | #endif
27 | .toolbar {
28 | ToolbarItem(placement: .primaryAction) {
29 | if let scanResult = scanStatus.scanResult {
30 | Button {
31 | addReceiptToExpenseSheetItem = scanResult
32 | } label: {
33 | #if os(macOS)
34 | HStack {
35 | Image(systemName: "plus")
36 | .symbolRenderingMode(.monochrome)
37 | .tint(.accentColor)
38 | Text("Add to Expesnes")
39 | }
40 | #else
41 | Text("Add to Expenses")
42 | #endif
43 | }
44 | }
45 | }
46 | }
47 | }
48 | }
49 |
50 | //#Preview {
51 | // ExpenseReceiptScannerView()
52 | //}
53 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Views/LogItemView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LogItemView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct LogItemView: View {
11 |
12 | let log: ExpenseLog
13 | @Environment(\.horizontalSizeClass) var horizontalSizeClass
14 |
15 | var body: some View {
16 | switch horizontalSizeClass {
17 | case .compact: compactView
18 | default: regularView
19 | }
20 | }
21 |
22 | var compactView: some View {
23 | HStack(spacing: 16) {
24 | CategoryImageView(category: log.categoryEnum)
25 | VStack(alignment: .leading, spacing: 8) {
26 | Text(log.name).font(.headline)
27 | Text(log.dateText).font(.subheadline)
28 | }
29 | Spacer()
30 | Text(log.amountText).font(.headline)
31 | }
32 | }
33 |
34 | var regularView: some View {
35 | HStack(spacing: 16) {
36 | CategoryImageView(category: log.categoryEnum)
37 | Spacer()
38 | Text(log.name)
39 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
40 | Spacer()
41 | Text(log.amountText)
42 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
43 | Spacer()
44 | Text(log.dateText)
45 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
46 | Spacer()
47 | Text(log.categoryEnum.rawValue)
48 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
49 | Spacer()
50 | }
51 | }
52 | }
53 |
54 | #Preview {
55 | VStack {
56 | ForEach([
57 | ExpenseLog(id: "1", name: "sushi", category: "Food", amount: 10, date: .now),
58 | ExpenseLog(id: "2", name: "Electricity", category: "Utilities", amount: 50, date: .now)
59 | ]) { log in
60 | LogItemView(log: log)
61 | }
62 | }
63 | .padding()
64 | }
65 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Views/SelectSortOrderView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SelectSortOrderView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SelectSortOrderView: View {
11 |
12 | @Environment(\.horizontalSizeClass) var horizontalSizeClass
13 |
14 | @Binding var sortType: SortType
15 | @Binding var sortOrder: SortOrder
16 |
17 | private let sortTypes = SortType.allCases
18 | private let sortOrders = SortOrder.allCases
19 |
20 | var body: some View {
21 | HStack {
22 | #if !os(macOS)
23 | Text("Sort By")
24 | #endif
25 |
26 | Picker(selection: $sortType, label: Text("Sort By")) {
27 | ForEach(sortTypes) { type in
28 | if horizontalSizeClass == .compact {
29 | Image(systemName: type.systemNameIcon).tag(type)
30 | } else {
31 | Text(type.rawValue.capitalized)
32 | .tag(type)
33 | }
34 | }
35 | }.pickerStyle(SegmentedPickerStyle())
36 |
37 | #if !os(macOS)
38 | Text("Order By")
39 | #endif
40 |
41 | Picker(selection: $sortOrder, label: Text("Order By")) {
42 | ForEach(sortOrders) { order in
43 | if horizontalSizeClass == .compact {
44 | Image(systemName: order == .ascending ? "arrow.up" : "arrow.down").tag(order)
45 | } else {
46 | Text(order.rawValue.capitalized)
47 | .tag(order)
48 | }
49 | }
50 | }.pickerStyle(SegmentedPickerStyle())
51 |
52 | }
53 | .padding()
54 | .frame(height: 64)
55 | }
56 |
57 | }
58 |
59 | #Preview {
60 | @State var vm = LogListViewModel()
61 | return SelectSortOrderView(sortType: $vm.sortType, sortOrder: $vm.sortOrder)
62 | }
63 |
--------------------------------------------------------------------------------
/AIExpenseTracker/ViewModels/LogFormViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LogFormViewModel.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import Foundation
9 | import Observation
10 |
11 | @Observable
12 | class FormViewModel {
13 |
14 | var logToEdit: ExpenseLog?
15 |
16 | let db = DatabaseManager.shared
17 |
18 | var name = ""
19 | var amount: Double = 0
20 | var category = Category.utilities
21 | var date = Date()
22 |
23 | var isSaveButtonDisabled: Bool {
24 | name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
25 | }
26 |
27 | let numberFormatter: NumberFormatter = {
28 | let formatter = NumberFormatter()
29 | formatter.isLenient = true
30 | formatter.numberStyle = .currency
31 | formatter.currencyCode = "USD"
32 | return formatter
33 | }()
34 |
35 | init(logToEdit: ExpenseLog? = nil) {
36 | self.logToEdit = logToEdit
37 | if let logToEdit {
38 | self.name = logToEdit.name
39 | self.amount = logToEdit.amount
40 | self.category = logToEdit.categoryEnum
41 | self.date = logToEdit.date
42 | numberFormatter.currencyCode = logToEdit.currency
43 | }
44 | }
45 |
46 | func save() {
47 | var log: ExpenseLog
48 | if let logToEdit {
49 | log = logToEdit
50 | } else {
51 | log = ExpenseLog(id: UUID().uuidString,
52 | name: "", category: "", amount: 0, date: .now)
53 | }
54 |
55 | log.name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
56 | log.category = self.category.rawValue
57 | log.amount = self.amount
58 | log.date = self.date
59 |
60 | if self.logToEdit == nil {
61 | try? db.add(log: log)
62 | } else {
63 | db.update(log: log)
64 | }
65 | }
66 |
67 | func delete(log: ExpenseLog) {
68 | db.delete(log: log)
69 | }
70 |
71 | }
72 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Views/ChartView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ChartView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/06/24.
6 | //
7 |
8 | import Charts
9 | import SwiftUI
10 | import Foundation
11 |
12 | enum ChartType: String {
13 | case pie, bar
14 | }
15 |
16 | struct Option: Identifiable {
17 | let id = UUID()
18 | let category: Category
19 | let amount: Double
20 | }
21 |
22 | struct BarChartView: View {
23 |
24 | let options: [Option]
25 |
26 | var body: some View {
27 | Chart {
28 | ForEach(options) {
29 | BarMark(
30 | x: .value("Category", $0.category.rawValue),
31 | y: .value("Amount", $0.amount)
32 | )
33 | .foregroundStyle(by: .value("Category", $0.category.rawValue))
34 | }
35 | }
36 | .chartForegroundStyleScale(mapping: { (category: String) in
37 | Category(rawValue: category)?.color ?? .accentColor
38 | })
39 | .padding(.vertical)
40 | }
41 | }
42 |
43 | struct PieChartView: View {
44 |
45 | let options: [Option]
46 |
47 | var body: some View {
48 | Chart {
49 | ForEach(options) { option in
50 | SectorMark(
51 | angle: .value("Amount", option.amount),
52 | innerRadius: .ratio(0.618),
53 | angularInset: 1.5
54 | )
55 | .cornerRadius(5)
56 | .foregroundStyle(by: .value("Category", option.category.rawValue))
57 | }
58 | }
59 | .chartForegroundStyleScale(mapping: { (category: String) in
60 | Category(rawValue: category)?.color ?? .accentColor
61 | })
62 | .padding(.vertical)
63 | }
64 | }
65 |
66 |
67 | #Preview {
68 | Group {
69 | BarChartView(options: [.init(category: .food, amount: 300), .init(category: .entertainment, amount: 1000)])
70 | PieChartView(options: [.init(category: .food, amount: 300), .init(category: .entertainment, amount: 1000)])
71 | }
72 |
73 | }
74 |
--------------------------------------------------------------------------------
/AIExpenseTracker/ContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ContentView: View {
11 |
12 | @State var vm = LogListViewModel()
13 | @Environment(\.horizontalSizeClass) var horizontalSizeClass
14 |
15 | var body: some View {
16 | #if os(macOS)
17 | splitView
18 | #elseif os(visionOS)
19 | tabView
20 | #else
21 | switch horizontalSizeClass {
22 | case .compact: tabView
23 | default: splitView
24 | }
25 | #endif
26 | }
27 |
28 | var tabView: some View {
29 | TabView {
30 | NavigationStack {
31 | LogListContainerView(vm: $vm)
32 | }
33 | .tabItem {
34 | Label("Expenses", systemImage: "tray")
35 | }.tag(0)
36 |
37 | NavigationStack {
38 | AIAssistantView()
39 | }
40 | .tabItem {
41 | Label("AI Assistant", systemImage: "waveform")
42 | }.tag(1)
43 |
44 | NavigationStack {
45 | ExpenseReceiptScannerView()
46 | }
47 | .tabItem {
48 | Label("Receipt Scanner", systemImage: "eye")
49 | }.tag(2)
50 | }
51 | }
52 |
53 | var splitView: some View {
54 | NavigationSplitView {
55 | List {
56 | NavigationLink(destination: LogListContainerView(vm: $vm)) {
57 | Label("Expenses", systemImage: "tray")
58 | }
59 |
60 | NavigationLink(destination: AIAssistantView()) {
61 | Label("AI Assistant", systemImage: "waveform")
62 | }
63 |
64 | NavigationLink(destination: ExpenseReceiptScannerView()) {
65 | Label("Receipt Scanner", systemImage: "eye")
66 | }
67 |
68 | }
69 | } detail: {
70 | LogListContainerView(vm: $vm)
71 | }
72 | .navigationTitle("XCA AI Expense Tracker")
73 | }
74 | }
75 |
76 | #Preview {
77 | ContentView()
78 | }
79 |
80 |
81 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Views/FilterCategoriesView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FilterCategoriesView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct FilterCategoriesView: View {
11 |
12 | @Binding var selectedCategories: Set
13 | private let categories = Category.allCases
14 |
15 | var body: some View {
16 | VStack {
17 | ScrollView(.horizontal) {
18 | HStack(spacing: 8) {
19 | ForEach(categories) { category in
20 | FilterButtonView(category: category, isSelected: self.selectedCategories.contains(category), onTap: self.onTap)
21 | }
22 | }
23 | .padding(.horizontal)
24 | }
25 |
26 | if selectedCategories.count > 0 {
27 | Button(role: .destructive) {
28 | self.selectedCategories.removeAll()
29 | } label: {
30 | Text("Clear all filter selection (\(self.selectedCategories.count))")
31 | }
32 | }
33 |
34 | }
35 | }
36 |
37 | func onTap(category: Category) {
38 | if selectedCategories.contains(category) {
39 | selectedCategories.remove(category)
40 | } else {
41 | selectedCategories.insert(category)
42 | }
43 | }
44 | }
45 |
46 | struct FilterButtonView: View {
47 |
48 | var category: Category
49 | var isSelected: Bool
50 | var onTap: (Category) -> ()
51 |
52 | var body: some View {
53 | HStack(spacing: 4) {
54 | Text(category.rawValue.capitalized)
55 | .fixedSize(horizontal: true, vertical: /*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/)
56 | }
57 | .padding(.horizontal, 16)
58 | .padding(.vertical, 4)
59 | .background {
60 | RoundedRectangle(cornerRadius: 16)
61 | .stroke(isSelected ? category.color : Color.gray, lineWidth: 1)
62 | .overlay {
63 | RoundedRectangle(cornerRadius: 16).foregroundColor(isSelected ? category.color : Color.clear)
64 | }
65 | }
66 | .frame(height: 44)
67 | .onTapGesture {
68 | self.onTap(category)
69 | }
70 | .foregroundColor(isSelected ? .white : nil)
71 | }
72 | }
73 |
74 | #Preview {
75 | @State var vm = LogListViewModel()
76 | return FilterCategoriesView(selectedCategories: $vm.selectedCategories)
77 | }
78 |
--------------------------------------------------------------------------------
/AIExpenseTracker/AIAssistant/ViewModels/AIAssistantVoiceChatViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AIAssistantVoiceChatViewModel.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/06/24.
6 | //
7 |
8 | import ChatGPTUI
9 | import Foundation
10 | import Observation
11 | import ChatGPTSwift
12 | import FirebaseFirestore
13 |
14 | @Observable
15 | class AIAssistantVoiceChatViewModel: VoiceChatViewModel {
16 |
17 | let functionsManager: FunctionsManager
18 | let db = DatabaseManager.shared
19 |
20 | init(apiKey: String, model: ChatGPTModel = .gpt_hyphen_4o) {
21 | self.functionsManager = .init(apiKey: apiKey)
22 | super.init(model: model, apiKey: apiKey)
23 | self.functionsManager.addLogConfirmationCallback = { [weak self] isConfirmed, props in
24 | guard let self else {
25 | return
26 | }
27 | let text: String
28 | if isConfirmed {
29 | try? self.db.add(log: props.log)
30 | text = "Sure, i've added this log to your expenses list"
31 | } else {
32 | text = "Ok, i won't be adding this log"
33 | }
34 |
35 | let response = AIAssistantResponse(text: text, type: .addExpenseLog(.init(log: props.log, messageID: nil, userConfirmation: isConfirmed ? .confirmed : .cancelled, confirmationCallback: props.confirmationCallback)))
36 |
37 | if let _ = self.state.idleResponse {
38 | self.state = .idle(.customContent({ AIAssistantResponseView(response: response)}))
39 | }
40 | }
41 | }
42 |
43 | override func processSpeechTask(audioData: Data) -> Task {
44 | Task { @MainActor [unowned self] in
45 | do {
46 | self.state = .processingSpeech
47 | let prompt = try await api.generateAudioTransciptions(audioData: audioData)
48 | try Task.checkCancellation()
49 |
50 | let response = try await functionsManager.prompt(prompt, model: model)
51 | try Task.checkCancellation()
52 |
53 | let data = try await api.generateSpeechFrom(input: response.text, voice:
54 | .init(rawValue: selectedVoice.rawValue) ?? .alloy)
55 | try Task.checkCancellation()
56 |
57 | try self.playAudio(data: data, response: .customContent({ AIAssistantResponseView(response: response)}))
58 | } catch {
59 | if Task.isCancelled { return }
60 | state = .error(error)
61 | resetValues()
62 | }
63 | }
64 | }
65 |
66 |
67 | }
68 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Views/LogListView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LogListView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import FirebaseFirestore
9 | import SwiftUI
10 |
11 | struct LogListView: View {
12 |
13 | @Binding var vm: LogListViewModel
14 | @FirestoreQuery(collectionPath: "logs",
15 | predicates: [.order(by: SortType.date.rawValue, descending: true)])
16 | private var logs: [ExpenseLog]
17 |
18 | var body: some View {
19 | listView
20 | .sheet(item: $vm.logToEdit, onDismiss: { vm.logToEdit = nil }) { log in
21 | LogFormView(vm: .init(logToEdit: log))
22 | }
23 | .overlay {
24 | if logs.isEmpty {
25 | Text("No expenses data\nPlease add your expenses using the add button")
26 | .multilineTextAlignment(.center)
27 | .font(.headline)
28 | .padding(.horizontal)
29 | }
30 | }
31 | .onChange(of: vm.sortType) { updateFirestoreQuery() }
32 | .onChange(of: vm.sortOrder) { updateFirestoreQuery() }
33 | .onChange(of: vm.selectedCategories) { updateFirestoreQuery() }
34 |
35 | }
36 |
37 | var listView: some View {
38 | #if os(iOS)
39 | List {
40 | ForEach(logs) { log in
41 | LogItemView(log: log)
42 | .contentShape(Rectangle())
43 | .onTapGesture {
44 | vm.logToEdit = log
45 | }
46 | .padding(.vertical, 4)
47 | }
48 | .onDelete(perform: self.onDelete)
49 | }
50 | .listStyle(.plain)
51 |
52 | #else
53 | ZStack {
54 | ScrollView {
55 | ForEach(logs) { log in
56 | VStack {
57 | LogItemView(log: log)
58 | Divider()
59 | }
60 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
61 | .contentShape(Rectangle())
62 | .padding(.horizontal)
63 | .onTapGesture {
64 | self.vm.logToEdit = log
65 | }
66 | .contextMenu {
67 | Button("Edit") { self.vm.logToEdit = log }
68 | Button("Delete") { vm.db.delete(log: log) }
69 | }
70 | }
71 | }.contentMargins(.vertical, 8, for: .scrollContent)
72 | }
73 | #endif
74 | }
75 |
76 | func updateFirestoreQuery() {
77 | $logs.predicates = vm.predicates
78 | }
79 |
80 | private func onDelete(with indexSet: IndexSet) {
81 | indexSet.forEach { index in
82 | let log = logs[index]
83 | vm.db.delete(log: log)
84 | }
85 | }
86 |
87 | }
88 |
89 | #Preview {
90 | @State var vm = LogListViewModel()
91 | return LogListView(vm: $vm)
92 | #if os(macOS)
93 | .frame(width: 700)
94 | #endif
95 | }
96 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Views/LogFormView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LogFormView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct LogFormView: View {
11 |
12 | @State var vm: FormViewModel
13 | @Environment(\.dismiss) var dismiss
14 |
15 | #if !os(macOS)
16 | var title: String {
17 | ((vm.logToEdit == nil) ? "Create" : "Edit") + " Expense Log"
18 | }
19 |
20 | var body: some View {
21 | NavigationStack {
22 | formView
23 | .toolbar {
24 | ToolbarItem(placement: .confirmationAction) {
25 | Button("Save") {
26 | self.onSaveTapped()
27 | }
28 | .disabled(vm.isSaveButtonDisabled)
29 | }
30 |
31 | ToolbarItem(placement: .cancellationAction) {
32 | Button("Cancel") {
33 | self.onCancelTapped()
34 | }
35 | }
36 | }
37 | .navigationBarTitle(title, displayMode: .inline)
38 | }
39 | }
40 |
41 | #else
42 | var body: some View {
43 | VStack {
44 | formView.padding()
45 | HStack {
46 | Button("Cancel") {
47 | self.onCancelTapped()
48 | }
49 |
50 | Button("Save") {
51 | self.onSaveTapped()
52 | }
53 | .buttonStyle(BorderedProminentButtonStyle())
54 | .disabled(vm.isSaveButtonDisabled)
55 | }
56 | .padding()
57 | }
58 | .frame(minWidth: 300)
59 | }
60 |
61 |
62 | #endif
63 |
64 | private var formView: some View {
65 | Form {
66 | TextField("Name", text: $vm.name)
67 | .disableAutocorrection(true)
68 | TextField("Amount", value: $vm.amount, formatter: vm.numberFormatter)
69 |
70 | #if !os(macOS)
71 | .keyboardType(.numbersAndPunctuation)
72 | #endif
73 |
74 | Picker(selection: $vm.category, label: Text("Category")) {
75 | ForEach(Category.allCases) { category in
76 | Text(category.rawValue.capitalized).tag(category)
77 | }
78 | }
79 |
80 | DatePicker(selection: $vm.date, displayedComponents: [.date, .hourAndMinute]) {
81 | Text("Date")
82 | }
83 | }
84 | }
85 |
86 | private func onCancelTapped() {
87 | self.dismiss()
88 | }
89 |
90 | private func onSaveTapped() {
91 | #if !os(macOS)
92 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
93 |
94 | #endif
95 | vm.save()
96 | self.dismiss()
97 | }
98 |
99 | }
100 |
101 | #Preview {
102 | LogFormView(vm: .init())
103 | }
104 |
--------------------------------------------------------------------------------
/AIExpenseTracker/AIAssistant/ViewModels/AIAssistantTextChatViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AIAssistantTextChatViewModel.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/06/24.
6 | //
7 |
8 | import ChatGPTSwift
9 | import ChatGPTUI
10 | import Observation
11 | import Foundation
12 |
13 | @Observable
14 | class AIAssistantTextChatViewModel: TextChatViewModel {
15 |
16 | let functionsManager: FunctionsManager
17 | let db = DatabaseManager.shared
18 |
19 | init(apiKey: String, model: ChatGPTModel = .gpt_hyphen_4o) {
20 | self.functionsManager = .init(apiKey: apiKey)
21 | super.init(senderImage: _senderImage, botImage: _botImage, model: model, apiKey: apiKey)
22 | self.functionsManager.addLogConfirmationCallback = { [weak self] isConfirmed, props in
23 | guard let self, let id = props.messageID, let index = self.messages.firstIndex(where: { $0.id == id }) else {
24 | return
25 | }
26 | var messageRow = self.messages[index]
27 | let text: String
28 | if isConfirmed {
29 | try? self.db.add(log: props.log)
30 | text = "Sure, i've added this log to your expenses list"
31 | } else {
32 | text = "Ok, i won't be adding this log"
33 | }
34 |
35 | let response = AIAssistantResponse(text: text, type: .addExpenseLog(.init(log: props.log, messageID: id, userConfirmation: isConfirmed ? .confirmed : .cancelled, confirmationCallback: props.confirmationCallback)))
36 |
37 | messageRow.response = .customContent({ AIAssistantResponseView(response: response) })
38 | self.messages[index] = messageRow
39 | }
40 | }
41 |
42 | @MainActor
43 | override func sendTapped() async {
44 | self.task = Task {
45 | let text = inputMessage
46 | inputMessage = ""
47 | await callFunction(text)
48 | }
49 | }
50 |
51 | @MainActor
52 | override func retry(message: MessageRow) async {
53 | self.task = Task {
54 | guard let index = messages.firstIndex(where: { $0.id == message.id }) else {
55 | return
56 | }
57 | self.messages.remove(at: index)
58 | await callFunction(message.sendText)
59 | }
60 | }
61 |
62 | @MainActor
63 | func callFunction(_ prompt: String) async {
64 | isPrompting = true
65 | var messageRow = MessageRow(
66 | isPrompting: true,
67 | sendImage: senderImage,
68 | send: .rawText(prompt),
69 | responseImage: botImage,
70 | response: .rawText(""),
71 | responseError: nil)
72 |
73 | self.messages.append(messageRow)
74 |
75 | do {
76 | let response = try await functionsManager.prompt(prompt, model: model, messageID: messageRow.id)
77 | messageRow.response = .customContent({ AIAssistantResponseView(response: response)})
78 | } catch {
79 | messageRow.responseError = error.localizedDescription
80 | }
81 |
82 | messageRow.isPrompting = false
83 | self.messages[self.messages.count - 1] = messageRow
84 | isPrompting = false
85 |
86 | }
87 |
88 | }
89 |
90 |
--------------------------------------------------------------------------------
/AIExpenseTracker/AIAssistant/Views/AIAssistantResponseView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AIAssistantResponseView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/06/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct AIAssistantResponseView: View {
11 |
12 | let response: AIAssistantResponse
13 |
14 | var body: some View {
15 | switch response.type {
16 | case .addExpenseLog(let props):
17 | AddExpenseLogView(props: props)
18 | case .listExpenses(let logs):
19 | ListExpensesLogsView(text: response.text, logs: logs)
20 | case .visualizeExpenses(let chartType, let options):
21 | VisualizeExpensesLogsView(text: response.text, options: options, chartType: chartType)
22 | default:
23 | Text(response.text).frame(maxWidth: .infinity, alignment: .leading)
24 | }
25 | }
26 | }
27 |
28 | struct AddExpenseLogView: View {
29 |
30 | let props: AddExpenseLogViewProperties
31 |
32 | var body: some View {
33 | VStack(alignment: .leading) {
34 | Text("Please select the confirm button before i add it to your expense list")
35 | Divider()
36 | LogItemView(log: props.log)
37 | Divider()
38 | switch props.userConfirmation {
39 | case .pending:
40 | if let confirmationCallback = props.confirmationCallback {
41 | HStack {
42 | Button("Confirm") {
43 | confirmationCallback(true, props)
44 | }
45 | .buttonStyle(BorderedProminentButtonStyle())
46 |
47 | Button("Cancel", role: .destructive) {
48 | confirmationCallback(false, props)
49 | }
50 | .buttonStyle(BorderedProminentButtonStyle())
51 | .tint(.red)
52 | }
53 | }
54 | case .confirmed:
55 | Button("Confirmed") {}
56 | .buttonStyle(BorderedProminentButtonStyle())
57 | .disabled(true)
58 |
59 | Text("Sure, i've added this log to your expense list")
60 | case .cancelled:
61 | Button("Cancel", role: .destructive) {}
62 | .buttonStyle(BorderedProminentButtonStyle())
63 | .tint(.red)
64 | .disabled(true)
65 |
66 | Text("Ok, i won't be adding this log")
67 | }
68 | }
69 | }
70 | }
71 |
72 | struct ListExpensesLogsView: View {
73 |
74 | let text: String
75 | let logs: [ExpenseLog]
76 |
77 | var body: some View {
78 | VStack(alignment: .leading) {
79 | Text(text)
80 | if logs.count > 0 {
81 | Divider()
82 | ForEach(logs) {
83 | LogItemView(log: $0)
84 | Divider()
85 | }
86 | }
87 | }
88 | }
89 | }
90 |
91 | struct VisualizeExpensesLogsView: View {
92 |
93 | let text: String
94 | let options: [Option]
95 | let chartType: ChartType
96 |
97 | var body: some View {
98 | VStack(alignment: .leading) {
99 | Text(text)
100 | if options.count > 0 {
101 | Divider()
102 | switch chartType {
103 | case .pie:
104 | PieChartView(options: options)
105 | .frame(maxWidth: .infinity, minHeight: 220)
106 | case .bar:
107 | BarChartView(options: options)
108 | .frame(maxWidth: .infinity, minHeight: 220)
109 | }
110 | }
111 | }
112 | }
113 | }
114 |
115 |
116 | #Preview {
117 | AIAssistantResponseView(response: .init(text: "Hello", type: .contentText))
118 | }
119 |
--------------------------------------------------------------------------------
/AIExpenseTracker/Models/Category.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Category.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/05/24.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | enum Category: String, Identifiable, CaseIterable {
12 |
13 | var id: Self { self }
14 |
15 | case accountingAndLegalFees = "Accounting and legal fees"
16 | case bankFees = "Bank fees"
17 | case consultantsAndProfessionalServices = "Consultants and professional services"
18 | case depreciation = "Depreciation"
19 | case employeeBenefits = "Employee benefits"
20 | case employeeExpenses = "Employee expenses"
21 | case entertainment = "Entertainment"
22 | case food = "Food"
23 | case gifts = "Gifts"
24 | case health = "Health"
25 | case insurance = "Insurance"
26 | case interest = "Interest"
27 | case learning = "Learning"
28 | case licensingFees = "Licensing fees"
29 | case marketing = "Marketing"
30 | case membershipFees = "Membership fees"
31 | case officeSupplies = "Office supplies"
32 | case payroll = "Payroll"
33 | case repairs = "Repairs"
34 | case rent = "Rent"
35 | case rentOrMortgagePayments = "Rent or mortgage payments"
36 | case software = "Software"
37 | case tax = "Tax"
38 | case travel = "Travel"
39 | case utilities = "Utilities"
40 |
41 | var systemNameIcon: String {
42 | switch self {
43 | case .insurance: return "shield"
44 | case .utilities: return "drop"
45 | case .marketing: return "megaphone"
46 | case .bankFees: return "creditcard"
47 | case .officeSupplies: return "folder"
48 | case .payroll: return "dollarsign.circle"
49 | case .employeeBenefits: return "person.2.square.stack"
50 | case .employeeExpenses: return "briefcase"
51 | case .food: return "bag.circle"
52 | case .licensingFees: return "cart"
53 | case .repairs: return "wrench"
54 | case .travel: return "airplane"
55 | case .accountingAndLegalFees: return "scalemass"
56 | case .gifts: return "gift"
57 | case .rent: return "house"
58 | case .learning: return "book"
59 | case .entertainment: return "film"
60 | case .interest: return "percent"
61 | case .health: return "heart"
62 | case .membershipFees: return "person.2"
63 | case .consultantsAndProfessionalServices: return "briefcase.fill"
64 | case .depreciation: return "arrow.down.doc"
65 | case .rentOrMortgagePayments: return "house.fill"
66 | case .software: return "app"
67 | case .tax: return "scalemass.fill"
68 | }
69 | }
70 |
71 | var color: Color {
72 | switch self {
73 | case .insurance: return Color(red: 0.086, green: 0.525, blue: 0.820)
74 | case .utilities: return Color(red: 0.369, green: 0.769, blue: 0.439)
75 | case .marketing: return Color(red: 0.843, green: 0.000, blue: 0.239)
76 | case .bankFees: return Color(red: 0.976, green: 0.463, blue: 0.031)
77 | case .officeSupplies: return Color(red: 1.000, green: 0.745, blue: 0.161)
78 | case .payroll: return Color(red: 0.561, green: 0.318, blue: 0.784)
79 | case .employeeBenefits: return Color(red: 1.000, green: 0.565, blue: 0.667)
80 | case .employeeExpenses: return Color.cyan
81 | case .food: return Color(red: 0.553, green: 0.251, blue: 0.663)
82 | case .licensingFees: return Color(red: 0.420, green: 0.749, blue: 0.604)
83 | case .repairs: return Color(red: 0.545, green: 0.000, blue: 0.000)
84 | case .travel: return Color(red: 0.078, green: 0.482, blue: 0.894)
85 | case .accountingAndLegalFees: return Color.pink
86 | case .gifts: return Color(red: 1.000, green: 0.498, blue: 0.000)
87 | case .rent: return Color(red: 0.196, green: 0.714, blue: 0.875)
88 | case .learning: return Color(red: 0.239, green: 0.467, blue: 0.855)
89 | case .entertainment: return Color(red: 0.667, green: 0.180, blue: 0.686)
90 | case .interest: return Color(red: 0.949, green: 0.361, blue: 0.000)
91 | case .health: return Color(red: 0.835, green: 0.000, blue: 0.000)
92 | case .membershipFees: return Color(red: 0.259, green: 0.675, blue: 0.820)
93 | case .consultantsAndProfessionalServices: return Color(red: 0.263, green: 0.569, blue: 0.275)
94 | case .depreciation: return Color.mint
95 | case .rentOrMortgagePayments: return Color(red: 0.114, green: 0.647, blue: 0.871)
96 | case .software: return Color(red: 0.184, green: 0.463, blue: 0.239)
97 | case .tax: return Color.red
98 | }
99 | }
100 |
101 | }
102 |
--------------------------------------------------------------------------------
/AIExpenseTracker/ReceiptScanner/AddReceiptToExpenseConfirmationView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddReceiptToExpenseConfirmationView.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 07/07/24.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct AddReceiptToExpenseConfirmationView: View {
11 |
12 | @Environment(\.horizontalSizeClass) var horizontalSizeClass
13 | @Environment(\.presentationMode) var presentationMode
14 | @State var vm: AddReceiptToExpenseConfirmationViewModel
15 |
16 | var body: some View {
17 | NavigationStack {
18 | VStack(alignment: .leading) {
19 | List {
20 | HStack {
21 | DatePicker(selection: $vm.date, displayedComponents: [.date]) {
22 | Text("Date:")
23 | }
24 |
25 | Spacer()
26 |
27 | HStack {
28 | Picker(selection: $vm.currencyCode, label: Text("Currency:")) {
29 | ForEach(Locale.commonISOCurrencyCodes, id: \.self) { iso in
30 | Text(iso).tag(iso)
31 | }
32 | }
33 | }
34 | }
35 |
36 | switch horizontalSizeClass {
37 | case .regular: regularView
38 | default: compactView
39 | }
40 | }.listStyle(.plain)
41 | }
42 | .navigationTitle("Confirmation")
43 | .toolbar {
44 | ToolbarItem(placement: .confirmationAction) {
45 | Button("Confirm") {
46 | vm.save()
47 | presentationMode.wrappedValue.dismiss()
48 | }
49 | }
50 |
51 | ToolbarItem(placement: .cancellationAction) {
52 | Button("Cancel", role: .cancel) {
53 | presentationMode.wrappedValue.dismiss()
54 | }
55 | }
56 |
57 | ToolbarItem(placement: .destructiveAction) {
58 | Button("Reset Changes", role: .destructive) {
59 | self.vm.resetChanges()
60 | }
61 | .tint(.red)
62 | .disabled(!vm.isEdited)
63 | }
64 | }
65 | }
66 | }
67 |
68 | var regularView: some View {
69 | ForEach($vm.expenseLogs) { log in
70 | HStack(spacing: 16) {
71 | HStack {
72 | Text("Name:")
73 | nameTextField(log: log)
74 | }
75 |
76 | HStack {
77 | Text("Amount:")
78 | amountTextField(log: log)
79 | }
80 |
81 | HStack {
82 | categoryPicker(log: log)
83 | CategoryImageView(category: log.wrappedValue.categoryEnum)
84 | }
85 | }
86 | }
87 | .onDelete(perform: onDelete)
88 | }
89 |
90 | var compactView: some View {
91 | ForEach($vm.expenseLogs) { log in
92 | VStack(alignment: .leading, spacing: 16) {
93 | HStack {
94 | Text("Name:")
95 | .frame(maxWidth: 72, alignment: .leading)
96 | Spacer()
97 | nameTextField(log: log)
98 | }
99 |
100 | HStack {
101 | Text("Amount:")
102 | .frame(maxWidth: 72, alignment: .leading)
103 | Spacer()
104 | amountTextField(log: log)
105 | }
106 |
107 | HStack {
108 | Text("Category")
109 | Spacer()
110 | categoryPicker(log: log)
111 | CategoryImageView(category: log.wrappedValue.categoryEnum)
112 | }
113 | }
114 | }
115 | .onDelete(perform: onDelete)
116 |
117 | }
118 |
119 |
120 | func nameTextField(log: Binding) -> some View {
121 | TextField(text: log.name, label: {Text("Name")})
122 | .lineLimit(2)
123 | .textFieldStyle(RoundedBorderTextFieldStyle())
124 | }
125 |
126 | func amountTextField(log: Binding) -> some View {
127 | TextField("Amount", value: log.amount, formatter: vm.numberFormatter)
128 | .textFieldStyle(RoundedBorderTextFieldStyle())
129 | #if !os(macOS)
130 | .keyboardType(.numbersAndPunctuation)
131 | #endif
132 | }
133 |
134 | func categoryPicker(log: Binding) -> some View {
135 | Picker(selection: log.category, label: Text("Category:")) {
136 | ForEach(Category.allCases) { category in
137 | Text(category.rawValue.capitalized).tag(category.rawValue)
138 | }
139 | }
140 | }
141 |
142 | func onDelete(indexSet: IndexSet) {
143 | vm.expenseLogs.remove(atOffsets: indexSet)
144 | }
145 | }
146 |
147 | //#Preview {
148 | // AddReceiptToExpenseConfirmationView()
149 | //}
150 |
151 |
--------------------------------------------------------------------------------
/AIExpenseTracker/AIAssistant/Models/FunctionTools.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FunctionTools.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/06/24.
6 | //
7 |
8 | import ChatGPTSwift
9 | import Foundation
10 |
11 | enum AIAssistantFunctionType: String {
12 | case addExpenseLog
13 | case listExpenses
14 | case visualizeExpenses
15 | }
16 |
17 | typealias PropKeyValue = (key: String, value: [String: Any])
18 |
19 | let titleProp = (key: "title",
20 | value: [
21 | "type": "string",
22 | "description": "title or description of the expense"
23 | ])
24 |
25 | let amountProp = (key: "amount",
26 | value: [
27 | "type": "number",
28 | "description": "cost or amount of the expense"
29 | ])
30 |
31 | let currencyProp = (key: "currency",
32 | value: [
33 | "type": "string",
34 | "description": "Currency of the amount or cost. If you're not sure, just use USD as default value, no need to confirm with user"
35 | ])
36 |
37 | let dateProp = (key: "date",
38 | value: [
39 | "type": "string",
40 | "description": "date of expense. always use this format as the response yyyy-MM-dd. if no year is provided just use current year"
41 | ])
42 |
43 | let categoryProp = (key: "category",
44 | value: [
45 | "type": "string",
46 | "enum": Category.allCases.map { $0.rawValue },
47 | "description": "The category of the expense, if it's not provided explicitly by the user, you should infer it automatically based on the title of expense."
48 | ])
49 |
50 | let startDateProp = (key: "startDate",
51 | value: [
52 | "type": "string",
53 | "description": "start date. always use this format as the response yyyy-MM-dd. If no year is provided, just use current year"
54 | ])
55 |
56 |
57 | let endDateProp = (key: "endDate",
58 | value: [
59 | "type": "string",
60 | "description": "end date. always use this format as the response yyyy-MM-dd. if no year is provided just use current year"
61 | ])
62 |
63 | let sortOrderProp = (key: "sortOrder",
64 | value: [
65 | "type": "string",
66 | "enum": ["ascending", "descending"],
67 | "description": "the sort order of the list. if not provided, use descending as default value"
68 | ])
69 |
70 | let quantityOfLogsProp = (key: "quantityOfLogs",
71 | value: [
72 | "type": "number",
73 | "description": "Number of logs to be listed"
74 | ])
75 |
76 |
77 | let chartTypeProp = (key: "chartType",
78 | value: [
79 | "type": "string",
80 | "enum": ["pie", "bar"],
81 | "description": "the type of chart to be shown. if not provided, use pie as default value."
82 | ])
83 |
84 |
85 | func createParameters(properties: [PropKeyValue], requiredProperties: [PropKeyValue]? = nil) -> Components.Schemas.FunctionParameters {
86 | var propsDict = [String: [String: Any]]()
87 | properties.forEach {
88 | propsDict[$0.key] = $0.value
89 | }
90 | return try! .init(additionalProperties: .init(unvalidatedValue: [
91 | "type": "object",
92 | "properties": propsDict,
93 | "required": requiredProperties?.compactMap { $0.key } ?? []
94 | ]))
95 | }
96 |
97 | func createFunction(name: String, description: String, properties: [PropKeyValue], requiredProperties: [PropKeyValue]? = nil) -> ChatCompletionTool {
98 | .init(_type: .function, function: .init(
99 | description: description,
100 | name: name,
101 | parameters: createParameters(properties: properties, requiredProperties: requiredProperties)))
102 | }
103 |
104 | let tools: [Components.Schemas.ChatCompletionTool] = [
105 | createFunction(name: AIAssistantFunctionType.addExpenseLog.rawValue,
106 | description: "Add expense log",
107 | properties: [titleProp,
108 | amountProp,
109 | currencyProp,
110 | categoryProp,
111 | dateProp],
112 | requiredProperties: [titleProp, amountProp, categoryProp]),
113 | createFunction(name: AIAssistantFunctionType.listExpenses.rawValue,
114 | description: "list expenses logs",
115 | properties: [categoryProp,
116 | dateProp,
117 | startDateProp,
118 | endDateProp,
119 | sortOrderProp,
120 | quantityOfLogsProp
121 | ]),
122 | createFunction(name: AIAssistantFunctionType.visualizeExpenses.rawValue,
123 | description: "visualize expenses logs in pie or bar chart",
124 | properties: [chartTypeProp,
125 | dateProp,
126 | startDateProp,
127 | endDateProp
128 | ],
129 | requiredProperties: [chartTypeProp])
130 | ]
131 |
--------------------------------------------------------------------------------
/AIExpenseTracker/AIAssistant/FunctionsManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FunctionsManager.swift
3 | // AIExpenseTracker
4 | //
5 | // Created by Alfian Losari on 09/06/24.
6 | //
7 |
8 | import Foundation
9 | import FirebaseFirestore
10 | import ChatGPTSwift
11 |
12 | class FunctionsManager {
13 |
14 | let api: ChatGPTAPI
15 | let db = DatabaseManager.shared
16 | var addLogConfirmationCallback: AddExpenseLogConfirmationCallback?
17 |
18 | static let currentDateFormatter: DateFormatter = {
19 | let df = DateFormatter()
20 | df.dateFormat = "yyyy-MM-dd"
21 | return df
22 | }()
23 |
24 | let jsonDecoder: JSONDecoder = {
25 | let jsonDecoder = JSONDecoder()
26 | jsonDecoder.dateDecodingStrategy = .custom({ decoder in
27 | let container = try decoder.singleValueContainer()
28 | let dateString = try container.decode(String.self)
29 | guard let date = FunctionsManager.currentDateFormatter.date(from: dateString) else {
30 | throw DecodingError.dataCorruptedError(in: container, debugDescription: "cannot decode date")
31 | }
32 | return date
33 | })
34 | return jsonDecoder
35 | }()
36 |
37 | var systemText: String {
38 | "You are expert of tracking and managing expenses logs. Don't make assumptions about what values to plug into functions. Ask for clarification if a user request is ambiguous. Current date is \(Self.currentDateFormatter.string(from: .now))"
39 | }
40 |
41 | init(apiKey: String) {
42 | self.api = .init(apiKey: apiKey)
43 | }
44 |
45 | func prompt(_ prompt: String, model: ChatGPTModel = .gpt_hyphen_4o, messageID: UUID? = nil) async throws -> AIAssistantResponse {
46 | do {
47 | let message = try await api.callFunction(prompt: prompt, tools: tools, model: model, systemText: systemText)
48 | try Task.checkCancellation()
49 |
50 | if let toolCall = message.tool_calls?.first,
51 | let functionType = AIAssistantFunctionType(rawValue: toolCall.function.name),
52 | let argumentData = toolCall.function.arguments.data(using: .utf8) {
53 |
54 | switch functionType {
55 | case .addExpenseLog:
56 | guard let addLogConfirmationCallback else {
57 | throw "Add log confirmation callback is missing"
58 | }
59 | guard let addExpenseLogArgs = try? self.jsonDecoder.decode(AddExpenseLogArgs.self, from: argumentData) else {
60 | throw "Failed to parse function arguments \(toolCall.function.name) \(toolCall.function.arguments)"
61 | }
62 | let log = ExpenseLog(id: UUID().uuidString, name: addExpenseLogArgs.title, category: addExpenseLogArgs.category, amount: addExpenseLogArgs.amount, currency: addExpenseLogArgs.currency ?? "USD", date: addExpenseLogArgs.date ?? .now)
63 |
64 | return .init(text: "Please select the confirm button before i add it to your expense list", type: .addExpenseLog(.init(log: log, messageID: messageID, userConfirmation: .pending, confirmationCallback: addLogConfirmationCallback)))
65 |
66 | case .listExpenses:
67 | guard let listExpenseArgs = try? self.jsonDecoder.decode(ListExpenseArgs.self, from: argumentData) else {
68 | throw "Failed to parse function arguments \(toolCall.function.name) \(toolCall.function.arguments)"
69 | }
70 |
71 | let query = getQuery(args: listExpenseArgs)
72 | let docs = try await query.getDocuments()
73 | let logs = try docs.documents.map { try $0.data(as: ExpenseLog.self)}
74 |
75 | let text: String
76 | if listExpenseArgs.isDateFilterExists {
77 | if logs.isEmpty {
78 | text = "You don't have any expenses at given date"
79 | } else {
80 | text = "Sure, here's the list of your expenses with total sum of \(Utils.numberFormatter.string(from: NSNumber(value: logs.reduce(0, { $0 + $1.amount }))) ?? "")"
81 | }
82 | } else {
83 | if logs.isEmpty {
84 | text = "You don't have any recent expenses"
85 | } else {
86 | text = "Sure, here's the list of your last \(logs.count) expenses with total sum of \(Utils.numberFormatter.string(from: NSNumber(value: logs.reduce(0, { $0 + $1.amount }))) ?? "")"
87 | }
88 | }
89 |
90 | return .init(text: text, type: .listExpenses(logs))
91 |
92 | case .visualizeExpenses:
93 | guard let visualizeExpenseArgs = try? self.jsonDecoder.decode(VisualizeExpenseArgs.self, from: argumentData) else {
94 | throw "Failed to parse function arguments \(toolCall.function.name) \(toolCall.function.arguments)"
95 | }
96 |
97 | let query = getQuery(args: .init(date: visualizeExpenseArgs.date, startDate: visualizeExpenseArgs.startDate, endDate: visualizeExpenseArgs.endDate, category: nil, sortOrder: nil, quantityOfLogs: nil))
98 |
99 | let docs = try await query.getDocuments()
100 | let logs = try docs.documents.map { try $0.data(as: ExpenseLog.self)}
101 |
102 | var categorySumDict = [Category: Double]()
103 | logs.forEach { log in
104 | categorySumDict.updateValue((categorySumDict[log.categoryEnum] ?? 0) + log.amount, forKey: log.categoryEnum)
105 | }
106 |
107 | let chartOptions = categorySumDict.map { Option(category: $0.key, amount: $0.value) }
108 | return .init(text: "Sure, here is the visualization of your expenses for each category", type: .visualizeExpenses(visualizeExpenseArgs.chartTypeEnum, chartOptions))
109 |
110 | default:
111 | var text = "Function Name: \(toolCall.function.name)"
112 | text += "\nArgs: \(toolCall.function.arguments)"
113 | return .init(text: text, type: .contentText)
114 | }
115 | } else if let message = message.content {
116 | api.appendToHistoryList(userText: prompt, responseText: message)
117 | return .init(text: message, type: .contentText)
118 | } else {
119 | throw "Invalid response"
120 | }
121 |
122 | } catch {
123 | print(error.localizedDescription)
124 | throw error
125 | }
126 | }
127 |
128 | func getQuery(args: ListExpenseArgs) -> Query {
129 | var filters = [Filter]()
130 | if let startDate = args.startDate,
131 | let endDate = args.endDate {
132 | filters.append(.whereField("date", isGreaterOrEqualTo: startDate.startOfDay))
133 | filters.append(.whereField("date", isLessThanOrEqualTo: endDate.endOfDay))
134 | } else if let date = args.date {
135 | filters.append(.whereField("date", isGreaterOrEqualTo: date.startOfDay))
136 | filters.append(.whereField("date", isLessThanOrEqualTo: date.endOfDay))
137 | }
138 |
139 | if let category = args.category {
140 | filters.append(.whereField("category", isEqualTo: category))
141 | }
142 |
143 | var query = db.logsCollection.whereFilter(.andFilter(filters))
144 | let sortOrder = SortOrder(rawValue: args.sortOrder ?? "") ?? .descending
145 | query = query.order(by: "date", descending: sortOrder == .descending)
146 |
147 | if args.isDateFilterExists {
148 | if let quantityOfLogs = args.quantityOfLogs {
149 | query = query.limit(to: quantityOfLogs)
150 | }
151 | } else {
152 | let quantityOfLogs = args.quantityOfLogs ?? 100
153 | query = query.limit(to: quantityOfLogs)
154 | }
155 | return query
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/AIExpenseTracker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "94ce07d2f508c389cbd746c626cb2df4569d2b09828436efb12c5c4fb3cd14dd",
3 | "pins" : [
4 | {
5 | "identity" : "abseil-cpp-binary",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/google/abseil-cpp-binary.git",
8 | "state" : {
9 | "revision" : "748c7837511d0e6a507737353af268484e1745e2",
10 | "version" : "1.2024011601.1"
11 | }
12 | },
13 | {
14 | "identity" : "aireceiptscanner",
15 | "kind" : "remoteSourceControl",
16 | "location" : "https://github.com/alfianlosari/AIReceiptScanner",
17 | "state" : {
18 | "revision" : "28a096926d2132b0171ea6a4d0c965c7e30d1b92",
19 | "version" : "1.0.4"
20 | }
21 | },
22 | {
23 | "identity" : "app-check",
24 | "kind" : "remoteSourceControl",
25 | "location" : "https://github.com/google/app-check.git",
26 | "state" : {
27 | "revision" : "7d2688de038d5484866d835acb47b379722d610e",
28 | "version" : "10.19.0"
29 | }
30 | },
31 | {
32 | "identity" : "async-http-client",
33 | "kind" : "remoteSourceControl",
34 | "location" : "https://github.com/swift-server/async-http-client.git",
35 | "state" : {
36 | "revision" : "a22083713ee90808d527d0baa290c2fb13ca3096",
37 | "version" : "1.21.1"
38 | }
39 | },
40 | {
41 | "identity" : "chatgptswift",
42 | "kind" : "remoteSourceControl",
43 | "location" : "https://github.com/alfianlosari/ChatGPTSwift.git",
44 | "state" : {
45 | "revision" : "5d10da6f680a217ab458bea2402c41982599d525",
46 | "version" : "2.3.2"
47 | }
48 | },
49 | {
50 | "identity" : "chatgptui",
51 | "kind" : "remoteSourceControl",
52 | "location" : "https://github.com/alfianlosari/ChatGPTUI.git",
53 | "state" : {
54 | "revision" : "3c3e11c349092f0cd8e909a1cf3d0609c035628c",
55 | "version" : "0.3.1"
56 | }
57 | },
58 | {
59 | "identity" : "firebase-ios-sdk",
60 | "kind" : "remoteSourceControl",
61 | "location" : "https://github.com/firebase/firebase-ios-sdk",
62 | "state" : {
63 | "revision" : "97940381e57703c07f31a8058d8f39ec53b7c272",
64 | "version" : "10.25.0"
65 | }
66 | },
67 | {
68 | "identity" : "googleappmeasurement",
69 | "kind" : "remoteSourceControl",
70 | "location" : "https://github.com/google/GoogleAppMeasurement.git",
71 | "state" : {
72 | "revision" : "16244d177c4e989f87b25e9db1012b382cfedc55",
73 | "version" : "10.25.0"
74 | }
75 | },
76 | {
77 | "identity" : "googledatatransport",
78 | "kind" : "remoteSourceControl",
79 | "location" : "https://github.com/google/GoogleDataTransport.git",
80 | "state" : {
81 | "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565",
82 | "version" : "9.4.0"
83 | }
84 | },
85 | {
86 | "identity" : "googleutilities",
87 | "kind" : "remoteSourceControl",
88 | "location" : "https://github.com/google/GoogleUtilities.git",
89 | "state" : {
90 | "revision" : "8e5d57ed87057cd7b0e4e8f474d9e78f73eb85f7",
91 | "version" : "7.13.2"
92 | }
93 | },
94 | {
95 | "identity" : "gptencoder",
96 | "kind" : "remoteSourceControl",
97 | "location" : "https://github.com/alfianlosari/GPTEncoder.git",
98 | "state" : {
99 | "revision" : "a86968867ab4380e36b904a14c42215f71efe8b4",
100 | "version" : "1.0.4"
101 | }
102 | },
103 | {
104 | "identity" : "grpc-binary",
105 | "kind" : "remoteSourceControl",
106 | "location" : "https://github.com/google/grpc-binary.git",
107 | "state" : {
108 | "revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359",
109 | "version" : "1.62.2"
110 | }
111 | },
112 | {
113 | "identity" : "gtm-session-fetcher",
114 | "kind" : "remoteSourceControl",
115 | "location" : "https://github.com/google/gtm-session-fetcher.git",
116 | "state" : {
117 | "revision" : "0382ca27f22fb3494cf657d8dc356dc282cd1193",
118 | "version" : "3.4.1"
119 | }
120 | },
121 | {
122 | "identity" : "highlighterswift",
123 | "kind" : "remoteSourceControl",
124 | "location" : "https://github.com/alfianlosari/HighlighterSwift.git",
125 | "state" : {
126 | "revision" : "6d697f875a064dda825d943fe7f6b53edea08fe8",
127 | "version" : "1.0.0"
128 | }
129 | },
130 | {
131 | "identity" : "interop-ios-for-google-sdks",
132 | "kind" : "remoteSourceControl",
133 | "location" : "https://github.com/google/interop-ios-for-google-sdks.git",
134 | "state" : {
135 | "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648",
136 | "version" : "100.0.0"
137 | }
138 | },
139 | {
140 | "identity" : "leveldb",
141 | "kind" : "remoteSourceControl",
142 | "location" : "https://github.com/firebase/leveldb.git",
143 | "state" : {
144 | "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1",
145 | "version" : "1.22.5"
146 | }
147 | },
148 | {
149 | "identity" : "nanopb",
150 | "kind" : "remoteSourceControl",
151 | "location" : "https://github.com/firebase/nanopb.git",
152 | "state" : {
153 | "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1",
154 | "version" : "2.30910.0"
155 | }
156 | },
157 | {
158 | "identity" : "promises",
159 | "kind" : "remoteSourceControl",
160 | "location" : "https://github.com/google/promises.git",
161 | "state" : {
162 | "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac",
163 | "version" : "2.4.0"
164 | }
165 | },
166 | {
167 | "identity" : "siriwaveview",
168 | "kind" : "remoteSourceControl",
169 | "location" : "https://github.com/alfianlosari/SiriWaveView.git",
170 | "state" : {
171 | "revision" : "711287cd8d6ef16b5dbcced5ead82d93d0cb3c88",
172 | "version" : "1.1.0"
173 | }
174 | },
175 | {
176 | "identity" : "swift-algorithms",
177 | "kind" : "remoteSourceControl",
178 | "location" : "https://github.com/apple/swift-algorithms",
179 | "state" : {
180 | "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42",
181 | "version" : "1.2.0"
182 | }
183 | },
184 | {
185 | "identity" : "swift-atomics",
186 | "kind" : "remoteSourceControl",
187 | "location" : "https://github.com/apple/swift-atomics.git",
188 | "state" : {
189 | "revision" : "cd142fd2f64be2100422d658e7411e39489da985",
190 | "version" : "1.2.0"
191 | }
192 | },
193 | {
194 | "identity" : "swift-cmark",
195 | "kind" : "remoteSourceControl",
196 | "location" : "https://github.com/apple/swift-cmark.git",
197 | "state" : {
198 | "revision" : "3bc2f3e25df0cecc5dc269f7ccae65d0f386f06a",
199 | "version" : "0.4.0"
200 | }
201 | },
202 | {
203 | "identity" : "swift-collections",
204 | "kind" : "remoteSourceControl",
205 | "location" : "https://github.com/apple/swift-collections",
206 | "state" : {
207 | "revision" : "94cf62b3ba8d4bed62680a282d4c25f9c63c2efb",
208 | "version" : "1.1.0"
209 | }
210 | },
211 | {
212 | "identity" : "swift-http-types",
213 | "kind" : "remoteSourceControl",
214 | "location" : "https://github.com/apple/swift-http-types",
215 | "state" : {
216 | "revision" : "1ddbea1ee34354a6a2532c60f98501c35ae8edfa",
217 | "version" : "1.2.0"
218 | }
219 | },
220 | {
221 | "identity" : "swift-log",
222 | "kind" : "remoteSourceControl",
223 | "location" : "https://github.com/apple/swift-log.git",
224 | "state" : {
225 | "revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
226 | "version" : "1.5.4"
227 | }
228 | },
229 | {
230 | "identity" : "swift-markdown",
231 | "kind" : "remoteSourceControl",
232 | "location" : "https://github.com/apple/swift-markdown.git",
233 | "state" : {
234 | "revision" : "4aae40bf6fff5286e0e1672329d17824ce16e081",
235 | "version" : "0.4.0"
236 | }
237 | },
238 | {
239 | "identity" : "swift-nio",
240 | "kind" : "remoteSourceControl",
241 | "location" : "https://github.com/apple/swift-nio",
242 | "state" : {
243 | "revision" : "359c461e5561d22c6334828806cc25d759ca7aa6",
244 | "version" : "2.65.0"
245 | }
246 | },
247 | {
248 | "identity" : "swift-nio-extras",
249 | "kind" : "remoteSourceControl",
250 | "location" : "https://github.com/apple/swift-nio-extras.git",
251 | "state" : {
252 | "revision" : "a3b640d7dc567225db7c94386a6e71aded1bfa63",
253 | "version" : "1.22.0"
254 | }
255 | },
256 | {
257 | "identity" : "swift-nio-http2",
258 | "kind" : "remoteSourceControl",
259 | "location" : "https://github.com/apple/swift-nio-http2.git",
260 | "state" : {
261 | "revision" : "c6afe04165c865faaa687b42c32ed76dfcc91076",
262 | "version" : "1.31.0"
263 | }
264 | },
265 | {
266 | "identity" : "swift-nio-ssl",
267 | "kind" : "remoteSourceControl",
268 | "location" : "https://github.com/apple/swift-nio-ssl.git",
269 | "state" : {
270 | "revision" : "7c381eb6083542b124a6c18fae742f55001dc2b5",
271 | "version" : "2.26.0"
272 | }
273 | },
274 | {
275 | "identity" : "swift-nio-transport-services",
276 | "kind" : "remoteSourceControl",
277 | "location" : "https://github.com/apple/swift-nio-transport-services.git",
278 | "state" : {
279 | "revision" : "38ac8221dd20674682148d6451367f89c2652980",
280 | "version" : "1.21.0"
281 | }
282 | },
283 | {
284 | "identity" : "swift-numerics",
285 | "kind" : "remoteSourceControl",
286 | "location" : "https://github.com/apple/swift-numerics.git",
287 | "state" : {
288 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
289 | "version" : "1.0.2"
290 | }
291 | },
292 | {
293 | "identity" : "swift-openapi-async-http-client",
294 | "kind" : "remoteSourceControl",
295 | "location" : "https://github.com/swift-server/swift-openapi-async-http-client",
296 | "state" : {
297 | "revision" : "abfe558a66992ef1e896a577010f957915f30591",
298 | "version" : "1.0.0"
299 | }
300 | },
301 | {
302 | "identity" : "swift-openapi-runtime",
303 | "kind" : "remoteSourceControl",
304 | "location" : "https://github.com/apple/swift-openapi-runtime",
305 | "state" : {
306 | "revision" : "9a8291fa2f90cc7296f2393a99bb4824ee34f869",
307 | "version" : "1.4.0"
308 | }
309 | },
310 | {
311 | "identity" : "swift-openapi-urlsession",
312 | "kind" : "remoteSourceControl",
313 | "location" : "https://github.com/apple/swift-openapi-urlsession",
314 | "state" : {
315 | "revision" : "6efbfda5276bbbc8b4fec5d744f0ecd8c784eb47",
316 | "version" : "1.0.1"
317 | }
318 | },
319 | {
320 | "identity" : "swift-protobuf",
321 | "kind" : "remoteSourceControl",
322 | "location" : "https://github.com/apple/swift-protobuf.git",
323 | "state" : {
324 | "revision" : "9f0c76544701845ad98716f3f6a774a892152bcb",
325 | "version" : "1.26.0"
326 | }
327 | },
328 | {
329 | "identity" : "swift-system",
330 | "kind" : "remoteSourceControl",
331 | "location" : "https://github.com/apple/swift-system.git",
332 | "state" : {
333 | "revision" : "f9266c85189c2751589a50ea5aec72799797e471",
334 | "version" : "1.3.0"
335 | }
336 | }
337 | ],
338 | "version" : 3
339 | }
340 |
--------------------------------------------------------------------------------
/AIExpenseTracker.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 56;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 8B6A0DB52BECB7E20022F8E2 /* AIExpenseTrackerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DB42BECB7E20022F8E2 /* AIExpenseTrackerApp.swift */; };
11 | 8B6A0DB72BECB7E20022F8E2 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DB62BECB7E20022F8E2 /* ContentView.swift */; };
12 | 8B6A0DBD2BECB7E30022F8E2 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8B6A0DBC2BECB7E30022F8E2 /* Preview Assets.xcassets */; };
13 | 8B6A0DC82BECB8E80022F8E2 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 8B6A0DC72BECB8E80022F8E2 /* FirebaseFirestore */; };
14 | 8B6A0DCA2BECB8E80022F8E2 /* FirebaseFirestoreSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 8B6A0DC92BECB8E80022F8E2 /* FirebaseFirestoreSwift */; };
15 | 8B6A0DCD2BECC6170022F8E2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DCC2BECC6170022F8E2 /* AppDelegate.swift */; };
16 | 8B6A0DD02BECC9A50022F8E2 /* Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DCF2BECC9A50022F8E2 /* Category.swift */; };
17 | 8B6A0DD22BECCAA80022F8E2 /* ExpenseLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DD12BECCAA80022F8E2 /* ExpenseLog.swift */; };
18 | 8B6A0DD42BECCBAD0022F8E2 /* Sort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DD32BECCBAD0022F8E2 /* Sort.swift */; };
19 | 8B6A0DD62BECCC340022F8E2 /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DD52BECCC340022F8E2 /* Utils.swift */; };
20 | 8B6A0DD82BECCD2E0022F8E2 /* DatabaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DD72BECCD2E0022F8E2 /* DatabaseManager.swift */; };
21 | 8B6A0DDB2BECD35C0022F8E2 /* LogListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DDA2BECD35C0022F8E2 /* LogListViewModel.swift */; };
22 | 8B6A0DDE2BECD3DE0022F8E2 /* FilterCategoriesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DDD2BECD3DE0022F8E2 /* FilterCategoriesView.swift */; };
23 | 8B6A0DE02BECE2680022F8E2 /* SelectSortOrderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DDF2BECE2680022F8E2 /* SelectSortOrderView.swift */; };
24 | 8B6A0DE22BECE4D40022F8E2 /* CategoryImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DE12BECE4D40022F8E2 /* CategoryImageView.swift */; };
25 | 8B6A0DE42BECEE890022F8E2 /* LogItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DE32BECEE890022F8E2 /* LogItemView.swift */; };
26 | 8B6A0DE62BECF07C0022F8E2 /* LogListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DE52BECF07C0022F8E2 /* LogListView.swift */; };
27 | 8B6A0DE82BECF4800022F8E2 /* LogListContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DE72BECF4800022F8E2 /* LogListContainerView.swift */; };
28 | 8B6A0DEA2BECF90B0022F8E2 /* LogFormViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DE92BECF90B0022F8E2 /* LogFormViewModel.swift */; };
29 | 8B6A0DEC2BECFA620022F8E2 /* LogFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6A0DEB2BECFA620022F8E2 /* LogFormView.swift */; };
30 | 8B8D46132BEC45C5004FF132 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8B8D46122BEC45C5004FF132 /* Assets.xcassets */; };
31 | 8BA3ECBB2C15D70B004C3181 /* ChatGPTUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8BA3ECBA2C15D70B004C3181 /* ChatGPTUI */; };
32 | 8BA3ECBF2C15D79C004C3181 /* AIAssistantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA3ECBE2C15D79C004C3181 /* AIAssistantView.swift */; };
33 | 8BA3ECC22C15DCEC004C3181 /* FunctionTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA3ECC12C15DCEC004C3181 /* FunctionTools.swift */; };
34 | 8BA3ECC62C15E042004C3181 /* AIAssistantResponseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA3ECC52C15E042004C3181 /* AIAssistantResponseView.swift */; };
35 | 8BA3ECC82C15E06B004C3181 /* AIAssistantTextChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA3ECC72C15E06B004C3181 /* AIAssistantTextChatViewModel.swift */; };
36 | 8BA3ECCA2C15EB62004C3181 /* FunctionArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA3ECC92C15EB62004C3181 /* FunctionArguments.swift */; };
37 | 8BA3ECCC2C15EB9E004C3181 /* FunctionResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA3ECCB2C15EB9E004C3181 /* FunctionResponse.swift */; };
38 | 8BA3ECCE2C15EC9E004C3181 /* FunctionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA3ECCD2C15EC9E004C3181 /* FunctionsManager.swift */; };
39 | 8BA3ECD02C15FF0E004C3181 /* AIAssistantVoiceChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA3ECCF2C15FF0E004C3181 /* AIAssistantVoiceChatViewModel.swift */; };
40 | 8BA3ECD22C16086F004C3181 /* Date+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA3ECD12C16086F004C3181 /* Date+Extension.swift */; };
41 | 8BA3ECD42C160FA7004C3181 /* ChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA3ECD32C160FA7004C3181 /* ChartView.swift */; };
42 | 8BDA05382C3C1E0100CDADA4 /* AIReceiptScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 8BDA05372C3C1E0100CDADA4 /* AIReceiptScanner */; };
43 | 8BDA053E2C3C1E3D00CDADA4 /* AddReceiptToExpenseConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA05392C3C1E3D00CDADA4 /* AddReceiptToExpenseConfirmationView.swift */; };
44 | 8BDA053F2C3C1E3D00CDADA4 /* AddReceiptToExpenseConfirmationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA053A2C3C1E3D00CDADA4 /* AddReceiptToExpenseConfirmationViewModel.swift */; };
45 | 8BDA05402C3C1E3D00CDADA4 /* ExpenseReceiptScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA053B2C3C1E3D00CDADA4 /* ExpenseReceiptScannerView.swift */; };
46 | 8BDA05412C3C1E3D00CDADA4 /* Receipt+ExpenseLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA053C2C3C1E3D00CDADA4 /* Receipt+ExpenseLog.swift */; };
47 | 8BDA05432C3C1E6300CDADA4 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 8BDA05422C3C1E6300CDADA4 /* GoogleService-Info.plist */; };
48 | /* End PBXBuildFile section */
49 |
50 | /* Begin PBXFileReference section */
51 | 8B6A0DB12BECB7E20022F8E2 /* AIExpenseTracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AIExpenseTracker.app; sourceTree = BUILT_PRODUCTS_DIR; };
52 | 8B6A0DB42BECB7E20022F8E2 /* AIExpenseTrackerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIExpenseTrackerApp.swift; sourceTree = ""; };
53 | 8B6A0DB62BECB7E20022F8E2 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
54 | 8B6A0DBA2BECB7E30022F8E2 /* AIExpenseTracker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AIExpenseTracker.entitlements; sourceTree = ""; };
55 | 8B6A0DBC2BECB7E30022F8E2 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
56 | 8B6A0DCB2BECB96C0022F8E2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; };
57 | 8B6A0DCC2BECC6170022F8E2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
58 | 8B6A0DCF2BECC9A50022F8E2 /* Category.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Category.swift; sourceTree = ""; };
59 | 8B6A0DD12BECCAA80022F8E2 /* ExpenseLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpenseLog.swift; sourceTree = ""; };
60 | 8B6A0DD32BECCBAD0022F8E2 /* Sort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sort.swift; sourceTree = ""; };
61 | 8B6A0DD52BECCC340022F8E2 /* Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; };
62 | 8B6A0DD72BECCD2E0022F8E2 /* DatabaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseManager.swift; sourceTree = ""; };
63 | 8B6A0DDA2BECD35C0022F8E2 /* LogListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogListViewModel.swift; sourceTree = ""; };
64 | 8B6A0DDD2BECD3DE0022F8E2 /* FilterCategoriesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterCategoriesView.swift; sourceTree = ""; };
65 | 8B6A0DDF2BECE2680022F8E2 /* SelectSortOrderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectSortOrderView.swift; sourceTree = ""; };
66 | 8B6A0DE12BECE4D40022F8E2 /* CategoryImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryImageView.swift; sourceTree = ""; };
67 | 8B6A0DE32BECEE890022F8E2 /* LogItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogItemView.swift; sourceTree = ""; };
68 | 8B6A0DE52BECF07C0022F8E2 /* LogListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogListView.swift; sourceTree = ""; };
69 | 8B6A0DE72BECF4800022F8E2 /* LogListContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogListContainerView.swift; sourceTree = ""; };
70 | 8B6A0DE92BECF90B0022F8E2 /* LogFormViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFormViewModel.swift; sourceTree = ""; };
71 | 8B6A0DEB2BECFA620022F8E2 /* LogFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogFormView.swift; sourceTree = ""; };
72 | 8B8D46122BEC45C5004FF132 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
73 | 8BA3ECBE2C15D79C004C3181 /* AIAssistantView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIAssistantView.swift; sourceTree = ""; };
74 | 8BA3ECC12C15DCEC004C3181 /* FunctionTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunctionTools.swift; sourceTree = ""; };
75 | 8BA3ECC52C15E042004C3181 /* AIAssistantResponseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIAssistantResponseView.swift; sourceTree = ""; };
76 | 8BA3ECC72C15E06B004C3181 /* AIAssistantTextChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIAssistantTextChatViewModel.swift; sourceTree = ""; };
77 | 8BA3ECC92C15EB62004C3181 /* FunctionArguments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunctionArguments.swift; sourceTree = ""; };
78 | 8BA3ECCB2C15EB9E004C3181 /* FunctionResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunctionResponse.swift; sourceTree = ""; };
79 | 8BA3ECCD2C15EC9E004C3181 /* FunctionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FunctionsManager.swift; sourceTree = ""; };
80 | 8BA3ECCF2C15FF0E004C3181 /* AIAssistantVoiceChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIAssistantVoiceChatViewModel.swift; sourceTree = ""; };
81 | 8BA3ECD12C16086F004C3181 /* Date+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extension.swift"; sourceTree = ""; };
82 | 8BA3ECD32C160FA7004C3181 /* ChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartView.swift; sourceTree = ""; };
83 | 8BDA05392C3C1E3D00CDADA4 /* AddReceiptToExpenseConfirmationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddReceiptToExpenseConfirmationView.swift; sourceTree = ""; };
84 | 8BDA053A2C3C1E3D00CDADA4 /* AddReceiptToExpenseConfirmationViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddReceiptToExpenseConfirmationViewModel.swift; sourceTree = ""; };
85 | 8BDA053B2C3C1E3D00CDADA4 /* ExpenseReceiptScannerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExpenseReceiptScannerView.swift; sourceTree = ""; };
86 | 8BDA053C2C3C1E3D00CDADA4 /* Receipt+ExpenseLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Receipt+ExpenseLog.swift"; sourceTree = ""; };
87 | 8BDA05422C3C1E6300CDADA4 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
88 | /* End PBXFileReference section */
89 |
90 | /* Begin PBXFrameworksBuildPhase section */
91 | 8B6A0DAE2BECB7E20022F8E2 /* Frameworks */ = {
92 | isa = PBXFrameworksBuildPhase;
93 | buildActionMask = 2147483647;
94 | files = (
95 | 8BDA05382C3C1E0100CDADA4 /* AIReceiptScanner in Frameworks */,
96 | 8B6A0DC82BECB8E80022F8E2 /* FirebaseFirestore in Frameworks */,
97 | 8BA3ECBB2C15D70B004C3181 /* ChatGPTUI in Frameworks */,
98 | 8B6A0DCA2BECB8E80022F8E2 /* FirebaseFirestoreSwift in Frameworks */,
99 | );
100 | runOnlyForDeploymentPostprocessing = 0;
101 | };
102 | /* End PBXFrameworksBuildPhase section */
103 |
104 | /* Begin PBXGroup section */
105 | 8B6A0DA82BECB7E20022F8E2 = {
106 | isa = PBXGroup;
107 | children = (
108 | 8B6A0DB32BECB7E20022F8E2 /* AIExpenseTracker */,
109 | 8B6A0DB22BECB7E20022F8E2 /* Products */,
110 | 8B6A0DC62BECB8E80022F8E2 /* Frameworks */,
111 | );
112 | sourceTree = "";
113 | };
114 | 8B6A0DB22BECB7E20022F8E2 /* Products */ = {
115 | isa = PBXGroup;
116 | children = (
117 | 8B6A0DB12BECB7E20022F8E2 /* AIExpenseTracker.app */,
118 | );
119 | name = Products;
120 | sourceTree = "";
121 | };
122 | 8B6A0DB32BECB7E20022F8E2 /* AIExpenseTracker */ = {
123 | isa = PBXGroup;
124 | children = (
125 | 8BDA053D2C3C1E3D00CDADA4 /* ReceiptScanner */,
126 | 8BA3ECBC2C15D786004C3181 /* AIAssistant */,
127 | 8B6A0DDC2BECD3CB0022F8E2 /* Views */,
128 | 8B6A0DD92BECD34C0022F8E2 /* ViewModels */,
129 | 8B6A0DCE2BECC9940022F8E2 /* Models */,
130 | 8B6A0DCB2BECB96C0022F8E2 /* Info.plist */,
131 | 8B6A0DB42BECB7E20022F8E2 /* AIExpenseTrackerApp.swift */,
132 | 8BDA05422C3C1E6300CDADA4 /* GoogleService-Info.plist */,
133 | 8B6A0DCC2BECC6170022F8E2 /* AppDelegate.swift */,
134 | 8B6A0DB62BECB7E20022F8E2 /* ContentView.swift */,
135 | 8B8D46122BEC45C5004FF132 /* Assets.xcassets */,
136 | 8B6A0DBA2BECB7E30022F8E2 /* AIExpenseTracker.entitlements */,
137 | 8B6A0DBB2BECB7E30022F8E2 /* Preview Content */,
138 | 8B6A0DD52BECCC340022F8E2 /* Utils.swift */,
139 | 8B6A0DD72BECCD2E0022F8E2 /* DatabaseManager.swift */,
140 | );
141 | path = AIExpenseTracker;
142 | sourceTree = "";
143 | };
144 | 8B6A0DBB2BECB7E30022F8E2 /* Preview Content */ = {
145 | isa = PBXGroup;
146 | children = (
147 | 8B6A0DBC2BECB7E30022F8E2 /* Preview Assets.xcassets */,
148 | );
149 | path = "Preview Content";
150 | sourceTree = "";
151 | };
152 | 8B6A0DC62BECB8E80022F8E2 /* Frameworks */ = {
153 | isa = PBXGroup;
154 | children = (
155 | );
156 | name = Frameworks;
157 | sourceTree = "";
158 | };
159 | 8B6A0DCE2BECC9940022F8E2 /* Models */ = {
160 | isa = PBXGroup;
161 | children = (
162 | 8B6A0DCF2BECC9A50022F8E2 /* Category.swift */,
163 | 8B6A0DD12BECCAA80022F8E2 /* ExpenseLog.swift */,
164 | 8B6A0DD32BECCBAD0022F8E2 /* Sort.swift */,
165 | );
166 | path = Models;
167 | sourceTree = "";
168 | };
169 | 8B6A0DD92BECD34C0022F8E2 /* ViewModels */ = {
170 | isa = PBXGroup;
171 | children = (
172 | 8B6A0DDA2BECD35C0022F8E2 /* LogListViewModel.swift */,
173 | 8B6A0DE92BECF90B0022F8E2 /* LogFormViewModel.swift */,
174 | );
175 | path = ViewModels;
176 | sourceTree = "";
177 | };
178 | 8B6A0DDC2BECD3CB0022F8E2 /* Views */ = {
179 | isa = PBXGroup;
180 | children = (
181 | 8B6A0DDD2BECD3DE0022F8E2 /* FilterCategoriesView.swift */,
182 | 8B6A0DDF2BECE2680022F8E2 /* SelectSortOrderView.swift */,
183 | 8B6A0DE12BECE4D40022F8E2 /* CategoryImageView.swift */,
184 | 8B6A0DE32BECEE890022F8E2 /* LogItemView.swift */,
185 | 8B6A0DE52BECF07C0022F8E2 /* LogListView.swift */,
186 | 8B6A0DE72BECF4800022F8E2 /* LogListContainerView.swift */,
187 | 8B6A0DEB2BECFA620022F8E2 /* LogFormView.swift */,
188 | 8BA3ECD32C160FA7004C3181 /* ChartView.swift */,
189 | );
190 | path = Views;
191 | sourceTree = "";
192 | };
193 | 8BA3ECBC2C15D786004C3181 /* AIAssistant */ = {
194 | isa = PBXGroup;
195 | children = (
196 | 8BA3ECC32C15E011004C3181 /* ViewModels */,
197 | 8BA3ECC02C15DCDC004C3181 /* Models */,
198 | 8BA3ECBD2C15D790004C3181 /* Views */,
199 | 8BA3ECCD2C15EC9E004C3181 /* FunctionsManager.swift */,
200 | 8BA3ECD12C16086F004C3181 /* Date+Extension.swift */,
201 | );
202 | path = AIAssistant;
203 | sourceTree = "";
204 | };
205 | 8BA3ECBD2C15D790004C3181 /* Views */ = {
206 | isa = PBXGroup;
207 | children = (
208 | 8BA3ECBE2C15D79C004C3181 /* AIAssistantView.swift */,
209 | 8BA3ECC52C15E042004C3181 /* AIAssistantResponseView.swift */,
210 | );
211 | path = Views;
212 | sourceTree = "";
213 | };
214 | 8BA3ECC02C15DCDC004C3181 /* Models */ = {
215 | isa = PBXGroup;
216 | children = (
217 | 8BA3ECC12C15DCEC004C3181 /* FunctionTools.swift */,
218 | 8BA3ECC92C15EB62004C3181 /* FunctionArguments.swift */,
219 | 8BA3ECCB2C15EB9E004C3181 /* FunctionResponse.swift */,
220 | );
221 | path = Models;
222 | sourceTree = "";
223 | };
224 | 8BA3ECC32C15E011004C3181 /* ViewModels */ = {
225 | isa = PBXGroup;
226 | children = (
227 | 8BA3ECC72C15E06B004C3181 /* AIAssistantTextChatViewModel.swift */,
228 | 8BA3ECCF2C15FF0E004C3181 /* AIAssistantVoiceChatViewModel.swift */,
229 | );
230 | path = ViewModels;
231 | sourceTree = "";
232 | };
233 | 8BDA053D2C3C1E3D00CDADA4 /* ReceiptScanner */ = {
234 | isa = PBXGroup;
235 | children = (
236 | 8BDA05392C3C1E3D00CDADA4 /* AddReceiptToExpenseConfirmationView.swift */,
237 | 8BDA053A2C3C1E3D00CDADA4 /* AddReceiptToExpenseConfirmationViewModel.swift */,
238 | 8BDA053B2C3C1E3D00CDADA4 /* ExpenseReceiptScannerView.swift */,
239 | 8BDA053C2C3C1E3D00CDADA4 /* Receipt+ExpenseLog.swift */,
240 | );
241 | path = ReceiptScanner;
242 | sourceTree = "";
243 | };
244 | /* End PBXGroup section */
245 |
246 | /* Begin PBXNativeTarget section */
247 | 8B6A0DB02BECB7E20022F8E2 /* AIExpenseTracker */ = {
248 | isa = PBXNativeTarget;
249 | buildConfigurationList = 8B6A0DC02BECB7E30022F8E2 /* Build configuration list for PBXNativeTarget "AIExpenseTracker" */;
250 | buildPhases = (
251 | 8B6A0DAD2BECB7E20022F8E2 /* Sources */,
252 | 8B6A0DAE2BECB7E20022F8E2 /* Frameworks */,
253 | 8B6A0DAF2BECB7E20022F8E2 /* Resources */,
254 | );
255 | buildRules = (
256 | );
257 | dependencies = (
258 | );
259 | name = AIExpenseTracker;
260 | packageProductDependencies = (
261 | 8B6A0DC72BECB8E80022F8E2 /* FirebaseFirestore */,
262 | 8B6A0DC92BECB8E80022F8E2 /* FirebaseFirestoreSwift */,
263 | 8BA3ECBA2C15D70B004C3181 /* ChatGPTUI */,
264 | 8BDA05372C3C1E0100CDADA4 /* AIReceiptScanner */,
265 | );
266 | productName = AIExpenseTracker;
267 | productReference = 8B6A0DB12BECB7E20022F8E2 /* AIExpenseTracker.app */;
268 | productType = "com.apple.product-type.application";
269 | };
270 | /* End PBXNativeTarget section */
271 |
272 | /* Begin PBXProject section */
273 | 8B6A0DA92BECB7E20022F8E2 /* Project object */ = {
274 | isa = PBXProject;
275 | attributes = {
276 | BuildIndependentTargetsInParallel = 1;
277 | LastSwiftUpdateCheck = 1530;
278 | LastUpgradeCheck = 1530;
279 | TargetAttributes = {
280 | 8B6A0DB02BECB7E20022F8E2 = {
281 | CreatedOnToolsVersion = 15.3;
282 | };
283 | };
284 | };
285 | buildConfigurationList = 8B6A0DAC2BECB7E20022F8E2 /* Build configuration list for PBXProject "AIExpenseTracker" */;
286 | compatibilityVersion = "Xcode 14.0";
287 | developmentRegion = en;
288 | hasScannedForEncodings = 0;
289 | knownRegions = (
290 | en,
291 | Base,
292 | );
293 | mainGroup = 8B6A0DA82BECB7E20022F8E2;
294 | packageReferences = (
295 | 8B6A0DC52BECB8C30022F8E2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
296 | 8BA3ECB92C15D70B004C3181 /* XCRemoteSwiftPackageReference "ChatGPTUI" */,
297 | 8BDA05362C3C1E0100CDADA4 /* XCRemoteSwiftPackageReference "AIReceiptScanner" */,
298 | );
299 | productRefGroup = 8B6A0DB22BECB7E20022F8E2 /* Products */;
300 | projectDirPath = "";
301 | projectRoot = "";
302 | targets = (
303 | 8B6A0DB02BECB7E20022F8E2 /* AIExpenseTracker */,
304 | );
305 | };
306 | /* End PBXProject section */
307 |
308 | /* Begin PBXResourcesBuildPhase section */
309 | 8B6A0DAF2BECB7E20022F8E2 /* Resources */ = {
310 | isa = PBXResourcesBuildPhase;
311 | buildActionMask = 2147483647;
312 | files = (
313 | 8B6A0DBD2BECB7E30022F8E2 /* Preview Assets.xcassets in Resources */,
314 | 8B8D46132BEC45C5004FF132 /* Assets.xcassets in Resources */,
315 | 8BDA05432C3C1E6300CDADA4 /* GoogleService-Info.plist in Resources */,
316 | );
317 | runOnlyForDeploymentPostprocessing = 0;
318 | };
319 | /* End PBXResourcesBuildPhase section */
320 |
321 | /* Begin PBXSourcesBuildPhase section */
322 | 8B6A0DAD2BECB7E20022F8E2 /* Sources */ = {
323 | isa = PBXSourcesBuildPhase;
324 | buildActionMask = 2147483647;
325 | files = (
326 | 8B6A0DD82BECCD2E0022F8E2 /* DatabaseManager.swift in Sources */,
327 | 8B6A0DD22BECCAA80022F8E2 /* ExpenseLog.swift in Sources */,
328 | 8BA3ECC22C15DCEC004C3181 /* FunctionTools.swift in Sources */,
329 | 8BDA05412C3C1E3D00CDADA4 /* Receipt+ExpenseLog.swift in Sources */,
330 | 8BDA05402C3C1E3D00CDADA4 /* ExpenseReceiptScannerView.swift in Sources */,
331 | 8BA3ECD02C15FF0E004C3181 /* AIAssistantVoiceChatViewModel.swift in Sources */,
332 | 8B6A0DCD2BECC6170022F8E2 /* AppDelegate.swift in Sources */,
333 | 8B6A0DEA2BECF90B0022F8E2 /* LogFormViewModel.swift in Sources */,
334 | 8BDA053F2C3C1E3D00CDADA4 /* AddReceiptToExpenseConfirmationViewModel.swift in Sources */,
335 | 8B6A0DEC2BECFA620022F8E2 /* LogFormView.swift in Sources */,
336 | 8BA3ECC62C15E042004C3181 /* AIAssistantResponseView.swift in Sources */,
337 | 8B6A0DDB2BECD35C0022F8E2 /* LogListViewModel.swift in Sources */,
338 | 8BA3ECC82C15E06B004C3181 /* AIAssistantTextChatViewModel.swift in Sources */,
339 | 8B6A0DE82BECF4800022F8E2 /* LogListContainerView.swift in Sources */,
340 | 8BA3ECCC2C15EB9E004C3181 /* FunctionResponse.swift in Sources */,
341 | 8B6A0DB72BECB7E20022F8E2 /* ContentView.swift in Sources */,
342 | 8B6A0DD62BECCC340022F8E2 /* Utils.swift in Sources */,
343 | 8B6A0DE22BECE4D40022F8E2 /* CategoryImageView.swift in Sources */,
344 | 8BA3ECD22C16086F004C3181 /* Date+Extension.swift in Sources */,
345 | 8BDA053E2C3C1E3D00CDADA4 /* AddReceiptToExpenseConfirmationView.swift in Sources */,
346 | 8B6A0DB52BECB7E20022F8E2 /* AIExpenseTrackerApp.swift in Sources */,
347 | 8B6A0DD02BECC9A50022F8E2 /* Category.swift in Sources */,
348 | 8B6A0DE02BECE2680022F8E2 /* SelectSortOrderView.swift in Sources */,
349 | 8BA3ECCA2C15EB62004C3181 /* FunctionArguments.swift in Sources */,
350 | 8BA3ECBF2C15D79C004C3181 /* AIAssistantView.swift in Sources */,
351 | 8B6A0DDE2BECD3DE0022F8E2 /* FilterCategoriesView.swift in Sources */,
352 | 8B6A0DE62BECF07C0022F8E2 /* LogListView.swift in Sources */,
353 | 8BA3ECD42C160FA7004C3181 /* ChartView.swift in Sources */,
354 | 8BA3ECCE2C15EC9E004C3181 /* FunctionsManager.swift in Sources */,
355 | 8B6A0DD42BECCBAD0022F8E2 /* Sort.swift in Sources */,
356 | 8B6A0DE42BECEE890022F8E2 /* LogItemView.swift in Sources */,
357 | );
358 | runOnlyForDeploymentPostprocessing = 0;
359 | };
360 | /* End PBXSourcesBuildPhase section */
361 |
362 | /* Begin XCBuildConfiguration section */
363 | 8B6A0DBE2BECB7E30022F8E2 /* Debug */ = {
364 | isa = XCBuildConfiguration;
365 | buildSettings = {
366 | ALWAYS_SEARCH_USER_PATHS = NO;
367 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
368 | CLANG_ANALYZER_NONNULL = YES;
369 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
370 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
371 | CLANG_ENABLE_MODULES = YES;
372 | CLANG_ENABLE_OBJC_ARC = YES;
373 | CLANG_ENABLE_OBJC_WEAK = YES;
374 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
375 | CLANG_WARN_BOOL_CONVERSION = YES;
376 | CLANG_WARN_COMMA = YES;
377 | CLANG_WARN_CONSTANT_CONVERSION = YES;
378 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
379 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
380 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
381 | CLANG_WARN_EMPTY_BODY = YES;
382 | CLANG_WARN_ENUM_CONVERSION = YES;
383 | CLANG_WARN_INFINITE_RECURSION = YES;
384 | CLANG_WARN_INT_CONVERSION = YES;
385 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
386 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
387 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
388 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
389 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
390 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
391 | CLANG_WARN_STRICT_PROTOTYPES = YES;
392 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
393 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
394 | CLANG_WARN_UNREACHABLE_CODE = YES;
395 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
396 | COPY_PHASE_STRIP = NO;
397 | DEBUG_INFORMATION_FORMAT = dwarf;
398 | ENABLE_STRICT_OBJC_MSGSEND = YES;
399 | ENABLE_TESTABILITY = YES;
400 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
401 | GCC_C_LANGUAGE_STANDARD = gnu17;
402 | GCC_DYNAMIC_NO_PIC = NO;
403 | GCC_NO_COMMON_BLOCKS = YES;
404 | GCC_OPTIMIZATION_LEVEL = 0;
405 | GCC_PREPROCESSOR_DEFINITIONS = (
406 | "DEBUG=1",
407 | "$(inherited)",
408 | );
409 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
410 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
411 | GCC_WARN_UNDECLARED_SELECTOR = YES;
412 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
413 | GCC_WARN_UNUSED_FUNCTION = YES;
414 | GCC_WARN_UNUSED_VARIABLE = YES;
415 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
416 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
417 | MTL_FAST_MATH = YES;
418 | ONLY_ACTIVE_ARCH = YES;
419 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
420 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
421 | };
422 | name = Debug;
423 | };
424 | 8B6A0DBF2BECB7E30022F8E2 /* Release */ = {
425 | isa = XCBuildConfiguration;
426 | buildSettings = {
427 | ALWAYS_SEARCH_USER_PATHS = NO;
428 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
429 | CLANG_ANALYZER_NONNULL = YES;
430 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
431 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
432 | CLANG_ENABLE_MODULES = YES;
433 | CLANG_ENABLE_OBJC_ARC = YES;
434 | CLANG_ENABLE_OBJC_WEAK = YES;
435 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
436 | CLANG_WARN_BOOL_CONVERSION = YES;
437 | CLANG_WARN_COMMA = YES;
438 | CLANG_WARN_CONSTANT_CONVERSION = YES;
439 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
440 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
441 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
442 | CLANG_WARN_EMPTY_BODY = YES;
443 | CLANG_WARN_ENUM_CONVERSION = YES;
444 | CLANG_WARN_INFINITE_RECURSION = YES;
445 | CLANG_WARN_INT_CONVERSION = YES;
446 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
447 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
448 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
449 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
450 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
451 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
452 | CLANG_WARN_STRICT_PROTOTYPES = YES;
453 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
454 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
455 | CLANG_WARN_UNREACHABLE_CODE = YES;
456 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
457 | COPY_PHASE_STRIP = NO;
458 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
459 | ENABLE_NS_ASSERTIONS = NO;
460 | ENABLE_STRICT_OBJC_MSGSEND = YES;
461 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
462 | GCC_C_LANGUAGE_STANDARD = gnu17;
463 | GCC_NO_COMMON_BLOCKS = YES;
464 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
465 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
466 | GCC_WARN_UNDECLARED_SELECTOR = YES;
467 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
468 | GCC_WARN_UNUSED_FUNCTION = YES;
469 | GCC_WARN_UNUSED_VARIABLE = YES;
470 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
471 | MTL_ENABLE_DEBUG_INFO = NO;
472 | MTL_FAST_MATH = YES;
473 | SWIFT_COMPILATION_MODE = wholemodule;
474 | };
475 | name = Release;
476 | };
477 | 8B6A0DC12BECB7E30022F8E2 /* Debug */ = {
478 | isa = XCBuildConfiguration;
479 | buildSettings = {
480 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
481 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
482 | CODE_SIGN_ENTITLEMENTS = AIExpenseTracker/AIExpenseTracker.entitlements;
483 | CODE_SIGN_STYLE = Automatic;
484 | CURRENT_PROJECT_VERSION = 1;
485 | DEVELOPMENT_ASSET_PATHS = "\"AIExpenseTracker/Preview Content\"";
486 | DEVELOPMENT_TEAM = 5C2XD9H2JS;
487 | ENABLE_HARDENED_RUNTIME = YES;
488 | ENABLE_PREVIEWS = YES;
489 | GENERATE_INFOPLIST_FILE = YES;
490 | INFOPLIST_FILE = AIExpenseTracker/Info.plist;
491 | INFOPLIST_KEY_NSCameraUsageDescription = "Take receipt picture";
492 | INFOPLIST_KEY_NSMicrophoneUsageDescription = "Talk with AI Assistant";
493 | INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Get receipt picture";
494 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
495 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
496 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
497 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
498 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
499 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
500 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
501 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
502 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
503 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
504 | IPHONEOS_DEPLOYMENT_TARGET = 17.4;
505 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
506 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
507 | MACOSX_DEPLOYMENT_TARGET = 14.4;
508 | MARKETING_VERSION = 1.0;
509 | PRODUCT_BUNDLE_IDENTIFIER = com.alfianlosari.SpendingTracker;
510 | PRODUCT_NAME = "$(TARGET_NAME)";
511 | SDKROOT = auto;
512 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
513 | SUPPORTS_MACCATALYST = NO;
514 | SWIFT_EMIT_LOC_STRINGS = YES;
515 | SWIFT_VERSION = 5.0;
516 | TARGETED_DEVICE_FAMILY = "1,2,7";
517 | };
518 | name = Debug;
519 | };
520 | 8B6A0DC22BECB7E30022F8E2 /* Release */ = {
521 | isa = XCBuildConfiguration;
522 | buildSettings = {
523 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
524 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
525 | CODE_SIGN_ENTITLEMENTS = AIExpenseTracker/AIExpenseTracker.entitlements;
526 | CODE_SIGN_STYLE = Automatic;
527 | CURRENT_PROJECT_VERSION = 1;
528 | DEVELOPMENT_ASSET_PATHS = "\"AIExpenseTracker/Preview Content\"";
529 | DEVELOPMENT_TEAM = 5C2XD9H2JS;
530 | ENABLE_HARDENED_RUNTIME = YES;
531 | ENABLE_PREVIEWS = YES;
532 | GENERATE_INFOPLIST_FILE = YES;
533 | INFOPLIST_FILE = AIExpenseTracker/Info.plist;
534 | INFOPLIST_KEY_NSCameraUsageDescription = "Take receipt picture";
535 | INFOPLIST_KEY_NSMicrophoneUsageDescription = "Talk with AI Assistant";
536 | INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Get receipt picture";
537 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
538 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
539 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
540 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES;
541 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES;
542 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES;
543 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
544 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
545 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
546 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
547 | IPHONEOS_DEPLOYMENT_TARGET = 17.4;
548 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
549 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
550 | MACOSX_DEPLOYMENT_TARGET = 14.4;
551 | MARKETING_VERSION = 1.0;
552 | PRODUCT_BUNDLE_IDENTIFIER = com.alfianlosari.SpendingTracker;
553 | PRODUCT_NAME = "$(TARGET_NAME)";
554 | SDKROOT = auto;
555 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
556 | SUPPORTS_MACCATALYST = NO;
557 | SWIFT_EMIT_LOC_STRINGS = YES;
558 | SWIFT_VERSION = 5.0;
559 | TARGETED_DEVICE_FAMILY = "1,2,7";
560 | };
561 | name = Release;
562 | };
563 | /* End XCBuildConfiguration section */
564 |
565 | /* Begin XCConfigurationList section */
566 | 8B6A0DAC2BECB7E20022F8E2 /* Build configuration list for PBXProject "AIExpenseTracker" */ = {
567 | isa = XCConfigurationList;
568 | buildConfigurations = (
569 | 8B6A0DBE2BECB7E30022F8E2 /* Debug */,
570 | 8B6A0DBF2BECB7E30022F8E2 /* Release */,
571 | );
572 | defaultConfigurationIsVisible = 0;
573 | defaultConfigurationName = Release;
574 | };
575 | 8B6A0DC02BECB7E30022F8E2 /* Build configuration list for PBXNativeTarget "AIExpenseTracker" */ = {
576 | isa = XCConfigurationList;
577 | buildConfigurations = (
578 | 8B6A0DC12BECB7E30022F8E2 /* Debug */,
579 | 8B6A0DC22BECB7E30022F8E2 /* Release */,
580 | );
581 | defaultConfigurationIsVisible = 0;
582 | defaultConfigurationName = Release;
583 | };
584 | /* End XCConfigurationList section */
585 |
586 | /* Begin XCRemoteSwiftPackageReference section */
587 | 8B6A0DC52BECB8C30022F8E2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = {
588 | isa = XCRemoteSwiftPackageReference;
589 | repositoryURL = "https://github.com/firebase/firebase-ios-sdk";
590 | requirement = {
591 | kind = upToNextMajorVersion;
592 | minimumVersion = 10.25.0;
593 | };
594 | };
595 | 8BA3ECB92C15D70B004C3181 /* XCRemoteSwiftPackageReference "ChatGPTUI" */ = {
596 | isa = XCRemoteSwiftPackageReference;
597 | repositoryURL = "https://github.com/alfianlosari/ChatGPTUI.git";
598 | requirement = {
599 | kind = upToNextMajorVersion;
600 | minimumVersion = 0.3.1;
601 | };
602 | };
603 | 8BDA05362C3C1E0100CDADA4 /* XCRemoteSwiftPackageReference "AIReceiptScanner" */ = {
604 | isa = XCRemoteSwiftPackageReference;
605 | repositoryURL = "https://github.com/alfianlosari/AIReceiptScanner";
606 | requirement = {
607 | kind = upToNextMajorVersion;
608 | minimumVersion = 1.0.4;
609 | };
610 | };
611 | /* End XCRemoteSwiftPackageReference section */
612 |
613 | /* Begin XCSwiftPackageProductDependency section */
614 | 8B6A0DC72BECB8E80022F8E2 /* FirebaseFirestore */ = {
615 | isa = XCSwiftPackageProductDependency;
616 | package = 8B6A0DC52BECB8C30022F8E2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
617 | productName = FirebaseFirestore;
618 | };
619 | 8B6A0DC92BECB8E80022F8E2 /* FirebaseFirestoreSwift */ = {
620 | isa = XCSwiftPackageProductDependency;
621 | package = 8B6A0DC52BECB8C30022F8E2 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
622 | productName = FirebaseFirestoreSwift;
623 | };
624 | 8BA3ECBA2C15D70B004C3181 /* ChatGPTUI */ = {
625 | isa = XCSwiftPackageProductDependency;
626 | package = 8BA3ECB92C15D70B004C3181 /* XCRemoteSwiftPackageReference "ChatGPTUI" */;
627 | productName = ChatGPTUI;
628 | };
629 | 8BDA05372C3C1E0100CDADA4 /* AIReceiptScanner */ = {
630 | isa = XCSwiftPackageProductDependency;
631 | package = 8BDA05362C3C1E0100CDADA4 /* XCRemoteSwiftPackageReference "AIReceiptScanner" */;
632 | productName = AIReceiptScanner;
633 | };
634 | /* End XCSwiftPackageProductDependency section */
635 | };
636 | rootObject = 8B6A0DA92BECB7E20022F8E2 /* Project object */;
637 | }
638 |
--------------------------------------------------------------------------------