├── .gitignore ├── AIExpenseTracker.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── swiftpm │ └── Package.resolved ├── AIExpenseTracker ├── AIAssistant │ ├── Date+Extension.swift │ ├── FunctionsManager.swift │ ├── Models │ │ ├── FunctionArguments.swift │ │ ├── FunctionResponse.swift │ │ └── FunctionTools.swift │ ├── ViewModels │ │ ├── AIAssistantTextChatViewModel.swift │ │ └── AIAssistantVoiceChatViewModel.swift │ └── Views │ │ ├── AIAssistantResponseView.swift │ │ └── AIAssistantView.swift ├── AIExpenseTracker.entitlements ├── AIExpenseTrackerApp.swift ├── AppDelegate.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── ContentView.swift ├── DatabaseManager.swift ├── Info.plist ├── Models │ ├── Category.swift │ ├── ExpenseLog.swift │ └── Sort.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── ReceiptScanner │ ├── AddReceiptToExpenseConfirmationView.swift │ ├── AddReceiptToExpenseConfirmationViewModel.swift │ ├── ExpenseReceiptScannerView.swift │ └── Receipt+ExpenseLog.swift ├── Utils.swift ├── ViewModels │ ├── LogFormViewModel.swift │ └── LogListViewModel.swift └── Views │ ├── CategoryImageView.swift │ ├── ChartView.swift │ ├── FilterCategoriesView.swift │ ├── LogFormView.swift │ ├── LogItemView.swift │ ├── LogListContainerView.swift │ ├── LogListView.swift │ └── SelectSortOrderView.swift └── README.md /.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.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 | -------------------------------------------------------------------------------- /AIExpenseTracker.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /AIExpenseTracker.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /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/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/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSAppTransportSecurity 6 | 7 | NSAllowsArbitraryLoads 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /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/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/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/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XCA AI Expense Tracker SwiftUI App 2 | 3 | ![alt text](https://i.ibb.co/KGtfPd7/promo.png) 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) --------------------------------------------------------------------------------