├── .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