├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Sources └── AIReceiptScanner │ ├── Image+Extension.swift │ ├── Utils.swift │ ├── UI │ ├── DefaultReceiptPickerScannerMenuViewLabel.swift │ ├── ReceiptPickerScannerMenuViewModel.swift │ ├── ReceiptPickerScannerDefaultMenuView.swift │ ├── CameraView.swift │ ├── UIModels.swift │ ├── ReceiptPickerScannerView.swift │ ├── ReceiptPickerScannerMenuView.swift │ └── ReceiptScanResultView.swift │ ├── Models.swift │ ├── Image+Helpers.swift │ └── AIReceiptScanner.swift ├── Package.swift ├── LICENSE ├── README.MD └── Package.resolved /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/AIReceiptScanner/Image+Extension.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(SwiftUI) 3 | import SwiftUI 4 | #endif 5 | 6 | #if canImport(AppKit) 7 | import AppKit 8 | public typealias ReceiptImage = NSImage 9 | public extension ReceiptImage { 10 | var swiftUiImage: Image { 11 | Image(nsImage: self) 12 | } 13 | } 14 | #endif 15 | 16 | #if canImport(UIKit) 17 | import UIKit 18 | public typealias ReceiptImage = UIImage 19 | public extension ReceiptImage { 20 | var swiftUiImage: Image { 21 | Image(uiImage: self) 22 | } 23 | } 24 | #endif 25 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "AIReceiptScanner", 8 | platforms: [ 9 | .iOS(.v17), 10 | .watchOS(.v9), 11 | .macOS(.v14), 12 | .tvOS(.v17), 13 | .visionOS(.v1)], 14 | products: [ 15 | .library( 16 | name: "AIReceiptScanner", 17 | targets: ["AIReceiptScanner"]), 18 | ], 19 | dependencies: [ 20 | .package(url: "https://github.com/alfianlosari/ChatGPTSwift.git", from: "2.5.0") 21 | ], 22 | targets: [ 23 | .target( 24 | name: "AIReceiptScanner", 25 | dependencies: [ 26 | "ChatGPTSwift" 27 | ] 28 | ), 29 | 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Sources/AIReceiptScanner/Utils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | func printDebug(_ log: String) { 4 | #if DEBUG 5 | print("XCA AIReceiptScanner: \(log)") 6 | #endif 7 | } 8 | 9 | struct Utils { 10 | 11 | static let shared = Utils() 12 | 13 | private init() {} 14 | 15 | let currentDateFormatter: DateFormatter = { 16 | let df = DateFormatter() 17 | df.dateFormat = "yyyy-MM-dd" 18 | return df 19 | }() 20 | 21 | let numberFormatter: NumberFormatter = { 22 | let formatter = NumberFormatter() 23 | formatter.isLenient = true 24 | formatter.numberStyle = .currency 25 | formatter.currencyCode = "USD" 26 | return formatter 27 | }() 28 | 29 | func formatAmount(_ amount: Double, currency: String?) -> String { 30 | numberFormatter.currencyCode = currency ?? "" 31 | return numberFormatter.string(from: NSNumber(value: amount)) ?? String(amount) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/AIReceiptScanner/UI/DefaultReceiptPickerScannerMenuViewLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alfian Losari on 17/06/24. 3 | // 4 | 5 | #if canImport(SwiftUI) 6 | import SwiftUI 7 | 8 | struct DefaultReceiptPickerScannerMenuViewLabel: View { 9 | 10 | var image: Image? 11 | 12 | init(image: Image? = nil) { 13 | self.image = image 14 | } 15 | 16 | var body: some View { 17 | VStack { 18 | if let image { 19 | image 20 | .resizable() 21 | .scaledToFit() 22 | .clipped() 23 | .frame(maxWidth: .infinity, maxHeight: 600) 24 | } 25 | 26 | HStack { 27 | Image(systemName: "photo.badge.plus") 28 | .imageScale(.large) 29 | 30 | if image == nil { 31 | Text("Select Image") 32 | } else { 33 | Text("Select Other Image") 34 | } 35 | } 36 | } 37 | } 38 | } 39 | #endif 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alfian Losari 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/AIReceiptScanner/UI/ReceiptPickerScannerMenuViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // Created by Alfian Losari on 30/06/24. 4 | // 5 | 6 | #if canImport(SwiftUI) 7 | import PhotosUI 8 | import Observation 9 | import SwiftUI 10 | 11 | @Observable 12 | class ReceiptPickerScannerMenuViewModel { 13 | 14 | let receiptScanner: AIReceiptScanner 15 | var selectedImage: ReceiptImage? 16 | var selectedPhotoPickerItem: PhotosPickerItem? 17 | var shouldPresentPhotoPicker = false 18 | var scanStatus: ScanStatus = .idle 19 | var isPickingFile = false 20 | var isShowingCamera = false 21 | var cameraImage: ReceiptImage? 22 | 23 | public init(apiKey: String) { 24 | self.receiptScanner = .init(apiKey: apiKey) 25 | } 26 | 27 | @MainActor 28 | func scanImage() async { 29 | guard let image = selectedImage else { return } 30 | self.scanStatus = .prompting(image) 31 | do { 32 | let receipt = try await receiptScanner.scanImage(image) 33 | self.scanStatus = .success(.init(image: image, receipt: receipt)) 34 | } catch { 35 | self.scanStatus = .failure(error, image) 36 | } 37 | } 38 | 39 | } 40 | #endif 41 | -------------------------------------------------------------------------------- /Sources/AIReceiptScanner/UI/ReceiptPickerScannerDefaultMenuView.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import PhotosUI 3 | import SwiftUI 4 | 5 | public struct ReceiptPickerScannerDefaultMenuView: View { 6 | 7 | let apiKey: String 8 | @Binding var scanStatus: ScanStatus 9 | 10 | public init(apiKey: String, scanStatus: Binding) { 11 | self.apiKey = apiKey 12 | self._scanStatus = scanStatus 13 | } 14 | 15 | public var body: some View { 16 | #if os(macOS) || os(visionOS) 17 | VStack { 18 | if let image = scanStatus.receiptImage { 19 | image 20 | .resizable() 21 | .scaledToFit() 22 | .clipped() 23 | #if os(macOS) 24 | .frame(maxWidth: .infinity, maxHeight: .infinity) 25 | #endif 26 | } 27 | 28 | ReceiptPickerScannerMenuView(apiKey: apiKey, scanStatus: $scanStatus) { 29 | HStack { 30 | Image(systemName: "photo.badge.plus") 31 | .imageScale(.large) 32 | 33 | if scanStatus.receiptImage == nil { 34 | Text("Select Image") 35 | } else { 36 | Text("Select Other Image") 37 | } 38 | } 39 | } 40 | 41 | } 42 | #else 43 | ReceiptPickerScannerMenuView(apiKey: apiKey, scanStatus: $scanStatus) { 44 | DefaultReceiptPickerScannerMenuViewLabel(image: scanStatus.receiptImage) 45 | } 46 | #endif 47 | } 48 | 49 | } 50 | 51 | #endif 52 | 53 | -------------------------------------------------------------------------------- /Sources/AIReceiptScanner/UI/CameraView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Alfian Losari on 30/06/24. 6 | // 7 | 8 | #if canImport(SwiftUI) 9 | import Foundation 10 | import SwiftUI 11 | 12 | #if os(iOS) 13 | struct CameraView: UIViewControllerRepresentable { 14 | @Binding var isPresented: Bool 15 | @Binding var image: ReceiptImage? 16 | 17 | class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { 18 | @Binding var isPresented: Bool 19 | @Binding var image: ReceiptImage? 20 | 21 | init(isPresented: Binding, image: Binding) { 22 | _isPresented = isPresented 23 | _image = image 24 | } 25 | 26 | func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { 27 | isPresented = false 28 | } 29 | 30 | func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { 31 | if let uiImage = info[.originalImage] as? UIImage { 32 | image = uiImage 33 | } 34 | isPresented = false 35 | } 36 | } 37 | 38 | func makeCoordinator() -> Coordinator { 39 | return Coordinator(isPresented: $isPresented, image: $image) 40 | } 41 | 42 | func makeUIViewController(context: Context) -> UIImagePickerController { 43 | let picker = UIImagePickerController() 44 | picker.delegate = context.coordinator 45 | picker.sourceType = .camera 46 | picker.allowsEditing = false 47 | return picker 48 | } 49 | 50 | func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { 51 | } 52 | } 53 | #endif 54 | 55 | #endif 56 | -------------------------------------------------------------------------------- /Sources/AIReceiptScanner/UI/UIModels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Alfian Losari on 29/06/24. 3 | // 4 | 5 | #if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) || os(visionOS) 6 | import Foundation 7 | import SwiftUI 8 | 9 | public struct SuccessScanResult: Identifiable, Equatable { 10 | public let id = UUID() 11 | public let image: ReceiptImage 12 | public let receipt: Receipt 13 | 14 | public init(image: ReceiptImage, receipt: Receipt) { 15 | self.image = image 16 | self.receipt = receipt 17 | } 18 | } 19 | 20 | public enum ScanStatus: Equatable { 21 | case idle 22 | case pickingImage 23 | case prompting(ReceiptImage) 24 | case success(SuccessScanResult) 25 | case failure(Error, ReceiptImage) 26 | 27 | public var isPrompting: Bool { 28 | if case .prompting = self { 29 | return true 30 | } 31 | return false 32 | } 33 | 34 | public var receiptImage: Image? { 35 | if case .prompting(let receiptImage) = self { 36 | return receiptImage.swiftUiImage 37 | } else if case .success(let successScanResult) = self { 38 | return successScanResult.image.swiftUiImage 39 | } else if case .failure(_, let receiptImage) = self { 40 | return receiptImage.swiftUiImage 41 | } 42 | return nil 43 | } 44 | 45 | public var scanResult: SuccessScanResult? { 46 | if case .success(let result) = self { 47 | return result 48 | } 49 | return nil 50 | } 51 | 52 | public var error: Error? { 53 | if case .failure(let error, _) = self { 54 | return error 55 | } 56 | return nil 57 | } 58 | 59 | public static func == (lhs: Self, rhs: Self) -> Bool { 60 | switch (lhs, rhs) { 61 | case (.idle, .idle): return true 62 | case (.pickingImage, .pickingImage): return true 63 | case (.prompting(let image1), .prompting(let image2)): 64 | return image1 == image2 65 | case (.success(let result1), .success(let result2)): 66 | return result1 == result2 67 | case (.failure(let error1, let image1), .failure(let error2, let image2)): 68 | return error1.localizedDescription == error2.localizedDescription && image1 == image2 69 | default: return false 70 | } 71 | } 72 | } 73 | #endif 74 | -------------------------------------------------------------------------------- /Sources/AIReceiptScanner/Models.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Models.swift 3 | // AIExpenseTracker 4 | // 5 | // Created by Alfian Losari on 22/06/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Receipt: Codable, Identifiable, Equatable { 11 | public let id = UUID() 12 | 13 | public var receiptId: String? 14 | public var date: Date? 15 | public var tax: Double? 16 | public var discount: Double? 17 | public var total: Double? 18 | public var paymentMethod: String? 19 | public var currency: String? 20 | public var merchantName: String? 21 | public var customerName: String? 22 | public var items: [LineItem]? 23 | 24 | public init(receiptId: String? = nil, date: Date? = nil, tax: Double? = nil, discount: Double? = nil, total: Double? = nil, paymentMethod: String? = nil, currency: String? = nil, merchantName: String? = nil, customerName: String? = nil, items: [LineItem]? = nil) { 25 | self.receiptId = receiptId 26 | self.date = date 27 | self.tax = tax 28 | self.discount = discount 29 | self.total = total 30 | self.paymentMethod = paymentMethod 31 | self.currency = currency 32 | self.merchantName = merchantName 33 | self.customerName = customerName 34 | self.items = items 35 | } 36 | } 37 | 38 | public struct LineItem: Codable, Identifiable, Equatable { 39 | public let id = UUID() 40 | public let name: String 41 | public let price: Double 42 | public let category: String 43 | public let quantity: Double 44 | 45 | public init(name: String, price: Double, category: String, quantity: Double) { 46 | self.name = name 47 | self.price = price 48 | self.category = category 49 | self.quantity = quantity 50 | } 51 | } 52 | 53 | enum Category: String, Identifiable, CaseIterable { 54 | 55 | var id: Self { self } 56 | 57 | case accountingAndLegalFees = "Accounting and legal fees" 58 | case bankFees = "Bank fees" 59 | case consultantsAndProfessionalServices = "Consultants and professional services" 60 | case depreciation = "Depreciation" 61 | case employeeBenefits = "Employee benefits" 62 | case employeeExpenses = "Employee expenses" 63 | case entertainment = "Entertainment" 64 | case food = "Food" 65 | case gifts = "Gifts" 66 | case health = "Health" 67 | case insurance = "Insurance" 68 | case interest = "Interest" 69 | case learning = "Learning" 70 | case licensingFees = "Licensing fees" 71 | case marketing = "Marketing" 72 | case membershipFees = "Membership fees" 73 | case officeSupplies = "Office supplies" 74 | case payroll = "Payroll" 75 | case repairs = "Repairs" 76 | case rent = "Rent" 77 | case rentOrMortgagePayments = "Rent or mortgage payments" 78 | case software = "Software" 79 | case tax = "Tax" 80 | case travel = "Travel" 81 | case utilities = "Utilities" 82 | } 83 | -------------------------------------------------------------------------------- /Sources/AIReceiptScanner/Image+Helpers.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | #if canImport(UIKit) 4 | import UIKit 5 | extension UIImage { 6 | 7 | func scaleToFit(targetSize: CGSize = .init(width: 512, height: 512)) -> UIImage { 8 | let widthRatio = targetSize.width / size.width 9 | let heightRatio = targetSize.height / size.height 10 | 11 | let scaleFactor = min(widthRatio, heightRatio) 12 | 13 | let scaledImageSize = CGSize( 14 | width: size.width * scaleFactor, 15 | height: size.height * scaleFactor 16 | ) 17 | 18 | let renderer = UIGraphicsImageRenderer(size: targetSize) 19 | let scaledImage = renderer.image { _ in 20 | self.draw(in: .init( 21 | origin: .init( 22 | x: (targetSize.width - scaledImageSize.width) / 2.0, 23 | y: (targetSize.height - scaledImageSize.height) / 2.0), 24 | size: scaledImageSize)) 25 | } 26 | return scaledImage 27 | } 28 | 29 | func scaledJPGData(compressionQuality: CGFloat = 0.5) -> Data { 30 | #if os(visionOS) 31 | let targetSize = CGSize( 32 | width: size.width / UITraitCollection.current.displayScale, 33 | height: size.height / UITraitCollection.current.displayScale) 34 | #else 35 | let targetSize = CGSize( 36 | width: size.width / UIScreen.main.scale, 37 | height: size.height / UIScreen.main.scale) 38 | #endif 39 | 40 | 41 | let renderer = UIGraphicsImageRenderer(size: targetSize) 42 | let resized = renderer.image { _ in 43 | self.draw(in: .init(origin: .zero, size: targetSize)) 44 | } 45 | return resized.jpegData(compressionQuality: compressionQuality)! 46 | } 47 | 48 | } 49 | 50 | #endif 51 | 52 | #if canImport(AppKit) 53 | import AppKit 54 | 55 | extension NSImage { 56 | 57 | func scaleToFit(targetSize: CGSize = .init(width: 512, height: 512)) -> NSImage? { 58 | let widthRatio = targetSize.width / size.width 59 | let heightRatio = targetSize.height / size.height 60 | 61 | let scaleFactor = min(widthRatio, heightRatio) 62 | 63 | let scaledImageSize = CGSize( 64 | width: size.width * scaleFactor, 65 | height: size.height * scaleFactor 66 | ) 67 | 68 | let targetRect = NSRect( 69 | x: (targetSize.width - scaledImageSize.width) / 2.0, 70 | y: (targetSize.height - scaledImageSize.height) / 2.0, 71 | width: scaledImageSize.width, 72 | height: scaledImageSize.height 73 | ) 74 | 75 | let newImage = NSImage(size: targetSize) 76 | newImage.lockFocus() 77 | self.draw(in: targetRect, from: .zero, operation: .copy, fraction: 1.0) 78 | newImage.unlockFocus() 79 | 80 | return newImage 81 | } 82 | 83 | func scaledJPGData(compressionQuality: CGFloat = 0.5) -> Data? { 84 | guard let tiffData = self.tiffRepresentation, 85 | let bitmapImage = NSBitmapImageRep(data: tiffData), 86 | let jpegData = bitmapImage.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality]) else { 87 | return nil 88 | } 89 | return jpegData 90 | } 91 | } 92 | 93 | #endif 94 | -------------------------------------------------------------------------------- /Sources/AIReceiptScanner/UI/ReceiptPickerScannerView.swift: -------------------------------------------------------------------------------- 1 | // Created by Alfian Losari on 30/06/24. 2 | // 3 | 4 | #if canImport(SwiftUI) 5 | import Foundation 6 | import SwiftUI 7 | 8 | public struct ReceiptPickerScannerView: View { 9 | 10 | @Binding var scanStatus: ScanStatus 11 | @State var scanResultViewItem: SuccessScanResult? 12 | let apiKey: String 13 | @Environment(\.horizontalSizeClass) var horizontalSizeClass 14 | 15 | public init(apiKey: String, scanStatus: Binding) { 16 | self.apiKey = apiKey 17 | self._scanStatus = scanStatus 18 | } 19 | 20 | public var body: some View { 21 | ScrollView { 22 | VStack(alignment: .center, spacing: 8) { 23 | switch horizontalSizeClass { 24 | case .compact: 25 | ReceiptPickerScannerDefaultMenuView(apiKey: apiKey, scanStatus: $scanStatus) 26 | .disabled(scanStatus.isPrompting) 27 | .frame(minHeight: 80) 28 | .padding() 29 | 30 | Divider() 31 | 32 | if scanStatus.isPrompting { 33 | ProgressView("Analyzing Image") 34 | } 35 | 36 | if let scanResult = scanStatus.scanResult { 37 | Button("Show Scan Result") { 38 | scanResultViewItem = scanResult 39 | } 40 | } 41 | 42 | if let error = scanStatus.error { 43 | Text(error.localizedDescription) 44 | .foregroundStyle(Color.red) 45 | } 46 | 47 | default: 48 | HStack(alignment: .top, spacing: 16) { 49 | ReceiptPickerScannerDefaultMenuView(apiKey: apiKey, scanStatus: $scanStatus) 50 | .disabled(scanStatus.isPrompting) 51 | .frame(maxWidth: .infinity) 52 | 53 | Divider() 54 | 55 | Group { 56 | if scanStatus.isPrompting { 57 | VStack { 58 | Spacer() 59 | ProgressView("Analyzing Image") 60 | Spacer() 61 | } 62 | 63 | } 64 | if let scanResult = scanStatus.scanResult { 65 | ReceiptScanResultView(scanResult: scanResult, applyBottomSheetTrayStyle: false) 66 | } 67 | 68 | if let error = scanStatus.error { 69 | Text(error.localizedDescription) 70 | .foregroundStyle(Color.red) 71 | } 72 | } 73 | .frame(maxWidth: .infinity) 74 | 75 | } 76 | .padding() 77 | .frame(maxWidth: 1024, alignment: .center) 78 | 79 | } 80 | } 81 | } 82 | #if !os(macOS) 83 | .sheet(item: $scanResultViewItem) { 84 | ReceiptScanResultView(scanResult: $0, applyBottomSheetTrayStyle: true) 85 | } 86 | #endif 87 | .onChange(of: scanStatus) { _, newValue in 88 | self.scanStatus = newValue 89 | switch newValue { 90 | case .success(let result): 91 | if horizontalSizeClass == .compact { 92 | self.scanResultViewItem = result 93 | } 94 | default: 95 | break 96 | } 97 | } 98 | 99 | } 100 | } 101 | 102 | #endif 103 | -------------------------------------------------------------------------------- /Sources/AIReceiptScanner/UI/ReceiptPickerScannerMenuView.swift: -------------------------------------------------------------------------------- 1 | #if canImport(SwiftUI) 2 | import PhotosUI 3 | import SwiftUI 4 | 5 | public struct ReceiptPickerScannerMenuView