├── .gitignore
├── Assets
    ├── App Store - iPad
    │   ├── iPad 1.jpg
    │   ├── iPad 2.jpg
    │   ├── iPad 3.jpg
    │   └── iPad 4.jpg
    ├── App Store - iPhone 1
    │   ├── black-mockup.png
    │   ├── iPhone 1 - 1.jpg
    │   ├── iPhone 1 - 2.jpg
    │   ├── iPhone 1 - 3.jpg
    │   └── iPhone 1 - 4.jpg
    ├── App Store - iPhone 2
    │   ├── iPhone 2 - 1.jpg
    │   ├── iPhone 2 - 2.jpg
    │   ├── iPhone 2 - 3.jpg
    │   └── iPhone 2 - 4.jpg
    ├── icon.png
    └── promo.png
├── LICENSE
├── PrivacyInfo.xcprivacy
├── README.md
├── SplitBill Extension
    ├── Base.lproj
    │   └── MainInterface.storyboard
    ├── Info.plist
    ├── ShareViewController.swift
    ├── SplitBill Extension.entitlements
    └── id.lproj
    │   └── MainInterface.strings
├── SplitBill.xcodeproj
    └── project.pbxproj
├── SplitBill
    ├── AppIcon.icon
    │   ├── Assets
    │   │   └── dark icon.svg
    │   └── icon.json
    ├── Assets.xcassets
    │   ├── AccentColor.colorset
    │   │   └── Contents.json
    │   ├── BackgroundColor.colorset
    │   │   └── Contents.json
    │   ├── Contents.json
    │   ├── ForegroundColor.colorset
    │   │   └── Contents.json
    │   ├── LabelColor.colorset
    │   │   └── Contents.json
    │   ├── MainColor.colorset
    │   │   └── Contents.json
    │   ├── MarkerColor.colorset
    │   │   └── Contents.json
    │   ├── cardBlue.colorset
    │   │   └── Contents.json
    │   ├── cardBlueFont.colorset
    │   │   └── Contents.json
    │   ├── cardBlueLight.colorset
    │   │   └── Contents.json
    │   ├── cardDark.colorset
    │   │   └── Contents.json
    │   ├── cardDarkFont.colorset
    │   │   └── Contents.json
    │   ├── cardDarkLight.colorset
    │   │   └── Contents.json
    │   ├── cardEmerald.colorset
    │   │   └── Contents.json
    │   ├── cardEmeraldFont.colorset
    │   │   └── Contents.json
    │   ├── cardEmeraldLight.colorset
    │   │   └── Contents.json
    │   ├── cardGray.colorset
    │   │   └── Contents.json
    │   ├── cardGrayFont.colorset
    │   │   └── Contents.json
    │   ├── cardGrayLight.colorset
    │   │   └── Contents.json
    │   ├── cardLightBlue.colorset
    │   │   └── Contents.json
    │   ├── cardLightBlueFont.colorset
    │   │   └── Contents.json
    │   ├── cardLightBlueLight.colorset
    │   │   └── Contents.json
    │   ├── cardRed.colorset
    │   │   └── Contents.json
    │   ├── cardRedFont.colorset
    │   │   └── Contents.json
    │   ├── cardRedLight.colorset
    │   │   └── Contents.json
    │   ├── cardThistle.colorset
    │   │   └── Contents.json
    │   ├── cardThistleFont.colorset
    │   │   └── Contents.json
    │   ├── cardThistleLight.colorset
    │   │   └── Contents.json
    │   ├── cardYellow.colorset
    │   │   └── Contents.json
    │   ├── cardYellowFont.colorset
    │   │   └── Contents.json
    │   ├── cardYellowLight.colorset
    │   │   └── Contents.json
    │   ├── exportCardBackground.colorset
    │   │   └── Contents.json
    │   ├── exportCardSeperator.colorset
    │   │   └── Contents.json
    │   └── github-logo.imageset
    │   │   ├── Contents.json
    │   │   └── github-mark.svg
    ├── BlurTop.swift
    ├── ButtonsOverlayLogic.swift
    ├── ButtonsOverlayView.swift
    ├── CalcTextField
    │   ├── CalcKeyboard.swift
    │   ├── CalcKeyboard.xib
    │   ├── CalcKeyboardViewController.swift
    │   └── CalcTextField.swift
    ├── CardListItem.swift
    ├── CardModel.swift
    ├── CardTapInteractionModifier.swift
    ├── CardsView.swift
    ├── ContentView.swift
    ├── ContentViewModel.swift
    ├── CustomColorPicker.swift
    ├── EditCardSheet.swift
    ├── EditCardsView.swift
    ├── EditableShares.swift
    ├── Extensions
    │   ├── CGRect.swift
    │   ├── Collection.swift
    │   ├── Double.swift
    │   ├── GeometryEffect.swift
    │   ├── NSExpression.swift
    │   ├── ObjC.m
    │   ├── PresentationDetent.swift
    │   ├── SplitBill-Bridging-Header.h
    │   ├── UIColor.swift
    │   ├── UIFont.swift
    │   ├── UIImage.swift
    │   ├── UIView.swift
    │   └── View.swift
    ├── FloatingTransaction.swift
    ├── FloatingTransactionTextField.swift
    ├── Font
    │   ├── SF-Pro-Rounded-Bold.otf
    │   └── SF-Pro-Rounded-Semibold.otf
    ├── HelperModels
    │   └── FloatingTransactionInfo.swift
    ├── ImagePickerView.swift
    ├── Info.plist
    ├── LinkItemView.swift
    ├── LiveTextImage.swift
    ├── LiveTextInteraction.swift
    ├── LiveTextInteractionExport.swift
    ├── MenuOptionsView.swift
    ├── Preview Content
    │   └── Preview Assets.xcassets
    │   │   └── Contents.json
    ├── ScannerView.swift
    ├── SelectImageView.swift
    ├── SettingsSheet.swift
    ├── SettingsView.swift
    ├── ShareTextField.swift
    ├── SingleCardView.swift
    ├── SplitBill.entitlements
    ├── SplitBill.swift
    ├── TransactionList.swift
    ├── TransactionModel.swift
    ├── UndoRedoStackView.swift
    ├── Util
    │   ├── Alerter.swift
    │   ├── Colors.swift
    │   ├── OneHandedZoomGesture.swift
    │   └── util.swift
    ├── ZoomableScrollView.swift
    ├── de.lproj
    │   └── Localizable.strings
    ├── en.lproj
    │   └── Localizable.strings
    └── id.lproj
    │   └── Localizable.strings
└── SplitBillShared
    ├── Shared.swift
    └── SplitBillShared.h
/.gitignore:
--------------------------------------------------------------------------------
1 | SplitBill/.DS_Store
2 | SplitBill.xcodeproj/project.xcworkspace/xcuserdata
3 | SplitBill.xcodeproj/project.xcworkspace
4 | SplitBill.xcodeproj/xcuserdata
5 | .DS_Store
6 | 
--------------------------------------------------------------------------------
/Assets/App Store - iPad/iPad 1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPad/iPad 1.jpg
--------------------------------------------------------------------------------
/Assets/App Store - iPad/iPad 2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPad/iPad 2.jpg
--------------------------------------------------------------------------------
/Assets/App Store - iPad/iPad 3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPad/iPad 3.jpg
--------------------------------------------------------------------------------
/Assets/App Store - iPad/iPad 4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPad/iPad 4.jpg
--------------------------------------------------------------------------------
/Assets/App Store - iPhone 1/black-mockup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPhone 1/black-mockup.png
--------------------------------------------------------------------------------
/Assets/App Store - iPhone 1/iPhone 1 - 1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPhone 1/iPhone 1 - 1.jpg
--------------------------------------------------------------------------------
/Assets/App Store - iPhone 1/iPhone 1 - 2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPhone 1/iPhone 1 - 2.jpg
--------------------------------------------------------------------------------
/Assets/App Store - iPhone 1/iPhone 1 - 3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPhone 1/iPhone 1 - 3.jpg
--------------------------------------------------------------------------------
/Assets/App Store - iPhone 1/iPhone 1 - 4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPhone 1/iPhone 1 - 4.jpg
--------------------------------------------------------------------------------
/Assets/App Store - iPhone 2/iPhone 2 - 1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPhone 2/iPhone 2 - 1.jpg
--------------------------------------------------------------------------------
/Assets/App Store - iPhone 2/iPhone 2 - 2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPhone 2/iPhone 2 - 2.jpg
--------------------------------------------------------------------------------
/Assets/App Store - iPhone 2/iPhone 2 - 3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPhone 2/iPhone 2 - 3.jpg
--------------------------------------------------------------------------------
/Assets/App Store - iPhone 2/iPhone 2 - 4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/App Store - iPhone 2/iPhone 2 - 4.jpg
--------------------------------------------------------------------------------
/Assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/icon.png
--------------------------------------------------------------------------------
/Assets/promo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/Assets/promo.png
--------------------------------------------------------------------------------
/PrivacyInfo.xcprivacy:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 | 
 5 | 	NSPrivacyAccessedAPITypes
 6 | 	
 7 | 		
 8 | 			NSPrivacyAccessedAPITypeReasons
 9 | 			
10 | 				CA92.1
11 | 				1C8F.1
12 | 			
13 | 			NSPrivacyAccessedAPIType
14 | 			NSPrivacyAccessedAPICategoryUserDefaults
15 | 		
16 | 	
17 | 
18 | 
19 | 
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
 1 | 
 2 |   
 3 |     
 4 |   
 5 | 
 6 | 
 7 | SplitBill
 8 | 
 9 | 
10 | An iOS App to split transactions from an image
11 | 
12 | 
13 | 
14 |   
15 |     
16 |   
17 | 
18 | 
19 | ## SplitBill
20 | 
21 | SplitBill is an iOS App to sum up transactions from an image and split them between people, categories, or anything else that makes sense to you.
22 | 
23 | It is available on the App Store for free: [Download](https://apps.apple.com/de/app/splitbill-split-from-image/id6444704240?l=en-GB).
24 | 
--------------------------------------------------------------------------------
/SplitBill Extension/Info.plist:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 | 
 5 | 	NSExtension
 6 | 	
 7 | 		NSExtensionAttributes
 8 | 		
 9 | 			IntentsSupported
10 | 			
11 | 			NSExtensionActivationRule
12 | 			
13 | 				NSExtensionActivationSupportsImageWithMaxCount
14 | 				1
15 | 			
16 | 		
17 | 		NSExtensionMainStoryboard
18 | 		MainInterface
19 | 		NSExtensionPointIdentifier
20 | 		com.apple.share-services
21 | 	
22 | 
23 | 
24 | 
--------------------------------------------------------------------------------
/SplitBill Extension/ShareViewController.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  ShareViewController.swift
  3 | //  SplitBill Extension
  4 | //
  5 | //  Created by fer0n on 21.01.23.
  6 | //
  7 | //
  8 | 
  9 | import UIKit
 10 | import MobileCoreServices
 11 | import UniformTypeIdentifiers
 12 | import SwiftUI
 13 | import SplitBillShared
 14 | 
 15 | @objc(ShareExtensionViewController)
 16 | class ShareViewController: UIViewController {
 17 | 
 18 |     @IBOutlet weak var checkmarkIcon: UIImageView!
 19 |     @IBOutlet weak var spinner: UIActivityIndicatorView!
 20 |     @IBOutlet weak var openInAppLabel: UILabel!
 21 |     @IBOutlet weak var imageSavedLabel: UILabel!
 22 |     @IBOutlet weak var openInAppButton: UIButton!
 23 |     @IBOutlet weak var closeButton: UIButton!
 24 |     @IBOutlet weak var selectedImage: UIImageView!
 25 | 
 26 |     override func viewDidAppear(_ animated: Bool) {
 27 |         super.viewDidAppear(animated)
 28 |         self.handleSharedFile()
 29 |     }
 30 | 
 31 |     override func viewWillAppear(_ animated: Bool) {
 32 |         super.viewWillAppear(animated)
 33 |         spinner.startAnimating()
 34 |     }
 35 | 
 36 |     override func viewDidLoad() {
 37 |         super.viewDidLoad()
 38 |         openInAppButton.setTitle(NSLocalizedString("openInApp", comment: ""), for: .normal)
 39 |         openInAppButton.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .heavy)
 40 |         openInAppButton.backgroundColor = UIColor.white
 41 |         openInAppButton.layer.cornerRadius = 15
 42 |         openInAppButton.clipsToBounds = true
 43 |         spinner.hidesWhenStopped = true
 44 | 
 45 |         imageSavedLabel.text = NSLocalizedString("imageSaved", comment: "")
 46 |         openInAppLabel.text = NSLocalizedString("openInAppExplanation", comment: "")
 47 | 
 48 |         hideSuccessUI()
 49 |     }
 50 | 
 51 |     @IBAction func handleOpenInAppButton() {
 52 |         openSplitBillApp()
 53 |         closeExtension()
 54 |     }
 55 | 
 56 |     @IBAction func handleCloseButton() {
 57 |         cancelExtension()
 58 |     }
 59 | 
 60 |     private func cancelExtension() {
 61 |         // swiftlint:disable:next discouraged_direct_init
 62 |         self.extensionContext?.cancelRequest(withError: NSError())
 63 |     }
 64 | 
 65 |     private func closeExtension() {
 66 |         self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
 67 |     }
 68 | 
 69 |     private func hideSuccessUI() {
 70 |         openInAppButton.isEnabled = false
 71 |         imageSavedLabel.isHidden = true
 72 |         openInAppLabel.isHidden = true
 73 |         checkmarkIcon.isHidden = true
 74 |     }
 75 | 
 76 |     private func handleSharedFile() {
 77 |         hideSuccessUI()
 78 | 
 79 |         // extracting the path to the URL that is being shared
 80 |         let attachments = (self.extensionContext?.inputItems.first as? NSExtensionItem)?.attachments ?? []
 81 |         let contentType = UTType.image.identifier
 82 |         let identifier = "public.heic"
 83 | 
 84 |         for provider in attachments {
 85 |             if provider.hasItemConformingToTypeIdentifier(identifier) {
 86 |                 provider.loadItem(forTypeIdentifier: identifier, options: nil) { (data, error) in
 87 |                     /*
 88 |                      For some reason, the coordinates of heic images are different.
 89 |                      Images here appear to have a .heic and .jpeg version, where the .jpeg has the same
 90 |                      coordinate issues. This workaround tries to use the .heic version and
 91 |                      apply the coordinate workaround later on
 92 |                      */
 93 |                     self.storeImage(data, error)
 94 |                 }
 95 |             } else if provider.hasItemConformingToTypeIdentifier(contentType) {
 96 |                 provider.loadItem(forTypeIdentifier: contentType,
 97 |                                   options: nil) { (data, error) in
 98 |                     self.storeImage(data, error)
 99 |                 }
100 | 
101 |             }
102 |         }
103 | 
104 |     }
105 | 
106 |     func storeImage(_ data: NSSecureCoding?, _ error: Error?) {
107 |         guard error == nil else { return }
108 |         if let url = data as? URL,
109 |            let uiImg = UIImage(contentsOfFile: url.path) {
110 |             let isHeic = url.pathExtension == "HEIC"
111 |             saveImageData(uiImg, isHeic: isHeic)
112 |         } else if let uiImg = data as? UIImage {
113 |             // always use png here since it's a screenshot
114 |             saveImageData(uiImg, isHeic: false)
115 |         } else if let data = data as? Data,
116 |                   let uiImg = UIImage(data: data) {
117 |             saveImageData(uiImg, isHeic: false)
118 |         } else {
119 |             fatalError("Impossible to save image")
120 |         }
121 |     }
122 | 
123 |     private func displayError() {
124 |         self.imageSavedLabel.text = NSLocalizedString("error", comment: "")
125 |         self.imageSavedLabel.isHidden = false
126 |         self.openInAppLabel.text = NSLocalizedString("errorMessage", comment: "")
127 |         self.openInAppLabel.isHidden = false
128 |         self.openInAppButton.isEnabled = true
129 |         self.spinner.stopAnimating()
130 |     }
131 | 
132 |     private func saveImageData(_ image: UIImage, isHeic: Bool?) {
133 |         DispatchQueue.main.async {
134 |             var data: Data
135 |             do {
136 |                 data = try saveImageDataToSplitBill(image, isHeic: isHeic, isPreservation: false)
137 |             } catch {
138 |                 self.displayError()
139 |                 return
140 |             }
141 | 
142 |             let targetSize = CGSizeApplyAffineTransform(self.selectedImage.frame.size,
143 |                                                         CGAffineTransform(scaleX: 3, y: 3))
144 |             let image = UIImage(data: data) ?? image
145 |             let lowResImage = resizeImage(image: image, targetSize: targetSize)
146 |             self.selectedImage.image = makeRoundedImage(image: lowResImage)
147 |             self.selectedImage.setNeedsDisplay()
148 | 
149 |             self.openInAppButton.isEnabled = true
150 |             self.spinner.stopAnimating()
151 |             self.imageSavedLabel.isHidden = false
152 |             self.openInAppLabel.isHidden = false
153 |             self.checkmarkIcon.isHidden = false
154 |         }
155 |     }
156 | 
157 |     private func openSplitBillApp() {
158 |         if let url = URL(string: "splitbill://") {
159 |             _ = openURL(url)
160 |         }
161 |     }
162 | 
163 |     @objc func openURL(_ url: URL) -> Bool {
164 |         var responder: UIResponder? = self
165 |         while responder != nil {
166 |             if let application = responder as? UIApplication {
167 |                 return application.perform(#selector(openURL(_:)), with: url) != nil
168 |             }
169 |             responder = responder?.next
170 |         }
171 |         return false
172 |     }
173 | }
174 | 
175 | func makeRoundedImage(image: UIImage) -> UIImage {
176 |     let imageLayer = CALayer()
177 |     let rect = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)
178 |     imageLayer.frame = rect
179 |     imageLayer.contents = image.cgImage
180 |     imageLayer.masksToBounds = true
181 |     imageLayer.cornerRadius = rect.cornerRadius
182 | 
183 |     UIGraphicsBeginImageContext(image.size)
184 |     var roundedImage: UIImage?
185 |     if let context = UIGraphicsGetCurrentContext() {
186 |         imageLayer.render(in: context)
187 |         roundedImage = UIGraphicsGetImageFromCurrentImageContext()
188 |     }
189 |     UIGraphicsEndImageContext()
190 |     return roundedImage ?? image
191 | }
192 | 
193 | func resizeImage(image: UIImage, targetSize: CGSize) -> UIImage {
194 |     let size = image.size
195 | 
196 |     let widthRatio  = targetSize.width  / size.width
197 |     let heightRatio = targetSize.height / size.height
198 | 
199 |     // Figure out what our orientation is, and use that to form the rectangle
200 |     var newSize: CGSize
201 |     if widthRatio > heightRatio {
202 |         newSize = CGSize(width: size.width * heightRatio, height: size.height * heightRatio)
203 |     } else {
204 |         newSize = CGSize(width: size.width * widthRatio, height: size.height * widthRatio)
205 |     }
206 | 
207 |     // This is the rect that we've calculated out and this is what is actually used below
208 |     let rect = CGRect(x: 0, y: 0, width: newSize.width, height: newSize.height)
209 | 
210 |     // Actually do the resizing to the rect using the ImageContext stuff
211 |     UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0)
212 |     image.draw(in: rect)
213 |     let newImage = UIGraphicsGetImageFromCurrentImageContext()
214 |     UIGraphicsEndImageContext()
215 | 
216 |     return newImage!
217 | }
218 | 
219 | extension CGRect {
220 |     var cornerRadius: CGFloat {
221 |         0.05 * min(self.width, self.height)
222 |     }
223 | }
224 | 
--------------------------------------------------------------------------------
/SplitBill Extension/SplitBill Extension.entitlements:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 | 
 5 | 	com.apple.security.application-groups
 6 | 	
 7 | 		group.splitbill
 8 | 	
 9 | 
10 | 
11 | 
--------------------------------------------------------------------------------
/SplitBill Extension/id.lproj/MainInterface.strings:
--------------------------------------------------------------------------------
 1 | 
 2 | /* Class = "UIButton"; normalTitle = "openInApp"; ObjectID = "Ejp-7b-CWG"; */
 3 | "Ejp-7b-CWG.normalTitle" = "openInApp";
 4 | 
 5 | /* Class = "UILabel"; text = "imageSaved"; ObjectID = "r5p-e7-ScY"; */
 6 | "r5p-e7-ScY.text" = "imageSaved";
 7 | 
 8 | /* Class = "UILabel"; text = "Next time you open the app the image will be loaded automatically"; ObjectID = "tZJ-Aj-Yru"; */
 9 | "tZJ-Aj-Yru.text" = "Next time you open the app the image will be loaded automatically";
10 | 
--------------------------------------------------------------------------------
/SplitBill/AppIcon.icon/Assets/dark icon.svg:
--------------------------------------------------------------------------------
 1 | 
10 | 
--------------------------------------------------------------------------------
/SplitBill/AppIcon.icon/icon.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "fill" : {
 3 |     "linear-gradient" : [
 4 |       "srgb:0.30980,0.63922,0.96471,1.00000",
 5 |       "display-p3:0.34902,0.20392,0.87843,1.00000"
 6 |     ]
 7 |   },
 8 |   "groups" : [
 9 |     {
10 |       "blur-material" : null,
11 |       "hidden" : false,
12 |       "layers" : [
13 |         {
14 |           "blend-mode-specializations" : [
15 |             {
16 |               "appearance" : "dark",
17 |               "value" : "normal"
18 |             }
19 |           ],
20 |           "fill-specializations" : [
21 |             {
22 |               "value" : {
23 |                 "solid" : "display-p3:1.00000,1.00000,1.00000,1.00000"
24 |               }
25 |             },
26 |             {
27 |               "appearance" : "dark",
28 |               "value" : {
29 |                 "linear-gradient" : [
30 |                   "display-p3:0.31373,0.64314,0.85490,1.00000",
31 |                   "display-p3:0.33333,0.33333,0.83529,1.00000"
32 |                 ]
33 |               }
34 |             }
35 |           ],
36 |           "glass" : true,
37 |           "hidden" : false,
38 |           "image-name" : "dark icon.svg",
39 |           "name" : "dark icon"
40 |         }
41 |       ],
42 |       "shadow" : {
43 |         "kind" : "neutral",
44 |         "opacity" : 0.5
45 |       },
46 |       "specular" : true,
47 |       "translucency" : {
48 |         "enabled" : true,
49 |         "value" : 0.5
50 |       }
51 |     }
52 |   ],
53 |   "supported-platforms" : {
54 |     "circles" : [
55 |       "watchOS"
56 |     ],
57 |     "squares" : "shared"
58 |   }
59 | }
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "platform" : "universal",
 6 |         "reference" : "labelColor"
 7 |       },
 8 |       "idiom" : "universal"
 9 |     }
10 |   ],
11 |   "info" : {
12 |     "author" : "xcode",
13 |     "version" : 1
14 |   }
15 | }
16 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/BackgroundColor.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "extended-srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.118",
 9 |           "green" : "0.110",
10 |           "red" : "0.110"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "platform" : "ios",
24 |         "reference" : "systemGray6Color"
25 |       },
26 |       "idiom" : "universal"
27 |     }
28 |   ],
29 |   "info" : {
30 |     "author" : "xcode",
31 |     "version" : 1
32 |   }
33 | }
34 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 |   "info" : {
3 |     "author" : "xcode",
4 |     "version" : 1
5 |   }
6 | }
7 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/ForegroundColor.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "1.000",
 9 |           "green" : "1.000",
10 |           "red" : "1.000"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "light"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0.000",
27 |           "green" : "0.000",
28 |           "red" : "0.000"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     },
33 |     {
34 |       "appearances" : [
35 |         {
36 |           "appearance" : "luminosity",
37 |           "value" : "dark"
38 |         }
39 |       ],
40 |       "color" : {
41 |         "color-space" : "srgb",
42 |         "components" : {
43 |           "alpha" : "1.000",
44 |           "blue" : "1.000",
45 |           "green" : "1.000",
46 |           "red" : "1.000"
47 |         }
48 |       },
49 |       "idiom" : "universal"
50 |     }
51 |   ],
52 |   "info" : {
53 |     "author" : "xcode",
54 |     "version" : 1
55 |   }
56 | }
57 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/LabelColor.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x00",
 9 |           "green" : "0x00",
10 |           "red" : "0x00"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "srgb",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0xFF",
27 |           "green" : "0xFF",
28 |           "red" : "0xFF"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/MainColor.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.000",
 9 |           "green" : "0.000",
10 |           "red" : "0.000"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/MarkerColor.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "platform" : "universal",
 6 |         "reference" : "systemCyanColor"
 7 |       },
 8 |       "idiom" : "universal"
 9 |     }
10 |   ],
11 |   "info" : {
12 |     "author" : "xcode",
13 |     "version" : 1
14 |   }
15 | }
16 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardBlue.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x57",
 9 |           "green" : "0x35",
10 |           "red" : "0x1D"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardBlueFont.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0xFF",
 9 |           "green" : "0x9A",
10 |           "red" : "0x62"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "light"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "display-p3",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0x57",
27 |           "green" : "0x35",
28 |           "red" : "0x1D"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     },
33 |     {
34 |       "appearances" : [
35 |         {
36 |           "appearance" : "luminosity",
37 |           "value" : "dark"
38 |         }
39 |       ],
40 |       "color" : {
41 |         "color-space" : "display-p3",
42 |         "components" : {
43 |           "alpha" : "1.000",
44 |           "blue" : "1.000",
45 |           "green" : "0.606",
46 |           "red" : "0.386"
47 |         }
48 |       },
49 |       "idiom" : "universal"
50 |     }
51 |   ],
52 |   "info" : {
53 |     "author" : "xcode",
54 |     "version" : 1
55 |   }
56 | }
57 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardBlueLight.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0xA1",
 9 |           "green" : "0x60",
10 |           "red" : "0x34"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardDark.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x1A",
 9 |           "green" : "0x15",
10 |           "red" : "0x13"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardDarkFont.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.796",
 9 |           "green" : "0.667",
10 |           "red" : "0.596"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "light"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "display-p3",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0x2E",
27 |           "green" : "0x26",
28 |           "red" : "0x22"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     },
33 |     {
34 |       "appearances" : [
35 |         {
36 |           "appearance" : "luminosity",
37 |           "value" : "dark"
38 |         }
39 |       ],
40 |       "color" : {
41 |         "color-space" : "display-p3",
42 |         "components" : {
43 |           "alpha" : "1.000",
44 |           "blue" : "0.796",
45 |           "green" : "0.667",
46 |           "red" : "0.596"
47 |         }
48 |       },
49 |       "idiom" : "universal"
50 |     }
51 |   ],
52 |   "info" : {
53 |     "author" : "xcode",
54 |     "version" : 1
55 |   }
56 | }
57 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardDarkLight.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.180",
 9 |           "green" : "0.149",
10 |           "red" : "0.133"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardEmerald.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x61",
 9 |           "green" : "0xBC",
10 |           "red" : "0x78"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardEmeraldFont.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.380",
 9 |           "green" : "0.737",
10 |           "red" : "0.471"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "light"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "display-p3",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0x57",
27 |           "green" : "0x95",
28 |           "red" : "0x6C"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     },
33 |     {
34 |       "appearances" : [
35 |         {
36 |           "appearance" : "luminosity",
37 |           "value" : "dark"
38 |         }
39 |       ],
40 |       "color" : {
41 |         "color-space" : "srgb",
42 |         "components" : {
43 |           "alpha" : "1.000",
44 |           "blue" : "0.380",
45 |           "green" : "0.737",
46 |           "red" : "0.471"
47 |         }
48 |       },
49 |       "idiom" : "universal"
50 |     }
51 |   ],
52 |   "info" : {
53 |     "author" : "xcode",
54 |     "version" : 1
55 |   }
56 | }
57 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardEmeraldLight.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x61",
 9 |           "green" : "0xBC",
10 |           "red" : "0x78"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardGray.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.505",
 9 |           "green" : "0.469",
10 |           "red" : "0.416"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardGrayFont.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.306",
 9 |           "green" : "0.284",
10 |           "red" : "0.252"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "dark"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "display-p3",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0.793",
27 |           "green" : "0.735",
28 |           "red" : "0.654"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     }
33 |   ],
34 |   "info" : {
35 |     "author" : "xcode",
36 |     "version" : 1
37 |   }
38 | }
39 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardGrayLight.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.505",
 9 |           "green" : "0.469",
10 |           "red" : "0.416"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardLightBlue.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x9D",
 9 |           "green" : "0x7B",
10 |           "red" : "0x45"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardLightBlueFont.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.880",
 9 |           "green" : "0.692",
10 |           "red" : "0.463"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "light"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "display-p3",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0x9D",
27 |           "green" : "0x7B",
28 |           "red" : "0x45"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     },
33 |     {
34 |       "appearances" : [
35 |         {
36 |           "appearance" : "luminosity",
37 |           "value" : "dark"
38 |         }
39 |       ],
40 |       "color" : {
41 |         "color-space" : "display-p3",
42 |         "components" : {
43 |           "alpha" : "1.000",
44 |           "blue" : "0.880",
45 |           "green" : "0.692",
46 |           "red" : "0.463"
47 |         }
48 |       },
49 |       "idiom" : "universal"
50 |     }
51 |   ],
52 |   "info" : {
53 |     "author" : "xcode",
54 |     "version" : 1
55 |   }
56 | }
57 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardLightBlueLight.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.880",
 9 |           "green" : "0.692",
10 |           "red" : "0.463"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardRed.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x3C",
 9 |           "green" : "0x39",
10 |           "red" : "0xA8"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardRedFont.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x52",
 9 |           "green" : "0x4E",
10 |           "red" : "0xE4"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "light"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "display-p3",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0x3C",
27 |           "green" : "0x39",
28 |           "red" : "0xA8"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     },
33 |     {
34 |       "appearances" : [
35 |         {
36 |           "appearance" : "luminosity",
37 |           "value" : "dark"
38 |         }
39 |       ],
40 |       "color" : {
41 |         "color-space" : "display-p3",
42 |         "components" : {
43 |           "alpha" : "1.000",
44 |           "blue" : "0.499",
45 |           "green" : "0.490",
46 |           "red" : "1.000"
47 |         }
48 |       },
49 |       "idiom" : "universal"
50 |     }
51 |   ],
52 |   "info" : {
53 |     "author" : "xcode",
54 |     "version" : 1
55 |   }
56 | }
57 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardRedLight.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x52",
 9 |           "green" : "0x4E",
10 |           "red" : "0xE4"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardThistle.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x9A",
 9 |           "green" : "0x68",
10 |           "red" : "0x92"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardThistleFont.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.835",
 9 |           "green" : "0.737",
10 |           "red" : "0.816"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "light"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "display-p3",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0x9A",
27 |           "green" : "0x68",
28 |           "red" : "0x92"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     },
33 |     {
34 |       "appearances" : [
35 |         {
36 |           "appearance" : "luminosity",
37 |           "value" : "dark"
38 |         }
39 |       ],
40 |       "color" : {
41 |         "color-space" : "srgb",
42 |         "components" : {
43 |           "alpha" : "1.000",
44 |           "blue" : "0xD4",
45 |           "green" : "0xBB",
46 |           "red" : "0xD0"
47 |         }
48 |       },
49 |       "idiom" : "universal"
50 |     }
51 |   ],
52 |   "info" : {
53 |     "author" : "xcode",
54 |     "version" : 1
55 |   }
56 | }
57 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardThistleLight.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0xD4",
 9 |           "green" : "0xBB",
10 |           "red" : "0xD0"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardYellow.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "display-p3",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x44",
 9 |           "green" : "0xA5",
10 |           "red" : "0xC5"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardYellowFont.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.188",
 9 |           "green" : "0.776",
10 |           "red" : "0.973"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     },
15 |     {
16 |       "appearances" : [
17 |         {
18 |           "appearance" : "luminosity",
19 |           "value" : "light"
20 |         }
21 |       ],
22 |       "color" : {
23 |         "color-space" : "display-p3",
24 |         "components" : {
25 |           "alpha" : "1.000",
26 |           "blue" : "0.280",
27 |           "green" : "0.529",
28 |           "red" : "0.676"
29 |         }
30 |       },
31 |       "idiom" : "universal"
32 |     },
33 |     {
34 |       "appearances" : [
35 |         {
36 |           "appearance" : "luminosity",
37 |           "value" : "dark"
38 |         }
39 |       ],
40 |       "color" : {
41 |         "color-space" : "srgb",
42 |         "components" : {
43 |           "alpha" : "1.000",
44 |           "blue" : "0.188",
45 |           "green" : "0.776",
46 |           "red" : "0.973"
47 |         }
48 |       },
49 |       "idiom" : "universal"
50 |     }
51 |   ],
52 |   "info" : {
53 |     "author" : "xcode",
54 |     "version" : 1
55 |   }
56 | }
57 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/cardYellowLight.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0.188",
 9 |           "green" : "0.776",
10 |           "red" : "0.973"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/exportCardBackground.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x1E",
 9 |           "green" : "0x1C",
10 |           "red" : "0x1C"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/exportCardSeperator.colorset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "colors" : [
 3 |     {
 4 |       "color" : {
 5 |         "color-space" : "srgb",
 6 |         "components" : {
 7 |           "alpha" : "1.000",
 8 |           "blue" : "0x3B",
 9 |           "green" : "0x39",
10 |           "red" : "0x39"
11 |         }
12 |       },
13 |       "idiom" : "universal"
14 |     }
15 |   ],
16 |   "info" : {
17 |     "author" : "xcode",
18 |     "version" : 1
19 |   }
20 | }
21 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/github-logo.imageset/Contents.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "images" : [
 3 |     {
 4 |       "filename" : "github-mark.svg",
 5 |       "idiom" : "universal",
 6 |       "scale" : "1x"
 7 |     },
 8 |     {
 9 |       "idiom" : "universal",
10 |       "scale" : "2x"
11 |     },
12 |     {
13 |       "idiom" : "universal",
14 |       "scale" : "3x"
15 |     }
16 |   ],
17 |   "info" : {
18 |     "author" : "xcode",
19 |     "version" : 1
20 |   },
21 |   "properties" : {
22 |     "template-rendering-intent" : "template"
23 |   }
24 | }
25 | 
--------------------------------------------------------------------------------
/SplitBill/Assets.xcassets/github-logo.imageset/github-mark.svg:
--------------------------------------------------------------------------------
1 | 
--------------------------------------------------------------------------------
/SplitBill/BlurTop.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  BlurTop.swift
 3 | //  SplitBill
 4 | //
 5 | 
 6 | import SwiftUI
 7 | 
 8 | struct BlurTop: View {
 9 |     var body: some View {
10 |         GeometryReader { geo in
11 |             if geo.safeAreaInsets.top > 0 {
12 |                 Color.clear
13 |                     .background(.thickMaterial)
14 |                     .mask {
15 |                         LinearGradient(gradient: Gradient(colors: [.black, .clear]),
16 |                                        startPoint: .top,
17 |                                        endPoint: .bottom)
18 |                     }
19 |                     .frame(height: geo.safeAreaInsets.top + 25)
20 |                     .edgesIgnoringSafeArea(.top)
21 |             }
22 |         }
23 |     }
24 | }
25 | 
--------------------------------------------------------------------------------
/SplitBill/ButtonsOverlayLogic.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ButtonsOverlayLogic.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 16.08.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | import LinkPresentation
11 | 
12 | extension ButtonsOverlayView {
13 |     func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
14 |         return ""
15 |     }
16 | 
17 |     func activityViewController(_ activityViewController: UIActivityViewController,
18 |                                 itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
19 |         return nil
20 |     }
21 | 
22 |     func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
23 |         let image = UIImage(named: "YourImage")!
24 |         let imageProvider = NSItemProvider(object: image)
25 |         let metadata = LPLinkMetadata()
26 |         metadata.imageProvider = imageProvider
27 |         return metadata
28 |     }
29 | }
30 | 
31 | struct ImageModel: Transferable {
32 |     let getImage: () async -> UIImage?
33 | 
34 |     static var transferRepresentation: some TransferRepresentation {
35 |         DataRepresentation(exportedContentType: .jpeg) { item in
36 |             try await { () -> Data in
37 |                 if let img = await item.getImage(), let jpeg = img.jpegData(compressionQuality: 0.6) {
38 |                     return jpeg
39 |                 } else {
40 |                     throw ExportImageError.noImageFound
41 |                 }
42 |             }()
43 |         }
44 |     }
45 | }
46 | 
--------------------------------------------------------------------------------
/SplitBill/ButtonsOverlayView.swift:
--------------------------------------------------------------------------------
 1 | import Foundation
 2 | import SwiftUI
 3 | 
 4 | struct ButtonsOverlayView: View {
 5 |     @AppStorage("successfulUserActionCount") var successfulUserActionCount: Int = 0
 6 |     @Binding var showImagePicker: Bool
 7 |     @Binding var showScanner: Bool
 8 |     @Binding var showSettings: Bool
 9 |     @Binding var showEditCardSheet: Bool
10 | 
11 |     @State var hasBeenSubtracted = false
12 |     var showCardsView: Bool = false
13 | 
14 |     let size: CGFloat = 45
15 | 
16 |     var body: some View {
17 |         VStack {
18 |             HStack {
19 |                 HStack(alignment: .top) {
20 |                     MenuOptionsView(
21 |                         showScanner: $showScanner,
22 |                         showImagePicker: $showImagePicker,
23 |                         showSettings: $showSettings,
24 |                         hasBeenSubtracted: $hasBeenSubtracted,
25 |                         size: size
26 |                     )
27 |                     Spacer()
28 |                     UndoRedoStackView(size: size)
29 |                 }
30 |             }
31 |             .foregroundColor(Color.foregroundColor)
32 |             .padding(.horizontal)
33 |             Spacer()
34 |             if showCardsView {
35 |                 CardsView(showEditCardSheet: $showEditCardSheet)
36 |             }
37 |         }
38 |     }
39 | 
40 |     func handleSuccessfulUserActionCount() {
41 |         if !hasBeenSubtracted {
42 |             successfulUserActionCount -= 1
43 |             hasBeenSubtracted = true
44 |         }
45 |     }
46 | }
47 | 
48 | #Preview {
49 |     ButtonsOverlayView(showImagePicker: .constant(false),
50 |                        showScanner: .constant(false),
51 |                        showSettings: .constant(false),
52 |                        showEditCardSheet: .constant(false),
53 |                        showCardsView: false)
54 |         .background(.black)
55 |         .environmentObject(ContentViewModel())
56 | }
57 | 
--------------------------------------------------------------------------------
/SplitBill/CalcTextField/CalcKeyboard.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  CalcKeyboard.swift
  3 | //  ScorePad
  4 | //
  5 | //  Created by fer0n on 12.07.21.
  6 | //
  7 | 
  8 | import UIKit
  9 | import SwiftUI
 10 | 
 11 | // The view controller will adopt this protocol (delegate)
 12 | // and thus must contain the keyWasTapped method
 13 | protocol KeyboardDelegate: AnyObject {
 14 |     func keyWasTapped(action: KeyboardAction, character: String)
 15 | }
 16 | 
 17 | class CalcKeyboard: UIView {
 18 | 
 19 |     // This variable will be set as the view controller so that
 20 |     // the keyboard can send messages to the view controller.
 21 |     var delegate: KeyboardDelegate?
 22 |     var accentColor: UIColor
 23 |     var bgColor: UIColor
 24 |     var initialDeleteTimer: Timer?
 25 |     var continuousDeleteTimer: Timer?
 26 | 
 27 |     // MARK: - keyboard initialization
 28 |     required init?(coder aDecoder: NSCoder) {
 29 |         self.accentColor = UIColor.systemBlue
 30 |         self.bgColor = UIColor.systemBlue
 31 |         super.init(coder: aDecoder)
 32 |         initializeSubviews()
 33 |     }
 34 | 
 35 |     override init(frame: CGRect) {
 36 |         self.accentColor = UIColor.systemBlue
 37 |         self.bgColor = UIColor.systemBlue
 38 |         super.init(frame: frame)
 39 |         initializeSubviews()
 40 |     }
 41 | 
 42 |     func setAccentColor(color: UIColor,
 43 |                         bgColor: UIColor) {
 44 |         self.accentColor = color
 45 |         self.bgColor = bgColor
 46 |     }
 47 | 
 48 |     func initializeSubviews() {
 49 |         let xibFileName = "CalcKeyboard" // xib extention not included
 50 |         if let view = Bundle.main.loadNibNamed(xibFileName, owner: self, options: nil)![0] as? UIView {
 51 |             self.addSubview(view)
 52 |             view.frame = self.bounds
 53 |         }
 54 |     }
 55 | 
 56 |     override func willMove(toWindow newWindow: UIWindow?) {
 57 |         super.willMove(toWindow: newWindow)
 58 |         if newWindow == nil {
 59 |             // UIView disappear
 60 |         } else {
 61 |             // UIView appear
 62 |             updateColor()
 63 |         }
 64 |     }
 65 | 
 66 |     @objc func startContinuousDelete() {
 67 |         self.delegate?.keyWasTapped(action: .delete, character: "")
 68 | 
 69 |         // Start the continuous delete timer for faster deletion
 70 |         continuousDeleteTimer = Timer.scheduledTimer(timeInterval: 0.1,
 71 |                                                      target: self,
 72 |                                                      selector: #selector(performDeleteAction),
 73 |                                                      userInfo: nil,
 74 |                                                      repeats: true)
 75 |     }
 76 | 
 77 |     @objc func performDeleteAction() {
 78 |         self.delegate?.keyWasTapped(action: .delete, character: "")
 79 |     }
 80 | 
 81 |     func updateColor() {
 82 |         let allSubViews = self.allSubviews
 83 |         for view in allSubViews {
 84 |             if let button = view as? UIButton {
 85 |                 switch button.tag {
 86 |                 case KeyboardAction.insertNumber.rawValue,
 87 |                      KeyboardAction.point.rawValue:
 88 |                     button.setTitleColor(self.accentColor, for: .normal)
 89 |                 default:
 90 |                     button.backgroundColor = self.bgColor
 91 |                 }
 92 |             }
 93 |         }
 94 |     }
 95 | 
 96 |     @IBAction func touchDown(_ sender: UIButton) {
 97 |         switch sender.tag {
 98 |         case KeyboardAction.delete.rawValue:
 99 |             performDeleteAction()
100 |             initialDeleteTimer = Timer.scheduledTimer(timeInterval: 0.5,
101 |                                                       target: self,
102 |                                                       selector: #selector(startContinuousDelete),
103 |                                                       userInfo: nil,
104 |                                                       repeats: false)
105 |         default:
106 |             return
107 |         }
108 |     }
109 | 
110 |     @IBAction func touchUp(_ sender: UIButton) {
111 |         initialDeleteTimer?.invalidate()
112 |         continuousDeleteTimer?.invalidate()
113 |         initialDeleteTimer = nil
114 |         continuousDeleteTimer = nil
115 |     }
116 | 
117 |     // MARK: - Button actions from .xib file
118 |     @IBAction func calcKeyboard(sender: UIButton) {
119 |         // When a button is tapped, send that information to the
120 |         // delegate (ie, the view controller)
121 |         let text = sender.titleLabel?.text
122 |         self.delegate?.keyWasTapped(action: KeyboardAction(rawValue: sender.tag) ?? KeyboardAction.insertNumber,
123 |                                     character: text ?? "")
124 |     }
125 | }
126 | 
127 | enum KeyboardAction: Int {
128 |     case insertNumber = 0
129 |     case submit = 1
130 |     case delete = 2
131 |     case point = 3
132 |     case insertOperand = 4
133 | }
134 | 
--------------------------------------------------------------------------------
/SplitBill/CalcTextField/CalcKeyboardViewController.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  CalcKeyboardViewController.swift
 3 | //  ScorePad
 4 | //
 5 | //  Created by fer0n on 13.07.21.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | 
11 | class CalcKeyboardViewController: UIViewController, KeyboardDelegate {
12 | 
13 |     let generator = UIImpactFeedbackGenerator(style: .light)
14 |     let notificationGenerator = UINotificationFeedbackGenerator()
15 | 
16 |     var textField: UITextField?
17 |     var onSubmit: ((Double?) -> Void)?
18 | 
19 |     override func viewDidLoad() {
20 |         super.viewDidLoad()
21 | 
22 |         generator.prepare()
23 |     }
24 | 
25 |     // required method for keyboard delegate protocol
26 |     func keyWasTapped(action: KeyboardAction, character: String) {
27 |         guard let textField = textField else { return }
28 | 
29 |         switch action {
30 |         case .insertNumber:
31 |             textField.insertText(character)
32 |         case .delete:
33 |             textField.deleteBackward()
34 |         case .submit:
35 |             self.notificationGenerator.prepare()
36 |             self.evaluateExpression()
37 |         case .point:
38 |             textField.insertText(".")
39 |         case .insertOperand:
40 |             textField.insertText(character)
41 |         }
42 |     }
43 | 
44 |     func evaluateExpression() {
45 |         guard let textField = textField,
46 |               let text = textField.text,
47 |               let callback = self.onSubmit else { return }
48 |         if text.isEmpty {
49 |             self.generator.impactOccurred()
50 |             callback(0)
51 |             textField.endEditing(true)
52 |             return
53 |         }
54 |         // check if expression is just a number
55 |         if let res = Double(text) {
56 |             self.generator.impactOccurred()
57 |             callback(res)
58 |             textField.endEditing(true)
59 |         } else {
60 | 
61 |             // if not evaluate the expression
62 |             var numericExpression = text
63 |             numericExpression = numericExpression.replacingOccurrences(of: "÷", with: "/")
64 |             numericExpression = numericExpression.replacingOccurrences(of: "×", with: "*")
65 | 
66 |             do {
67 |                 try ObjC.catchException {
68 |                     // calls that might throw an NSException
69 |                     var expression = NSExpression(format: numericExpression)
70 |                     expression = expression.toFloatingPointDivision()
71 |                     if let result = expression.expressionValue(with: nil, context: nil) as? Double {
72 | 
73 |                         // set result
74 |                         callback(result)
75 |                         textField.endEditing(true)
76 |                         textField.text = result.clean
77 |                         // set focus
78 |                         self.generator.impactOccurred()
79 |                     } else {
80 |                         print("failed")
81 |                     }
82 |                 }
83 |             } catch {
84 |                 print("Calc expression \(numericExpression) can't be resolved: \(error)")
85 |                 self.notificationGenerator.notificationOccurred(.error)
86 |                 withAnimation(Animation.default) {
87 |                     callback(nil)
88 |                 }
89 |             }
90 |         }
91 |     }
92 | }
93 | 
--------------------------------------------------------------------------------
/SplitBill/CalcTextField/CalcTextField.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  CalcTextField.swift
  3 | //  ScorePad
  4 | //
  5 | //  Created by fer0n on 12.07.21.
  6 | //
  7 | 
  8 | import Foundation
  9 | import SwiftUI
 10 | 
 11 | struct CalcTextField: UIViewRepresentable {
 12 | 
 13 |     let textField: UITextField
 14 |     var placeholder: String
 15 |     @Binding var text: String
 16 |     let onSubmit: (Double?) -> Void
 17 |     let onEditingChanged: (Bool) -> Void
 18 |     var accentColor: UIColor
 19 |     var bgColor: UIColor
 20 |     var textColor: UIColor
 21 |     var font: UIFont
 22 |     var alignment: NSTextAlignment
 23 | 
 24 |     init(_ placeholder: String,
 25 |          text: Binding,
 26 |          onSubmit: @escaping (Double?) -> Void,
 27 |          onEditingChanged: @escaping (Bool) -> Void,
 28 |          accentColor: UIColor,
 29 |          bgColor: UIColor,
 30 |          textColor: UIColor,
 31 |          font: UIFont = .rounded(ofSize: 18, weight: .medium),
 32 |          alignment: NSTextAlignment = .left) {
 33 |         self.placeholder = placeholder
 34 |         self._text = text
 35 |         self.onSubmit = onSubmit
 36 |         self.onEditingChanged = onEditingChanged
 37 |         self.accentColor = accentColor
 38 |         self.bgColor = bgColor
 39 |         self.textColor = textColor
 40 |         self.font = font
 41 |         self.alignment = alignment
 42 |         self.textField = UITextField()
 43 |     }
 44 | 
 45 |     func makeUIView(context: UIViewRepresentableContext) -> UITextField {
 46 |         let keyboardView = CalcKeyboard(frame: CGRect(x: 0, y: 0, width: 0, height: 320))
 47 |         let delegate = CalcKeyboardViewController()
 48 |         delegate.textField = self.textField
 49 |         delegate.onSubmit = self.onSubmit
 50 |         keyboardView.setAccentColor(color: accentColor, bgColor: bgColor)
 51 |         keyboardView.delegate = delegate
 52 | 
 53 |         textField.inputView = keyboardView
 54 |         textField.textAlignment = alignment
 55 |         textField.font = font
 56 |         textField.placeholder = placeholder
 57 |         textField.delegate = context.coordinator
 58 |         textField.textColor = textColor
 59 | 
 60 |         textField.addTarget(context.coordinator,
 61 |                             action: #selector(context.coordinator.textChanged),
 62 |                             for: .editingChanged)
 63 |         return textField
 64 |     }
 65 | 
 66 |     func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext) {
 67 |         uiView.font = self.font
 68 |         uiView.textColor = self.textColor
 69 |         if uiView.text != text {
 70 |             uiView.text = text
 71 |         }
 72 |     }
 73 | 
 74 |     func makeCoordinator() -> CalcTextField.Coordinator {
 75 |         Coordinator(parent: self, onEditingChanged: self.onEditingChanged)
 76 |     }
 77 | 
 78 |     class Coordinator: NSObject, UITextFieldDelegate {
 79 |         var parent: CalcTextField
 80 |         let onEditingChanged: (Bool) -> Void
 81 | 
 82 |         init(parent: CalcTextField,
 83 |              onEditingChanged: @escaping (Bool) -> Void) {
 84 |             self.parent = parent
 85 |             self.onEditingChanged = onEditingChanged
 86 |         }
 87 | 
 88 |         func textFieldDidBeginEditing(_ textField: UITextField) {
 89 |             self.onEditingChanged(true)
 90 |         }
 91 | 
 92 |         func textFieldDidEndEditing(_ textField: UITextField) {
 93 |             self.onEditingChanged(false)
 94 |         }
 95 | 
 96 |         @objc func textChanged(_ sender: UITextField) {
 97 |             guard let text = sender.text else { return }
 98 |             self.parent.text = text
 99 |         }
100 |     }
101 | }
102 | 
--------------------------------------------------------------------------------
/SplitBill/CardListItem.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  CardListItem.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 21.04.24.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | 
11 | struct CardListItem: View {
12 |     var card: Binding
13 |     @EnvironmentObject var cvm: ContentViewModel
14 |     var disableEdit: Bool = false
15 | 
16 |     @State private var showColorPicker = false
17 |     @State var selectedColor: ColorKeys?
18 | 
19 |     var body: some View {
20 |         HStack {
21 |             (
22 |                 card.wrappedValue.isChosen
23 |                     ? Image(systemName: "checkmark.circle.fill")
24 |                     : Image(systemName: "circle")
25 |             )
26 |             .imageScale(.large)
27 |             .onTapGesture {
28 |                 cvm.toggleChosen(card.id)
29 |             }
30 | 
31 |             TextField(card.name.wrappedValue, text: card.name)
32 |                 .disabled(disableEdit)
33 |                 .onChange(of: card.wrappedValue.name) {
34 |                     cvm.saveCardDataDebounced()
35 |                 }
36 |                 .submitLabel(.done)
37 | 
38 |             Spacer()
39 |             if !disableEdit {
40 |                 Circle()
41 |                     .strokeBorder(card.wrappedValue.color.font, lineWidth: 1)
42 |                     .background(Circle().foregroundColor(card.wrappedValue.color.dark))
43 |                     .frame(width: 25, height: 25)
44 |                     .onTapGesture {
45 |                         selectedColor = card.wrappedValue.colorKey
46 |                         showColorPicker = true
47 |                     }
48 |                     .popover(isPresented: $showColorPicker) {
49 |                         CustomColorPicker(isPresented: $showColorPicker,
50 |                                           card: card,
51 |                                           selectedColor: $selectedColor,
52 |                                           setNewColor: setNewColor)
53 |                             .padding(10)
54 |                             .presentationCompactAdaptation(.popover)
55 |                     }
56 |             }
57 |         }
58 |         .alignmentGuide(.listRowSeparatorLeading) { _ in
59 |             return 0
60 |         }
61 |     }
62 | 
63 |     func setNewColor(card: Card, color: ColorKeys) {
64 |         cvm.setCardColor(card, color: color)
65 |     }
66 | }
67 | 
--------------------------------------------------------------------------------
/SplitBill/CardModel.swift:
--------------------------------------------------------------------------------
  1 | import Foundation
  2 | import SwiftUI
  3 | 
  4 | // MARK: Card
  5 | enum CardType: Int {
  6 |     case total
  7 |     case normal
  8 | }
  9 | 
 10 | enum CardCodingKeys: CodingKey {
 11 |     case id
 12 |     case name
 13 |     case isChosen
 14 |     case emptyText
 15 |     case cardType
 16 |     case color
 17 |     case transactionIds
 18 | }
 19 | 
 20 | struct Card: Identifiable, Hashable, Codable {
 21 |     let id: UUID
 22 |     private var rawName: String
 23 |     var isActive: Bool
 24 |     var isChosen: Bool
 25 |     var transactionIds: [UUID] = []
 26 |     var emptyText: String = "empty"
 27 |     var cardType: CardType = .normal
 28 | 
 29 |     var colorKey: ColorKeys = .neutralGray
 30 | 
 31 |     var color: CardColor {
 32 |         get {
 33 |             CardColor.get(colorKey)
 34 |         }
 35 |         set {
 36 |             self.colorKey = newValue.id
 37 |         }
 38 |     }
 39 | 
 40 |     var name: String {
 41 |         get {
 42 |             switch cardType {
 43 |             case .total:
 44 |                 return String(localized: "sum")
 45 |             case .normal:
 46 |                 return rawName
 47 |             }
 48 |         }
 49 |         set {
 50 |             rawName = newValue
 51 |         }
 52 |     }
 53 | 
 54 |     var stringName: String {
 55 |         if cardType != .total
 56 |             || !(
 57 |                 ContentViewModel.totalTransactionId != nil
 58 |                     && transactionIds.contains(ContentViewModel.totalTransactionId!)
 59 |             ) {
 60 |             return name
 61 |         }
 62 |         return String(localized: "remaining")
 63 |     }
 64 | 
 65 |     var identifier: String {
 66 |         return self.id.uuidString
 67 |     }
 68 | 
 69 |     init(name: String, isSelected: Bool = false, transactionIds: [UUID] = [], emptyText: String? = nil) {
 70 |         self.id = UUID()
 71 |         self.rawName = name
 72 |         self.isActive = isSelected
 73 |         self.transactionIds = transactionIds
 74 |         self.isChosen = false
 75 |         if emptyText != nil {
 76 |             self.emptyText = emptyText!
 77 |         }
 78 |     }
 79 | 
 80 |     init(_ cardType: CardType) {
 81 |         switch cardType {
 82 |         case .total:
 83 |             self.init(name: "sum", emptyText: "total")
 84 |             self.colorKey = .neutralDark
 85 |         case .normal:
 86 |             self.init(name: "unnamed")
 87 |         }
 88 |         self.cardType = cardType
 89 |     }
 90 | 
 91 |     init(from decoder: Decoder) throws {
 92 |         let container = try decoder.container(keyedBy: CardCodingKeys.self)
 93 |         rawName = try container.decode(String.self, forKey: .name)
 94 |         isChosen = try container.decode(Bool.self, forKey: .isChosen)
 95 |         let rawCardType = try container.decode(Int.self, forKey: .cardType)
 96 |         cardType = CardType(rawValue: rawCardType) ?? .normal
 97 |         isActive = false
 98 |         do {
 99 |             id = try container.decode(UUID.self, forKey: .id)
100 |         } catch {
101 |             id = UUID()
102 |             print("no id found")
103 |         }
104 |         do {
105 |             transactionIds = try container.decode([UUID].self, forKey: .transactionIds)
106 |         } catch {
107 |             print("couldn't load transactionIds: \(error)")
108 |         }
109 |         do {
110 |             let raw = try container.decode(Int.self, forKey: .color)
111 |             colorKey = ColorKeys(rawValue: raw)!
112 |         } catch {
113 |             if cardType == .total {
114 |                 colorKey = .neutralDark
115 |             } else {
116 |                 colorKey = ColorKeys.allCases.randomElement()!
117 |             }
118 |         }
119 |     }
120 | 
121 |     func encode(to encoder: Encoder) throws {
122 |         var container = encoder.container(keyedBy: CardCodingKeys.self)
123 |         try container.encode(id, forKey: .id)
124 |         try container.encode(name, forKey: .name)
125 |         try container.encode(isChosen, forKey: .isChosen)
126 |         let rawCardType = cardType.rawValue
127 |         try container.encode(rawCardType, forKey: .cardType)
128 |         try container.encode(transactionIds, forKey: .transactionIds)
129 |         let raw = colorKey.rawValue
130 |         try container.encode(raw, forKey: .color)
131 |     }
132 | 
133 |     mutating func removeTransaction(_ transaction: Transaction) {
134 |         transactionIds = transactionIds.filter { $0 != transaction.id }
135 |     }
136 | 
137 |     mutating func clearTransactions() {
138 |         transactionIds = []
139 |     }
140 | 
141 |     mutating func addTransactionId(_ transactionId: UUID) {
142 |         transactionIds.append(transactionId)
143 |     }
144 | 
145 |     static func == (lhs: Card, rhs: Card) -> Bool {
146 |         lhs.id == rhs.id
147 |     }
148 | 
149 |     public func hash(into hasher: inout Hasher) {
150 |         return hasher.combine(identifier)
151 |     }
152 | }
153 | 
--------------------------------------------------------------------------------
/SplitBill/CardTapInteractionModifier.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  CardTapInteractionModifier.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 29.03.25.
 6 | //
 7 | 
 8 | import SwiftUI
 9 | 
10 | struct CardTapInteractionModifier: ViewModifier {
11 |     @EnvironmentObject var cvm: ContentViewModel
12 |     @Binding var showTransactions: Bool
13 | 
14 |     let card: Card
15 |     let isSelected: Bool
16 |     var toggleTransaction: () -> Void
17 |     let handleAutoScroll: () -> Void
18 | 
19 |     @State var dragCancelled = false
20 |     @State var lastTapTime: Date?
21 | 
22 |     func body(content: Content) -> some View {
23 |         content
24 |             .gesture(
25 |                 TapGesture(count: 2)
26 |                     .onEnded {
27 |                         handleDoubleTap()
28 |                     }
29 |                     .simultaneously(with: TapGesture(count: 1)
30 |                                         .onEnded {
31 |                                             handleSingleTap()
32 |                                         }
33 |                     )
34 |             )
35 |     }
36 | 
37 |     func hideTransactions() {
38 |         if showTransactions {
39 |             toggleTransaction()
40 |         }
41 |     }
42 | 
43 |     func handleSingleTap() {
44 |         let isLastCard = cvm.isLastChosenCard(card)
45 |         withAnimation(.easeInOut(duration: 0.2)) {
46 |             cvm.previouslyActiveCardsIds = cvm.activeCardsIds
47 |             if cvm.activeCardsIds.count > 1 {
48 |                 cvm.setActiveCard(card.id, multiple: false)
49 |                 return
50 |             }
51 |             if isSelected {
52 |                 if !showTransactions && isLastCard {
53 |                     handleAutoScroll()
54 |                 }
55 |                 toggleTransaction()
56 |             } else {
57 |                 cvm.setActiveCard(card.id)
58 |                 handleAutoScroll()
59 |             }
60 |         }
61 |     }
62 | 
63 |     func handleDoubleTap() {
64 |         if card.cardType == .total {
65 |             return
66 |         }
67 |         cvm.restoreActiveState(cvm.previouslyActiveCardsIds)
68 |         if cvm.previouslyActiveCardsIds.first(where: { $0 == card.id }) != nil {
69 |             cvm.setActiveCard(card.id, value: false, multiple: true)
70 |         } else {
71 |             cvm.setActiveCard(card.id, value: true, multiple: true)
72 |         }
73 |         if cvm.activeCardsIds.count > 1 {
74 |             hideTransactions()
75 |         }
76 |     }
77 | }
78 | 
79 | extension View {
80 |     func cardTapInteraction(
81 |         showTransactions: Binding,
82 |         card: Card,
83 |         isSelected: Bool,
84 |         toggleTransaction: @escaping () -> Void,
85 |         handleAutoScroll: @escaping () -> Void
86 |     ) -> some View {
87 |         self.modifier(
88 |             CardTapInteractionModifier(
89 |                 showTransactions: showTransactions,
90 |                 card: card,
91 |                 isSelected: isSelected,
92 |                 toggleTransaction: toggleTransaction,
93 |                 handleAutoScroll: handleAutoScroll
94 |             )
95 |         )
96 |     }
97 | }
98 | 
--------------------------------------------------------------------------------
/SplitBill/CardsView.swift:
--------------------------------------------------------------------------------
 1 | import SwiftUI
 2 | 
 3 | struct CardsView: View {
 4 |     @EnvironmentObject var cvm: ContentViewModel
 5 |     @State var showCardTransactions = false
 6 |     @Binding var showEditCardSheet: Bool
 7 | 
 8 |     var body: some View {
 9 |         ScrollViewReader { scrollView in
10 |             ScrollView(.horizontal) {
11 |                 HStack(alignment: .bottom, spacing: 6) {
12 |                     addCardsButton
13 |                     CardSpacer()
14 |                     ForEach(cvm.chosenNormalCards, id: \.self) { card in
15 |                         singleCardListItem(card, scrollView: scrollView)
16 |                     }
17 |                     if !cvm.specialCards.isEmpty {
18 |                         CardSpacer()
19 |                         if cvm.totalCard?.isChosen ?? false {
20 |                             singleCardListItem(cvm.totalCard!, scrollView: scrollView)
21 |                                 .id(cvm.totalCard)
22 |                         }
23 |                     }
24 |                 }
25 |                 .padding([.leading, .trailing], 15)
26 |                 .padding(.bottom, 12)
27 |             }
28 |             .scrollIndicators(.hidden)
29 |         }
30 |     }
31 | 
32 |     func handleAutoScroll(_ scrollView: ScrollViewProxy, card: Card) {
33 |         scrollView.scrollTo(card, anchor: .center)
34 |     }
35 | 
36 |     func toggleTransactions() {
37 |         showCardTransactions.toggle()
38 |     }
39 | 
40 |     func singleCardListItem(_ card: Card, scrollView: ScrollViewProxy) -> some View {
41 |         SingleCardView(showTransactions: $showCardTransactions,
42 |                        showEditCardSheet: $showEditCardSheet,
43 |                        card: card,
44 |                        isSelected: card.isActive || cvm.isActiveCard(card),
45 |                        toggleTransaction: toggleTransactions,
46 |                        handleAutoScroll: { handleAutoScroll(scrollView, card: card) })
47 |             .transition(.scale)
48 |     }
49 | 
50 |     var addCardsButton: some View {
51 |         Button {
52 |             showEditCardSheet = true
53 |         } label: {
54 |             Image(systemName: "plus")
55 |                 .font(.system(size: 20))
56 |                 .fontWeight(.bold)
57 |         }
58 |         .frame(width: 50, height: 50)
59 |         .cardBackground(false, .black, in: .circle)
60 |     }
61 | }
62 | 
63 | struct CardSpacer: View {
64 |     var body: some View {
65 |         Spacer()
66 |             .frame(width: 5, height: 5)
67 |     }
68 | }
69 | 
70 | #Preview {
71 |     @Previewable @State var cvm = ContentViewModel.preview
72 | 
73 |     CardsView(
74 |         showEditCardSheet: .constant(
75 |             false
76 |         )
77 |     )
78 |     .background(.black)
79 |     .environmentObject(ContentViewModel())
80 | }
81 | 
--------------------------------------------------------------------------------
/SplitBill/ContentView.swift:
--------------------------------------------------------------------------------
  1 | import SwiftUI
  2 | import PhotosUI
  3 | import UniformTypeIdentifiers
  4 | import StoreKit
  5 | 
  6 | struct ContentView: View {
  7 |     @AppStorage("startupItem") var startupItem: StartupItem = .scanner
  8 |     @AppStorage("successfulUserActionCount") var successfulUserActionCount: Int = 0
  9 | 
 10 |     @Environment(\.undoManager) var undoManager
 11 |     @Environment(\.scenePhase) var scenePhase
 12 |     @Environment(Alerter.self) var alerter
 13 |     @Environment(\.requestReview) var requestReview
 14 |     @EnvironmentObject var cvm: ContentViewModel
 15 | 
 16 |     @State var showScanner: Bool = false
 17 |     @State var showEditCardSheet: Bool = false
 18 |     @State var showImagePicker: Bool = false
 19 |     @State var showSettings: Bool = false
 20 |     @State var isLoadingReplacingImage: Bool = false
 21 | 
 22 |     @State var showReplaceImageAlert: Bool = false
 23 |     @State var replacingImage: UIImage?
 24 |     @State var replacingImageIsHeic: Bool?
 25 | 
 26 |     let zoomBufferPadding: CGFloat = 500
 27 | 
 28 |     var body: some View {
 29 |         ZStack {
 30 |             Color.backgroundColor.ignoresSafeArea()
 31 | 
 32 |             ZStack {
 33 |                 if cvm.image != nil {
 34 |                     LiveTextImage(showEditCardSheet: $showEditCardSheet, zoomBufferPadding: zoomBufferPadding)
 35 |                         .ignoresSafeArea()
 36 |                         .onAppear {
 37 |                             if cvm.normalCards.count <= 0 {
 38 |                                 DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
 39 |                                     self.showEditCardSheet = true
 40 |                                 }
 41 |                             }
 42 |                         }
 43 |                 } else {
 44 |                     SelectImageView(showImagePicker: $showImagePicker, showScanner: $showScanner)
 45 |                 }
 46 |                 if isLoadingReplacingImage {
 47 |                     ProgressView()
 48 |                         .padding(.bottom, 100)
 49 |                 }
 50 | 
 51 |                 BlurTop()
 52 | 
 53 |                 ButtonsOverlayView(showImagePicker: $showImagePicker,
 54 |                                    showScanner: $showScanner,
 55 |                                    showSettings: $showSettings,
 56 |                                    showEditCardSheet: $showEditCardSheet,
 57 |                                    showCardsView: cvm.image != nil)
 58 |             }
 59 |         }
 60 |         .sheet(isPresented: $showScanner) {
 61 |             ScannerView(completion: { image in
 62 |                 if let image = image {
 63 |                     cvm.changeImage(image)
 64 |                     cvm.clearAllTransactionsAndHistory()
 65 |                 }
 66 |                 self.showScanner = false
 67 |             })
 68 |             .ignoresSafeArea()
 69 |         }
 70 |         .sheet(isPresented: $showImagePicker) {
 71 |             ImagePickerView(sourceType: .photoLibrary) { image, isHeic  in
 72 |                 cvm.changeImage(image, isHeic)
 73 |                 cvm.clearAllTransactionsAndHistory()
 74 |             }
 75 |             .ignoresSafeArea()
 76 |         }
 77 |         .editCardsSheet(show: $showEditCardSheet)
 78 |         .settingsSheet(show: $showSettings)
 79 |         .onChange(of: undoManager) {
 80 |             cvm.undoManager = undoManager
 81 |         }
 82 |         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willTerminateNotification), perform: { _ in
 83 |             cvm.handleSaveState()
 84 |         })
 85 |         .onAppear {
 86 |             cvm.undoManager = undoManager
 87 |             cvm.alerter = self.alerter
 88 |             cvm.onTransactionTap = self.handleTransactionTap
 89 |             handleOpenOnStart()
 90 |         }
 91 |         .onOpenURL { _ in
 92 |             self.isLoadingReplacingImage = true
 93 |         }
 94 |         .onChange(of: scenePhase) {
 95 |             if scenePhase == .active {
 96 |                 if !cvm.savedImageIsPreserved() {
 97 |                     _ = handleStoredImage()
 98 |                 }
 99 |             } else if scenePhase == .background {
100 |                 cvm.handleSaveState()
101 |             }
102 |         }
103 |         .onChange(of: successfulUserActionCount) {
104 |             let limit = 5
105 |             if successfulUserActionCount > limit {
106 |                 Task {
107 |                     do {
108 |                         try await Task.sleep(nanoseconds: 8_000_000_000)
109 |                         if successfulUserActionCount > limit {
110 |                             requestReview()
111 |                             successfulUserActionCount = 0
112 |                         }
113 |                     } catch {}
114 |                 }
115 |             } else if successfulUserActionCount < 0 {
116 |                 successfulUserActionCount = 0
117 |             }
118 |         }
119 |         .alert("replaceImage", isPresented: $showReplaceImageAlert) {
120 |             Button("replaceYes") {
121 |                 if let img = replacingImage {
122 |                     cvm.changeImage(img, replacingImageIsHeic)
123 |                 }
124 |             }
125 |             Button("replaceNo", role: .cancel) { }
126 |         }
127 |     }
128 | 
129 |     func handleOpenOnStart() {
130 |         let isPreservation = handleStoredImage()
131 |         if replacingImage != nil && isPreservation == false {
132 |             // avoid opening scanner/picker if an image is loaded via extension,
133 |             // do open it if the image was simply preserved
134 |             return
135 |         }
136 | 
137 |         switch startupItem {
138 |         case .nothing:
139 |             break
140 |         case .scanner:
141 |             if AVCaptureDevice.authorizationStatus(for: .video) == .authorized {
142 |                 self.showScanner = true
143 |             }
144 |         case .imagePicker:
145 |             self.showImagePicker = true
146 |         }
147 |     }
148 | 
149 |     func handleStoredImage() -> Bool? {
150 |         let info = self.cvm.consumeStoredImage()
151 |         guard let img = info.image else {
152 |             self.isLoadingReplacingImage = false
153 |             return nil
154 |         }
155 |         if cvm.image == nil {
156 |             let hasTransactions = cvm.transactions.count > 0
157 |             cvm.changeImage(img, info.isHeic, analyseTransactions: !hasTransactions)
158 |         } else {
159 |             replacingImage = info.image
160 |             replacingImageIsHeic = info.isHeic
161 |             showReplaceImageAlert = true
162 |         }
163 |         self.isLoadingReplacingImage = false
164 |         return info.isPreservation
165 |     }
166 | 
167 |     func handleTransactionTap(_ transaction: Transaction) {
168 |         withAnimation {
169 |             if !cvm.hasActiveCards { return }
170 |             if cvm.transactionLinkedInAllActiveCards(transaction) {
171 |                 cvm.removeTransaction(transaction.id, from: cvm.activeCardsIds)
172 |                 cvm.flashTransaction(transaction.id, remove: true)
173 |             } else {
174 |                 cvm.linkTransactionToActiveCards(transaction)
175 |                 if cvm.flashTransactionValue {
176 |                     cvm.flashTransaction(transaction.id)
177 |                 }
178 |             }
179 |         }
180 |     }
181 | 
182 |     func ignoreTapsAt(_ point: CGPoint) -> Bool {
183 |         return cvm.lastTapWasHitting
184 |     }
185 | 
186 |     func onGestureHasBegun() {
187 |         cvm.emptyTapTimer?.invalidate()
188 |     }
189 | }
190 | 
191 | #Preview {
192 |     ContentView()
193 |         .environment(Alerter())
194 | }
195 | 
--------------------------------------------------------------------------------
/SplitBill/CustomColorPicker.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  CustomColorPicker.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 21.04.24.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | 
11 | struct CustomColorPicker: View {
12 |     @Binding var isPresented: Bool
13 |     @Binding var card: Card
14 |     @Binding var selectedColor: ColorKeys?
15 | 
16 |     var setNewColor: (_ card: Card, _ color: ColorKeys) -> Void
17 | 
18 |     let colors = ColorKeys.allCases
19 | 
20 |     var body: some View {
21 |         ZStack {
22 |             Grid {
23 |                 GridRow {
24 |                     ForEach(0..<4) { index in
25 |                         singleColor(colors[index])
26 |                     }
27 |                 }
28 |                 GridRow {
29 |                     ForEach(4..<8) { index in
30 |                         singleColor(colors[index])
31 |                     }
32 |                 }
33 |             }
34 |         }
35 |     }
36 | 
37 |     func singleColor(_ key: ColorKeys) -> some View {
38 |         ZStack {
39 |             Circle()
40 |                 .strokeBorder(CardColor.get(key).font, lineWidth: 1)
41 |                 .background(Circle().foregroundColor(CardColor.get(key).dark))
42 |                 .frame(width: 50, height: 50)
43 |                 .onTapGesture {
44 |                     setNewColor(card, key)
45 |                     isPresented = false
46 |                 }
47 |             if selectedColor == key {
48 |                 Image(systemName: "checkmark")
49 |                     .foregroundStyle(.white)
50 |             }
51 |         }
52 |     }
53 | }
54 | 
55 | #Preview {
56 |     @Previewable @State var show = true
57 | 
58 |     Button {
59 |         show.toggle()
60 |     } label: {
61 |         Text(verbatim: "show")
62 |     }
63 |     .popover(isPresented: $show) {
64 |         CustomColorPicker(
65 |             isPresented: .constant(true),
66 |             card: .constant(Card(name: "Test")),
67 |             selectedColor: .constant(.cardRed),
68 |             setNewColor: { _, _ in }
69 |         )
70 |         .padding(10)
71 |         .presentationCompactAdaptation(.popover)
72 |     }
73 | }
74 | 
--------------------------------------------------------------------------------
/SplitBill/EditCardSheet.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  EditCardSheet.swift
 3 | //  SplitBill
 4 | //
 5 | 
 6 | import SwiftUI
 7 | 
 8 | struct EditCardSheet: ViewModifier {
 9 |     @Binding var show: Bool
10 |     @Environment(\.dismiss) var dismiss
11 | 
12 |     func body(content: Content) -> some View {
13 |         content.sheet(isPresented: $show) {
14 |             // Workaround: in iOS 17, having a List { TextField(...) } breaks the partial sheet
15 |             // A partial sheet can be used again if/once this gets fixed by Apple
16 |             NavigationStack {
17 |                 EditCardsView()
18 |                     .toolbar {
19 |                         ToolbarItem(placement: .cancellationAction) {
20 |                             Button {
21 |                                 show = false
22 |                             } label: {
23 |                                 Image(systemName: "xmark")
24 |                             }
25 |                         }
26 |                     }
27 |                     .navigationTitle("editCards")
28 |                     .navigationBarTitleDisplayMode(.inline)
29 |             }
30 |         }
31 |     }
32 | }
33 | 
34 | extension View {
35 |     func editCardsSheet(show: Binding) -> some View {
36 |         self.modifier(EditCardSheet(show: show))
37 |     }
38 | }
39 | 
40 | #Preview {
41 |     ZStack {}
42 |         .editCardsSheet(show: .constant(true))
43 |         .environmentObject(ContentViewModel())
44 | }
45 | 
--------------------------------------------------------------------------------
/SplitBill/EditCardsView.swift:
--------------------------------------------------------------------------------
 1 | import SwiftUI
 2 | 
 3 | struct EditCardsView: View {
 4 |     @EnvironmentObject var cvm: ContentViewModel
 5 |     @State private var newCard = ""
 6 | 
 7 |     var body: some View {
 8 |         List {
 9 |             Section {
10 |                 TextField("newCard", text: $newCard)
11 |                     .submitLabel(.done)
12 |                     .onSubmit(addNewCard)
13 | 
14 |                 ForEach(cvm.normalCards.indices, id: \.self) { index in
15 |                     CardListItem(card: $cvm.normalCards[index])
16 |                         .id(cvm.normalCards[index].id)
17 |                 }
18 |                 .onDelete(perform: deleteCard)
19 |                 .onMove(perform: move)
20 |             }
21 | 
22 |             Section(header: Text("specialCard")) {
23 |                 if cvm.totalCard != nil {
24 |                     CardListItem(card: Binding($cvm.totalCard)!, disableEdit: true)
25 |                 }
26 |             }
27 |         }
28 |     }
29 | 
30 |     func addNewCard() {
31 |         let name = newCard.trimmingCharacters(in: .whitespacesAndNewlines)
32 |         guard name.count > 0 else { return }
33 |         cvm.addNewCard(name)
34 |         newCard = ""
35 |     }
36 | 
37 |     func deleteCard(at index: IndexSet) {
38 |         cvm.deleteCards(at: index)
39 |     }
40 | 
41 |     func move(from source: IndexSet, to destination: Int) {
42 |         cvm.moveNormalCard(from: source, to: destination)
43 |     }
44 | }
45 | 
--------------------------------------------------------------------------------
/SplitBill/EditableShares.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  EditableShares.swift
  3 | //  SplitBill
  4 | //
  5 | //  Created by fer0n on 06.09.23.
  6 | //
  7 | 
  8 | import SwiftUI
  9 | 
 10 | enum ShareEditType {
 11 |     case reset
 12 |     case edit
 13 | }
 14 | 
 15 | struct EditableShares: View {
 16 |     @Namespace var namespace
 17 | 
 18 |     @EnvironmentObject var cvm: ContentViewModel
 19 |     @Binding var floatingTransactionInfo: FloatingTransactionInfo
 20 |     @Binding var floatingTransaction: Transaction?
 21 | 
 22 |     let handleTransactionChange: (_ updatedTransaction: Transaction) -> Void
 23 | 
 24 |     init(_ floatingTransactionInfo: Binding,
 25 |          _ floatingTransaction: Binding,
 26 |          handleTransactionChange: @escaping (Transaction) -> Void) {
 27 |         self._floatingTransactionInfo = floatingTransactionInfo
 28 |         self._floatingTransaction = floatingTransaction
 29 |         self.handleTransactionChange = handleTransactionChange
 30 |     }
 31 | 
 32 |     var body: some View {
 33 |         let cornerRadius = floatingTransaction?.boundingBox?.cornerRadius ?? 0
 34 |         let padding = floatingTransactionInfo.padding / 2
 35 |         let shares = floatingTransaction?.shares.map { $0.1 }.sorted { (share1, share2) -> Bool in
 36 |             guard let index1 = cvm.getCardsIndex(of: share1.cardId),
 37 |                   let index2 = cvm.getCardsIndex(of: share2.cardId) else {
 38 |                 return false
 39 |             }
 40 |             return index1 < index2
 41 |         } ?? []
 42 | 
 43 |         HStack(alignment: .center, spacing: 0) {
 44 |             ForEach(shares, id: \.self) { share in
 45 |                 let card = cvm.getCardCopy(of: share.cardId)
 46 |                 if card != nil {
 47 |                     ShareTextField(share: share, card: card!,
 48 |                                    cornerRadius: cornerRadius,
 49 |                                    floatingTransactionInfo: $floatingTransactionInfo,
 50 |                                    shareValue: String(share.value ?? 0),
 51 |                                    editShare: self.editShare)
 52 |                         .geometryGroup()
 53 |                         .matchedGeometryEffect(id: "share-\(share.cardId)", in: namespace)
 54 |                 }
 55 |                 if share.cardId != shares.last?.cardId {
 56 |                     Image(systemName: "plus")
 57 |                         .padding(padding)
 58 |                         .geometryGroup()
 59 |                         .matchedGeometryEffect(id: "\(share.cardId)-plus", in: namespace)
 60 |                 }
 61 |             }
 62 |             if shares.count > 0 {
 63 |                 Image(systemName: "equal")
 64 |                     .padding(.leading, padding)
 65 |                     .geometryGroup()
 66 |                     .matchedGeometryEffect(id: "share-equal", in: namespace)
 67 |             }
 68 |         }
 69 |         .geometryGroup()
 70 |         .font(.system(size: ((floatingTransaction?.boundingBox?.height ?? 30) / 1.5),
 71 |                       weight: .semibold, design: .rounded))
 72 |     }
 73 | 
 74 |     func editShare(type: ShareEditType, cardId: UUID, value: Double? = nil, onError: @escaping () -> Void) {
 75 |         guard var transaction = floatingTransaction else {
 76 |             print("no floatingTransaction found to edit share in")
 77 |             return
 78 |         }
 79 |         cvm.handleError({
 80 |             switch type {
 81 |             case .edit:
 82 |                 try transaction.editShare(cardId: cardId, value: value)
 83 |             case .reset:
 84 |                 try transaction.resetShare(cardId: cardId)
 85 |             }
 86 |         }, onError: {
 87 |             onError()
 88 |         }, onSuccess: {
 89 |             handleTransactionChange(transaction)
 90 |         })
 91 |     }
 92 | 
 93 |     func resetShare(cardId: UUID) {
 94 |         guard var transaction = floatingTransaction else {
 95 |             print("no floatingTransaction found to edit share in")
 96 |             return
 97 |         }
 98 |         try? transaction.resetShare(cardId: cardId)
 99 |         handleTransactionChange(transaction)
100 |     }
101 | }
102 | 
103 | 
104 | 
105 | #Preview {
106 |     EditableShares(.constant(
107 |         FloatingTransactionInfo(
108 |             center: false,
109 |             width: nil,
110 |             value: "",
111 |             color: .neutralGray,
112 |             cardColors: [
113 |                 .red,
114 |                 .green,
115 |             ],
116 |             )
117 |     ), .constant(
118 |         Transaction(
119 |             value: 10
120 |         )
121 |     ), handleTransactionChange: { _ in })
122 |     .environmentObject(ContentViewModel())
123 | }
124 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/CGRect.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  CGRect.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 07.09.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension CGRect {
11 |     public var cornerRadius: CGFloat {
12 |         0.2 * min(self.width, self.height)
13 |     }
14 | }
15 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/Collection.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Collection.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 07.09.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension Collection {
11 |     /// Returns the element at the specified index if it is within bounds, otherwise nil.
12 |     subscript (safe index: Index) -> Element? {
13 |         return indices.contains(index) ? self[index] : nil
14 |     }
15 | }
16 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/Double.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Double.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 03.09.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | 
10 | extension Double {
11 |     /**
12 |      Returns value as string, truncating "1.0" to "1". Several decimal places are truncated to two: "1.124125" -> "1.12"
13 |      */
14 |     var clean: String {
15 |         return self.truncatingRemainder(dividingBy: 1) == 0
16 |             ? String(format: "%.0f", self)
17 |             : String(format: "%.02f", self)
18 |     }
19 | }
20 | 
21 | extension Double {
22 |     static func parse(from string: String) -> Double? {
23 |         let result = Double(string)
24 |         if let res = result, res > Double(Int.max) {
25 |             return nil
26 |         }
27 |         return result
28 |     }
29 | }
30 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/GeometryEffect.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  GeometryEffect.swift
 3 | //  ScorePad
 4 | //
 5 | //  Created by fer0n on 06.07.21.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | 
11 | struct Shake: GeometryEffect {
12 |     var amount: CGFloat = 10
13 |     var shakesPerUnit = 3
14 |     var animatableData: CGFloat
15 |     var isActive = true
16 | 
17 |     func effectValue(size: CGSize) -> ProjectionTransform {
18 |         if isActive {
19 |             return ProjectionTransform(CGAffineTransform(translationX:
20 |                                                             amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
21 |                                                          y: 0))
22 |         } else {
23 |             return ProjectionTransform()
24 |         }
25 |     }
26 | }
27 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/NSExpression.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  NSExpression.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 21.08.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | // swiftlint:disable all
10 | // https://stackoverflow.com/questions/46550658/can-i-force-nsexpression-and-expressionvalue-to-assume-doubles-instead-of-ints-s
11 | extension NSExpression {
12 | 
13 |     /**
14 |      Converts an expression to floating point values if it includes a divition. Otherwise an integer division is used, resulting in a wrong result.
15 |      */
16 |     func toFloatingPointDivision() -> NSExpression {
17 |         switch expressionType {
18 |         case .function where function == "divide:by:":
19 |             guard let args = arguments else { break }
20 |             let newArgs = args.map({ arg -> NSExpression in
21 |                 if arg.expressionType == .constantValue {
22 |                     if let value = arg.constantValue as? Double {
23 |                         return NSExpression(forConstantValue: value)
24 |                     } else {
25 |                         return arg
26 |                     }
27 |                 } else {
28 |                     return NSExpression(block: { (_, arguments, _) in
29 |                         // NB: The type of `+[NSExpression expressionForBlock:arguments]` is incorrect.
30 |                         // It claims the arguments is an array of NSExpressions, but it's not, it's
31 |                         // actually an array of the evaluated values. We can work around this by going
32 |                         // through NSArray.
33 |                         guard let arg = (arguments as NSArray).firstObject else { return NSNull() }
34 |                         return (arg as? Double) ?? arg
35 |                     }, arguments: [arg.toFloatingPointDivision()])
36 |                 }
37 |             })
38 |             return NSExpression(forFunction: operand, selectorName: function, arguments: newArgs)
39 |         case .function:
40 |             guard let args = arguments else { break }
41 |             let newArgs = args.map({ $0.toFloatingPointDivision() })
42 |             return NSExpression(forFunction: operand, selectorName: function, arguments: newArgs)
43 |         case .conditional:
44 |             return NSExpression(forConditional: predicate,
45 |                                 trueExpression: self.true.toFloatingPointDivision(),
46 |                                 falseExpression: self.false.toFloatingPointDivision())
47 |         case .unionSet:
48 |             return NSExpression(forUnionSet: left.toFloatingPointDivision(), with: right.toFloatingPointDivision())
49 |         case .intersectSet:
50 |             return NSExpression(forIntersectSet: left.toFloatingPointDivision(), with: right.toFloatingPointDivision())
51 |         case .minusSet:
52 |             return NSExpression(forMinusSet: left.toFloatingPointDivision(), with: right.toFloatingPointDivision())
53 |         case .subquery:
54 |             if let subQuery = collection as? NSExpression {
55 |                 return NSExpression(forSubquery: subQuery.toFloatingPointDivision(), usingIteratorVariable: variable, predicate: predicate)
56 |             }
57 |         case .aggregate:
58 |             if let subExpressions = collection as? [NSExpression] {
59 |                 return NSExpression(forAggregate: subExpressions.map({ $0.toFloatingPointDivision() }))
60 |             }
61 |         case .block:
62 |             guard let args = arguments else { break }
63 |             let newArgs = args.map({ $0.toFloatingPointDivision() })
64 |             return NSExpression(block: expressionBlock, arguments: newArgs)
65 |         case .constantValue, .anyKey:
66 |             break // Nothing to do here
67 |         case .evaluatedObject, .variable, .keyPath:
68 |             // FIXME: These should probably be wrapped in blocks like the one
69 |             // used in the `.function` case.
70 |             break
71 |         @unknown default:
72 |             return self
73 |         }
74 |         return self
75 |     }
76 | }
77 | // swiftlint:enable all
78 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/ObjC.m:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ObjC.m
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 21.08.23.
 6 | //
 7 | 
 8 | #import 
 9 | #import "SplitBill-Bridging-Header.h"
10 | 
11 | @implementation ObjC
12 | 
13 | + (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error {
14 |     @try {
15 |         tryBlock();
16 |         return YES;
17 |     }
18 |     @catch (NSException *exception) {
19 |         *error = [[NSError alloc] initWithDomain:exception.name code:0 userInfo:exception.userInfo];
20 |         return NO;
21 |     }
22 | }
23 | 
24 | @end
25 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/PresentationDetent.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  PresentationDetent.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 07.09.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | 
11 | private struct BarDetent: CustomPresentationDetent {
12 |     static func height(in context: Context) -> CGFloat? {
13 |         max(44, context.maxDetentValue * 0.1)
14 |     }
15 | }
16 | 
17 | extension PresentationDetent {
18 |     static let small = Self.height(200)
19 | }
20 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/SplitBill-Bridging-Header.h:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Use this file to import your target's public headers that you would like to expose to Swift.
 3 | //
 4 | 
 5 | #import 
 6 | 
 7 | @interface ObjC : NSObject
 8 | 
 9 | + (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error;
10 | 
11 | @end
12 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/UIColor.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIColor.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 07.09.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | 
11 | extension UIColor {
12 |     // Check if the color is light or dark, as defined by the injected lightness threshold.
13 |     // Some people report that 0.7 is best. I suggest to find out for yourself.
14 |     // A nil value is returned if the lightness couldn't be determined.
15 |     func isLight(threshold: Float = 0.5) -> Bool? {
16 |         let originalCGColor = self.cgColor
17 |         // Now we need to convert it to the RGB colorspace. UIColor.white / UIColor.black are greyscale and not RGB.
18 |         // If you don't do this then you will crash when accessing components
19 |         // index 2 below when evaluating greyscale colors.
20 |         let RGBCGColor = originalCGColor.converted(
21 |             to: CGColorSpaceCreateDeviceRGB(),
22 |             intent: .defaultIntent, options: nil
23 |         )
24 |         guard let components = RGBCGColor?.components else {
25 |             return nil
26 |         }
27 |         guard components.count >= 3 else {
28 |             return nil
29 |         }
30 |         let brightness = Float(((components[0] * 299) + (components[1] * 587) + (components[2] * 114)) / 1000)
31 |         return (brightness > threshold)
32 |     }
33 | }
34 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/UIFont.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIFont.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 07.09.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | 
11 | extension UIFont {
12 |     func calculateHeight(text: String, width: CGFloat) -> CGFloat {
13 |         let constraintRect = CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)
14 |         let boundingBox = text.boundingRect(with: constraintRect,
15 |                                             options: NSStringDrawingOptions.usesLineFragmentOrigin,
16 |                                             attributes: [NSAttributedString.Key.font: self],
17 |                                             context: nil)
18 |         return boundingBox.height
19 |     }
20 | }
21 | 
22 | extension UIFont {
23 |     class func rounded(ofSize size: CGFloat, weight: UIFont.Weight) -> UIFont {
24 |         let systemFont = UIFont.systemFont(ofSize: size, weight: weight)
25 |         let font: UIFont
26 | 
27 |         if let descriptor = systemFont.fontDescriptor.withDesign(.rounded) {
28 |             font = UIFont(descriptor: descriptor, size: size)
29 |         } else {
30 |             font = systemFont
31 |         }
32 |         return font
33 |     }
34 | }
35 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/UIImage.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIImage.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 07.09.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | 
11 | extension UIImage {
12 |     var averageColor: UIColor? {
13 |         guard let inputImage = CIImage(image: self) else { return nil }
14 |         let extentVector = CIVector(x: inputImage.extent.origin.x,
15 |                                     y: inputImage.extent.origin.y,
16 |                                     z: inputImage.extent.size.width,
17 |                                     w: inputImage.extent.size.height)
18 | 
19 |         guard let filter = CIFilter(
20 |             name: "CIAreaAverage",
21 |             parameters: [kCIInputImageKey: inputImage, kCIInputExtentKey: extentVector]
22 |         ) else {
23 |             return nil
24 |         }
25 |         guard let outputImage = filter.outputImage else { return nil }
26 | 
27 |         var bitmap = [UInt8](repeating: 0, count: 4)
28 |         guard let value = kCFNull else { return nil }
29 |         let context = CIContext(options: [.workingColorSpace: value])
30 |         context.render(outputImage,
31 |                        toBitmap: &bitmap,
32 |                        rowBytes: 4,
33 |                        bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
34 |                        format: .RGBA8,
35 |                        colorSpace: nil)
36 | 
37 |         return UIColor(red: CGFloat(bitmap[0]) / 255,
38 |                        green: CGFloat(bitmap[1]) / 255,
39 |                        blue: CGFloat(bitmap[2]) / 255,
40 |                        alpha: CGFloat(bitmap[3]) / 255)
41 |     }
42 | }
43 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/UIView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UIView.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 03.09.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | import UIKit
10 | 
11 | extension UIView {
12 |     var allSubviews: [UIView] {
13 |         return self.subviews.flatMap { [$0] + $0.allSubviews }
14 |     }
15 | }
16 | 
--------------------------------------------------------------------------------
/SplitBill/Extensions/View.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  View.swift
  3 | //  SplitBill
  4 | //
  5 | 
  6 | import SwiftUI
  7 | 
  8 | extension View {
  9 |     /// Applies the given transform if the given condition evaluates to `true`.
 10 |     /// - Parameters:
 11 |     ///   - condition: The condition to evaluate.
 12 |     ///   - transform: The transform to apply to the source `View`.
 13 |     /// - Returns: Either the original `View` or the modified `View` if the condition is `true`.
 14 |     @ViewBuilder func `if`(
 15 |         _ condition: @autoclosure () -> Bool,
 16 |         transform: (Self) -> Content
 17 |     ) -> some View {
 18 |         if condition() {
 19 |             transform(self)
 20 |         } else {
 21 |             self
 22 |         }
 23 |     }
 24 | }
 25 | 
 26 | extension View {
 27 |     func apply(@ViewBuilder _ block: (Self) -> V) -> V { block(self) }
 28 | 
 29 |     func myGlassEffect(interactive: Bool = false) -> some View {
 30 |         self.apply {
 31 |             if #available(iOS 26, *) {
 32 |                 $0.glassEffect(.regular.interactive(interactive), in: .circle)
 33 |             } else {
 34 |                 $0
 35 |                     .background(.thinMaterial)
 36 |                     .clipShape(Circle())
 37 |             }
 38 |         }
 39 |     }
 40 | }
 41 | 
 42 | extension View {
 43 |     func cardBackground(
 44 |         _ isSelected: Bool,
 45 |         _ selectedColor: Color,
 46 |         in shape: some Shape
 47 |     ) -> some View {
 48 |         self
 49 |             .apply {
 50 |                 if #available(iOS 26, *) {
 51 |                     $0.glassEffect(
 52 |                         .regular.tint(
 53 |                             isSelected ? selectedColor : nil
 54 |                         ),
 55 |                         in: shape
 56 |                     )
 57 |                 } else {
 58 |                     $0
 59 |                         .background(isSelected ? selectedColor : nil)
 60 |                         .background(.thinMaterial)
 61 |                         .background(isSelected ? Color.blue.opacity(0) : Color.black.opacity(0.3))
 62 |                         .clipShape(shape)
 63 |                 }
 64 |             }
 65 |             .contentShape(shape)
 66 |     }
 67 | }
 68 | 
 69 | extension View {
 70 |     func floatingTransactionModifier(_ floatingTransaction: Transaction?,
 71 |                                      _ floatingTransactionInfo: FloatingTransactionInfo) -> some View {
 72 |         self
 73 |             .padding(.horizontal, floatingTransactionInfo.padding)
 74 |             .floatingTransactionBackground(floatingTransaction, floatingTransactionInfo)
 75 |     }
 76 | 
 77 |     func floatingTransactionPosition(_ floatingTransaction: Transaction?,
 78 |                                      _ floatingTransactionInfo: FloatingTransactionInfo) -> some View {
 79 |         self
 80 |             .position(x: floatingTransactionInfo.center
 81 |                         ? floatingTransaction?.boundingBox?.midX ?? 0
 82 |                         : (floatingTransaction?.boundingBox?.minX ?? 0)
 83 |                         - (floatingTransactionInfo.width ?? floatingTransaction?.boundingBox?.width ?? 0) / 2
 84 |                         - floatingTransactionInfo.padding * 2,
 85 |                       y: floatingTransaction?.boundingBox?.midY ?? 0)
 86 |     }
 87 | 
 88 |     func floatingTransactionBackground(_ floatingTransaction: Transaction?,
 89 |                                        _ floatingTransactionInfo: FloatingTransactionInfo) -> some View {
 90 |         self
 91 |             .background(
 92 |                 GeometryReader { geometry in
 93 |                     ZStack {
 94 |                         ForEach(floatingTransactionInfo.cardColors, id: \.self) { color in
 95 |                             Rectangle()
 96 |                                 .fill(color)
 97 |                                 .frame(width: geometry.size.width / CGFloat(floatingTransactionInfo.cardColors.count),
 98 |                                        height: geometry.size.height)
 99 |                                 .offset(x: CGFloat(floatingTransactionInfo.cardColors.firstIndex(of: color)!)
100 |                                             * (geometry.size.width / CGFloat(floatingTransactionInfo.cardColors.count)),
101 |                                         y: 0)
102 |                         }
103 |                     }
104 |                 }
105 |             )
106 |             .clipShape(RoundedRectangle(cornerRadius:
107 |                                             floatingTransaction?.boundingBox?.cornerRadius
108 |                                             ?? floatingTransaction?.boundingBox?.minX
109 |                                             ?? 0
110 |             ))
111 |     }
112 | 
113 |     func onSizeChange(_ onSizeChange: @escaping (_ size: CGSize) -> Void) -> some View {
114 |         self
115 |             .onGeometryChange(for: CGSize.self) { proxy in
116 |                 proxy.size
117 |             } action: { newValue in
118 |                 onSizeChange(newValue)
119 |             }
120 |     }
121 | }
122 | 
--------------------------------------------------------------------------------
/SplitBill/FloatingTransaction.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  FloatingTransaction.swift
  3 | //  SplitBill
  4 | //
  5 | //  Created by fer0n on 08.08.23.
  6 | //
  7 | 
  8 | import SwiftUI
  9 | 
 10 | struct FloatingTransactionView: View {
 11 |     @EnvironmentObject var cvm: ContentViewModel
 12 |     @Environment(\.colorScheme) var colorScheme
 13 | 
 14 |     @State var floatingTransactionInfo = FloatingTransactionInfo(
 15 |         center: false,
 16 |         width: nil,
 17 |         value: "",
 18 |         color: .neutralGray)
 19 |     @State var floatingTransaction: Transaction?
 20 |     @FocusState private var floatingTransactionIsFocused: Bool
 21 |     @FocusState private var editableSharesFocused: Bool
 22 | 
 23 |     @State var floatingTransactionDisappearTimer: Timer?
 24 |     @State var attempts: Int = 0
 25 | 
 26 |     var body: some View {
 27 |         ZStack {
 28 |             if floatingTransaction != nil {
 29 |                 HStack(alignment: .center, spacing: 0) {
 30 |                     if floatingTransaction?.shares.count ?? 0 > 1 {
 31 |                         EditableShares($floatingTransactionInfo,
 32 |                                        $floatingTransaction,
 33 |                                        handleTransactionChange: self.handleFreeformTransaction)
 34 |                             .focused($editableSharesFocused)
 35 |                         Spacer()
 36 |                             .frame(width: padding)
 37 |                             .onChange(of: editableSharesFocused) {
 38 |                                 if !editableSharesFocused {
 39 |                                     debouncedHideFloatingTransaction()
 40 |                                 }
 41 |                             }
 42 |                     }
 43 |                     FloatingTransactionTextField(floatingTransactionInfo: $floatingTransactionInfo,
 44 |                                                  floatingTransaction: $floatingTransaction,
 45 |                                                  floatingTransactionIsFocused: $floatingTransactionIsFocused,
 46 |                                                  attempts: $attempts,
 47 |                                                  floatingTransactionDisappearTimer: floatingTransactionDisappearTimer,
 48 |                                                  handleFreeformTransaction: self.handleFreeformTransaction)
 49 |                 }
 50 |                 .geometryGroup()
 51 |                 .padding(padding)
 52 |                 .font(.system(size: (floatingTransaction?.boundingBox?.height ?? 30),
 53 |                               weight: .semibold, design: .rounded))
 54 |                 .apply {
 55 |                     if #available(iOS 26.0, *) {
 56 |                         $0
 57 |                             .glassEffect(
 58 |                                 .regular,
 59 |                                 in: shape
 60 |                             )
 61 |                     } else {
 62 |                         $0
 63 |                             .background(
 64 |                                 Color.black.opacity(0)
 65 |                                     .background(.ultraThinMaterial)
 66 |                             )
 67 |                             .clipShape(shape)
 68 |                     }
 69 |                 }
 70 |                 .foregroundStyle(cvm.getMarkerColor(colorScheme))
 71 |                 .environment(\.colorScheme, cvm.getColorScheme(colorScheme))
 72 |                 .onSizeChange(handleSizeChange)
 73 |                 .floatingTransactionPosition(floatingTransaction, floatingTransactionInfo)
 74 |             }
 75 |         }
 76 |         .onAppear {
 77 |             cvm.onImageLongPress = self.handleTransactionLongPress
 78 |             cvm.onFlashTransaction = self.flashTransaction
 79 |             cvm.onEmptyTap = self.handleEmptyTap
 80 |         }
 81 |         .onChange(of: cvm.image) {
 82 |             floatingTransaction = nil
 83 |         }
 84 |     }
 85 | 
 86 |     var shape: some Shape {
 87 |         RoundedRectangle(cornerRadius: cornerRadius + padding, style: .continuous)
 88 |     }
 89 | 
 90 |     var cornerRadius: CGFloat {
 91 |         floatingTransaction?.boundingBox?.cornerRadius ?? 0
 92 |     }
 93 | 
 94 |     var padding: CGFloat {
 95 |         floatingTransactionInfo.padding / 2
 96 |     }
 97 | 
 98 |     func flashTransaction(_ tId: UUID, _ remove: Bool) {
 99 |         if remove && floatingTransaction == nil {
100 |             return
101 |         }
102 | 
103 |         let transaction = cvm.getTransaction(tId)
104 |         guard let transaction = transaction,
105 |               transaction.shares.count > 0 else {
106 |             floatingTransaction = nil
107 |             print("no transaction or card found to display")
108 |             return
109 |         }
110 |         withAnimation {
111 |             setFloatingTransactionColor(transaction)
112 |             floatingTransaction = nil
113 |             floatingTransaction = transaction
114 |             floatingTransactionInfo.value = transaction.stringValue
115 |             floatingTransactionInfo.uiFont = UIFont.rounded(ofSize: floatingTransaction?.boundingBox?.height ?? 30,
116 |                                                             weight: .semibold)
117 |         }
118 |         debouncedHideFloatingTransaction()
119 |     }
120 | 
121 |     func debouncedHideFloatingTransaction() {
122 |         floatingTransactionDisappearTimer?.invalidate()
123 |         withAnimation {
124 |             if floatingTransactionInfo.value == "" {
125 |                 floatingTransaction = nil
126 |             } else if let duration = cvm.previewDuration.timeInterval {
127 |                 floatingTransactionDisappearTimer = Timer.scheduledTimer(withTimeInterval: duration,
128 |                                                                          repeats: false) { _ in
129 |                     if !editableSharesFocused {
130 |                         floatingTransaction = nil
131 |                     }
132 |                 }
133 |             }
134 |         }
135 |         floatingTransactionInfo.center = false
136 |     }
137 | 
138 |     func handleTransactionLongPress(_ transaction: Transaction?, _ point: CGPoint?) {
139 |         withAnimation {
140 |             setFloatingTransactionColor(transaction)
141 |             floatingTransactionIsFocused = true
142 |             if let transaction = transaction {
143 |                 // edit existing transaction
144 |                 floatingTransactionInfo.value = String(transaction.value)
145 |                 floatingTransactionInfo.center = false
146 |                 floatingTransaction = transaction
147 |                 floatingTransactionInfo.uiFont = UIFont.rounded(ofSize: transaction.boundingBox?.height ?? 30,
148 |                                                                 weight: .semibold)
149 |             } else {
150 |                 // new transaction
151 |                 guard let point = point else { return }
152 |                 floatingTransactionInfo.value = ""
153 |                 let boundingBox = cvm.getMedianBoundingBox()
154 |                 floatingTransaction = Transaction(
155 |                     value: 0,
156 |                     boundingBox: CGRect(x: point.x - boundingBox.width / 2,
157 |                                         y: point.y - boundingBox.height / 2,
158 |                                         width: boundingBox.width,
159 |                                         height: boundingBox.height))
160 |                 floatingTransactionInfo.center = true
161 |             }
162 |         }
163 |     }
164 | 
165 |     func handleEmptyTap() {
166 |         if cvm.previewDuration == .tapAway {
167 |             withAnimation {
168 |                 floatingTransaction = nil
169 |             }
170 |         }
171 |     }
172 | 
173 |     func getCardColors(from cardIndeces: [Array.Index?]) -> [Color] {
174 |         let cardIndeces = cardIndeces
175 |             .sorted { (index1, index2) -> Bool in
176 |                 guard let index1, let index2 else { return false }
177 |                 return index1 < index2
178 |             }
179 |         let cardColors = cardIndeces.map {
180 |             if let index = $0 {
181 |                 return cvm.cards[index].color.light
182 |             }
183 |             return Color.black
184 |         }
185 |         return cardColors
186 |     }
187 | 
188 |     func setFloatingTransactionColor(_ transaction: Transaction?) {
189 |         // get cardIds: either from transaction shares or from active cards
190 |         var cardIds: [UUID] = []
191 |         if let transaction = transaction {
192 |             cardIds = Array(transaction.shares.keys)
193 |         }
194 |         if cardIds.isEmpty {
195 |             cardIds = Array(cvm.activeCardsIds)
196 |         }
197 |         let cardIndices = cardIds.map { cvm.getCardsIndex(of: $0 )}
198 | 
199 |         // set CardColor, used for CalcTextField keyboard
200 |         if let firstCardIndex = cardIndices[0] {
201 |             let color = cvm.cards[firstCardIndex].color
202 |             floatingTransactionInfo.color = color
203 |         }
204 | 
205 |         // get colors of all cards
206 |         let cardColors = getCardColors(from: cardIndices)
207 |         floatingTransactionInfo.cardColors = !cardColors.isEmpty ? cardColors : [.black]
208 |     }
209 | 
210 |     func handleFreeformTransaction(updatedTransaction: Transaction? = nil) {
211 |         withAnimation {
212 |             if let value = Double.parse(from: floatingTransactionInfo.value),
213 |                var transaction = updatedTransaction ?? floatingTransaction {
214 |                 transaction.value = value
215 |                 let hitCard = cvm.getFirstChosencardOfTransaction(transaction)
216 |                 if hitCard != nil || cvm.hasTransaction(transaction) {
217 |                     cvm.correctTransaction(transaction)
218 |                 } else {
219 |                     let box = transaction.boundingBox ?? cvm.getProposedMarkerRect(basedOn: transaction.boundingBox)
220 |                     let newTransaction = cvm.createNewTransaction(value: value, boundingBox: box)
221 |                     cvm.linkTransactionToActiveCards(newTransaction)
222 |                 }
223 |                 try? transaction.refreshShares()
224 |                 self.floatingTransaction = nil // value doesn't update otherwise
225 |                 self.floatingTransaction = transaction
226 |             }
227 |             debouncedHideFloatingTransaction()
228 |         }
229 |     }
230 | 
231 |     func handleSizeChange(_ size: CGSize) {
232 |         if floatingTransaction == nil { return }
233 |         withAnimation {
234 |             floatingTransactionInfo.width = size.width
235 |             floatingTransactionInfo.padding = size.height * 0.2
236 |         }
237 |     }
238 | }
239 | 
--------------------------------------------------------------------------------
/SplitBill/FloatingTransactionTextField.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  FloatingTransactionTextField.swift
 3 | //  SplitBill
 4 | //
 5 | 
 6 | import SwiftUI
 7 | 
 8 | struct FloatingTransactionTextField: View {
 9 |     @Binding var floatingTransactionInfo: FloatingTransactionInfo
10 |     @Binding var floatingTransaction: Transaction?
11 |     @FocusState.Binding var floatingTransactionIsFocused: Bool
12 |     @Binding var attempts: Int
13 |     var floatingTransactionDisappearTimer: Timer?
14 |     var handleFreeformTransaction: (Transaction?) -> Void
15 | 
16 |     var body: some View {
17 |         ZStack {
18 |             CalcTextField(
19 |                 "",
20 |                 text: $floatingTransactionInfo.value,
21 |                 onSubmit: { result in
22 |                     guard let res = result else {
23 |                         self.attempts += 1
24 |                         return
25 |                     }
26 |                     if res != 0 {
27 |                         floatingTransactionInfo.value = String(res)
28 |                     }
29 |                 },
30 |                 onEditingChanged: { edit in
31 |                     if !edit {
32 |                         handleFreeformTransaction(nil)
33 |                     } else {
34 |                         floatingTransactionDisappearTimer?.invalidate()
35 |                     }
36 |                 },
37 |                 accentColor: floatingTransactionInfo.color.uiColorFont,
38 |                 bgColor: UIColor(floatingTransactionInfo.color.dark),
39 |                 textColor: UIColor(floatingTransactionInfo.color.contrast),
40 |                 font: floatingTransactionInfo.uiFont
41 |             )
42 |             .fixedSize()
43 |             .focused($floatingTransactionIsFocused)
44 |         }
45 |         .floatingTransactionModifier(floatingTransaction, floatingTransactionInfo)
46 |         .modifier(Shake(animatableData: CGFloat(self.attempts)))
47 |     }
48 | }
49 | 
--------------------------------------------------------------------------------
/SplitBill/Font/SF-Pro-Rounded-Bold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/SplitBill/Font/SF-Pro-Rounded-Bold.otf
--------------------------------------------------------------------------------
/SplitBill/Font/SF-Pro-Rounded-Semibold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fer0n/SplitBill/3040ff491f362d89ceb1d353bea94d7e0400a939/SplitBill/Font/SF-Pro-Rounded-Semibold.otf
--------------------------------------------------------------------------------
/SplitBill/HelperModels/FloatingTransactionInfo.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  FloatingTransactionInfo.swift
 3 | //  SplitBill
 4 | //
 5 | 
 6 | import SwiftUI
 7 | 
 8 | struct FloatingTransactionInfo {
 9 |     init(center: Bool, width: CGFloat?, value: String, color: ColorKeys, cardColors: [Color] = []) {
10 |         self.center = center
11 |         self.width = width
12 |         self.value = value
13 |         self.cardColors = cardColors
14 |         self.colorKey = color
15 |     }
16 | 
17 |     var center: Bool
18 |     var width: CGFloat?
19 |     var padding: CGFloat = 0
20 |     var value: String
21 |     var colorKey: ColorKeys
22 |     var editable = false
23 |     var cardColors: [Color]
24 |     var uiFont: UIFont = UIFont.rounded(ofSize: 20, weight: .semibold)
25 | 
26 |     var color: CardColor {
27 |         get {
28 |             CardColor.get(colorKey)
29 |         }
30 |         set {
31 |             self.colorKey = newValue.id
32 |         }
33 |     }
34 | }
35 | 
--------------------------------------------------------------------------------
/SplitBill/ImagePickerView.swift:
--------------------------------------------------------------------------------
 1 | import SwiftUI
 2 | 
 3 | public struct ImagePickerView: UIViewControllerRepresentable {
 4 |     private let sourceType: UIImagePickerController.SourceType
 5 |     private let onImagePicked: (UIImage, _ isHeic: Bool) -> Void
 6 |     @Environment(\.presentationMode) private var presentationMode
 7 | 
 8 |     public init(sourceType: UIImagePickerController.SourceType,
 9 |                 onImagePicked: @escaping (UIImage, _ isHeic: Bool) -> Void) {
10 |         self.sourceType = sourceType
11 |         self.onImagePicked = onImagePicked
12 |     }
13 | 
14 |     public func makeUIViewController(context: Context) -> UIImagePickerController {
15 |         let picker = UIImagePickerController()
16 |         picker.sourceType = self.sourceType
17 |         picker.delegate = context.coordinator
18 |         return picker
19 |     }
20 | 
21 |     public func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
22 | 
23 |     public func makeCoordinator() -> Coordinator {
24 |         Coordinator(
25 |             onDismiss: { self.presentationMode.wrappedValue.dismiss() },
26 |             onImagePicked: self.onImagePicked
27 |         )
28 |     }
29 | 
30 |     final public class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
31 | 
32 |         private let onDismiss: () -> Void
33 |         private let onImagePicked: (UIImage, _ isHeic: Bool) -> Void
34 | 
35 |         init(onDismiss: @escaping () -> Void, onImagePicked: @escaping (UIImage, _ isHeic: Bool) -> Void) {
36 |             self.onDismiss = onDismiss
37 |             self.onImagePicked = onImagePicked
38 |         }
39 | 
40 |         public func imagePickerController(_ picker: UIImagePickerController,
41 |                                           didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
42 |             if let image = info[.originalImage] as? UIImage {
43 |                 let isHeic = imageIsHeic(info)
44 |                 self.onImagePicked(image, isHeic)
45 |             }
46 |             self.onDismiss()
47 |         }
48 | 
49 |         public func imagePickerControllerDidCancel(_: UIImagePickerController) {
50 |             self.onDismiss()
51 |         }
52 | 
53 |         public func imageIsHeic(_ info: [UIImagePickerController.InfoKey: Any]) -> Bool {
54 |             if let assetPath = info[UIImagePickerController.InfoKey.referenceURL] as? NSURL,
55 |                let ext = assetPath.pathExtension,
56 |                ext == "HEIC" {
57 |                 return true
58 |             }
59 |             return false
60 |         }
61 | 
62 |     }
63 | 
64 | }
65 | 
--------------------------------------------------------------------------------
/SplitBill/Info.plist:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 | 
 5 | 	NSPhotoLibraryAddUsageDescription
 6 | 		Save annotated image to your library
 7 | 	CFBundleURLTypes
 8 | 	
 9 | 		
10 | 			CFBundleURLSchemes
11 | 			
12 | 				splitbill
13 | 			
14 | 		
15 | 	
16 | 	ITSAppUsesNonExemptEncryption
17 | 	
18 | 	UIApplicationSceneManifest
19 | 	
20 | 		UIApplicationSupportsMultipleScenes
21 | 		
22 | 		UISceneConfigurations
23 | 		
24 | 	
25 | 
26 | 
27 | 
--------------------------------------------------------------------------------
/SplitBill/LinkItemView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  LinkItemView.swift
 3 | //  SplitBill
 4 | //
 5 | 
 6 | import SwiftUI
 7 | 
 8 | struct LinkItemView: View {
 9 |     let destination: URL
10 |     let label: String
11 |     let content: () -> Content
12 | 
13 |     var body: some View {
14 |         Link(destination: destination) {
15 |             HStack(spacing: 20) {
16 |                 content()
17 |                     .frame(width: 24, height: 24)
18 |                     .foregroundColor(.labelColor)
19 |                 Text(LocalizedStringKey(label))
20 |                     .lineLimit(1)
21 |                     .truncationMode(.tail)
22 |                 Spacer()
23 |                 Image(systemName: "chevron.right")
24 |                     .foregroundColor(.labelColor)
25 |             }
26 |         }
27 |     }
28 | }
29 | 
--------------------------------------------------------------------------------
/SplitBill/LiveTextImage.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  LiveTextImage.swift
 3 | //  SplitBill
 4 | //
 5 | 
 6 | import SwiftUI
 7 | 
 8 | struct LiveTextImage: View {
 9 |     @EnvironmentObject var cvm: ContentViewModel
10 |     @Environment(\.colorScheme) var colorScheme
11 | 
12 |     @Binding var showEditCardSheet: Bool
13 |     let zoomBufferPadding: CGFloat
14 | 
15 |     var body: some View {
16 |         ZoomableScrollView(contentPadding: zoomBufferPadding,
17 |                            ignoreTapsAt: self.ignoreTapsAt,
18 |                            onGestureHasBegun: self.onGestureHasBegun,
19 |                            contentChanged: cvm.contentChanged) {
20 |             ZStack {
21 |                 LiveTextInteraction(
22 |                     invertColors: cvm.requiresInvertedColors(colorScheme),
23 |                     markerColor: cvm.getMarkerColor(colorScheme)
24 |                 )
25 |                 FloatingTransactionView()
26 |             }
27 |             .padding(zoomBufferPadding)
28 |             .overlay(
29 |                 Rectangle()
30 |                     .stroke(Color.backgroundColor, lineWidth: 10)
31 |             )
32 |             .background(Color.backgroundColor)
33 |         }
34 |         .onAppear {
35 |             DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
36 |                 if cvm.chosenNormalCards.isEmpty {
37 |                     showEditCardSheet = true
38 |                 }
39 |             }
40 |         }
41 |     }
42 | 
43 |     func ignoreTapsAt(_ point: CGPoint) -> Bool {
44 |         return cvm.lastTapWasHitting
45 |     }
46 | 
47 |     func onGestureHasBegun() {
48 |         cvm.emptyTapTimer?.invalidate()
49 |     }
50 | }
51 | 
--------------------------------------------------------------------------------
/SplitBill/LiveTextInteraction.swift:
--------------------------------------------------------------------------------
  1 | import UIKit
  2 | import SwiftUI
  3 | import VisionKit
  4 | import Vision
  5 | import CoreImage
  6 | 
  7 | struct LiveTextInteraction: UIViewRepresentable {
  8 |     let imageView = UIImageView()
  9 |     @EnvironmentObject var cvm: ContentViewModel
 10 |     let invertColors: Bool
 11 |     let markerColor: Color
 12 | 
 13 |     private static var invertedImageCache = [Int: UIImage]()
 14 | 
 15 |     class Coordinator: NSObject, ImageAnalysisInteractionDelegate {
 16 |         var parent: LiveTextInteraction
 17 | 
 18 |         init(_ parent: LiveTextInteraction) {
 19 |             self.parent = parent
 20 |             super.init()
 21 | 
 22 |             parent.imageView.isUserInteractionEnabled = true
 23 |             let tapGesture = UITapGestureRecognizer(target: self, action: #selector(imageTapped))
 24 |             tapGesture.numberOfTapsRequired = 1
 25 |             parent.imageView.addGestureRecognizer(tapGesture)
 26 | 
 27 |             let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(imageLongPressed))
 28 |             parent.imageView.addGestureRecognizer(longPressGesture)
 29 |         }
 30 | 
 31 |         @MainActor @objc func imageTapped(_ sender: UITapGestureRecognizer) {
 32 |             // do something when image tapped
 33 |             let point = sender.location(in: parent.imageView)
 34 |             parent.cvm.handleTap(at: point)
 35 |         }
 36 | 
 37 |         @MainActor @objc func imageLongPressed(_ sender: UILongPressGestureRecognizer) {
 38 |             // do something when image tapped
 39 |             let point = sender.location(in: parent.imageView)
 40 |             if sender.state == .began {
 41 |                 parent.cvm.handleLongPress(at: point)
 42 |             }
 43 |         }
 44 |     }
 45 | 
 46 |     func makeCoordinator() -> Coordinator {
 47 |         Coordinator(self)
 48 |     }
 49 | 
 50 |     func makeUIView(context: Context) -> UIImageView {
 51 |         self.cvm.generateExportImage = self.generateExportImage
 52 |         return imageView
 53 |     }
 54 | 
 55 |     func updateUIView(_ uiView: UIImageView, context: Context) {
 56 |         if invertColors, let image = cvm.image {
 57 |             uiView.image = invertImage(image)
 58 |         } else {
 59 |             uiView.image = cvm.image
 60 |         }
 61 | 
 62 |         if let drawingLayer = drawTransactions() {
 63 |             uiView.layer.sublayers?.removeAll()
 64 |             uiView.layer.addSublayer(drawingLayer)
 65 |         }
 66 |     }
 67 | 
 68 |     func drawTransactions(cardIsHighlighted: ((Card) -> Bool)? = nil, hideUnselected: Bool = false) -> CALayer? {
 69 |         guard let img = cvm.image else { return nil }
 70 |         let chosenCards = cvm.chosenCards
 71 |         var unusedTransactions = cvm.transactionList
 72 |         let layer = CALayer()
 73 | 
 74 |         for card in chosenCards {
 75 |             let rects: [(rect: CGRect, corners: UIRectCorner?)] = card.transactionIds.compactMap { tId in
 76 |                 if let index = unusedTransactions.firstIndex(where: { $0.id == tId }) {
 77 |                     unusedTransactions.remove(at: index)
 78 |                 }
 79 |                 if let transaction = cvm.getTransaction(tId) {
 80 |                     if transaction.shares.count > 1 {
 81 |                         if let rect = getSharedBoundingBox(transaction, card) {
 82 |                             return rect
 83 |                         }
 84 |                     } else {
 85 |                         if let box = transaction.boundingBox {
 86 |                             return (rect: box, corners: .allCorners)
 87 |                         }
 88 |                     }
 89 |                 }
 90 |                 return nil
 91 |             }
 92 |             let cardIsHighlighted = cardIsHighlighted?(card) ?? card.isActive
 93 |             let subLayer = drawRectsOnImage(rects, img, color: card.color.light, fill: true, stroke: cardIsHighlighted)
 94 |             if cardIsHighlighted {
 95 |                 layer.insertSublayer(subLayer, at: UInt32((layer.sublayers?.count ?? 1)))
 96 |             } else {
 97 |                 layer.insertSublayer(subLayer, at: 0)
 98 |             }
 99 |         }
100 |         if hideUnselected {
101 |             return layer
102 |         }
103 |         let unusedRects: [(rect: CGRect, corners: UIRectCorner?)] = unusedTransactions.compactMap { unusedTransaction in
104 |             if let box = unusedTransaction.boundingBox {
105 |                 return (rect: box, corners: .allCorners)
106 |             }
107 |             return nil
108 |         }
109 |         let unusedRectsLayer = drawRectsOnImage(unusedRects,
110 |                                                 img,
111 |                                                 color: markerColor,
112 |                                                 fill: false,
113 |                                                 stroke: true)
114 |         layer.insertSublayer(unusedRectsLayer, at: 0)
115 |         return layer
116 |     }
117 | 
118 |     func getSharedBoundingBox(_ transaction: Transaction, _ card: Card) -> (rect: CGRect, corners: UIRectCorner?)? {
119 |         let sorted = cvm.chosenCards.compactMap { card in
120 |             let share = transaction.shares[card.id]
121 |             return share == nil ? nil : (cardId: card.id, share: transaction.shares[card.id])
122 |         }
123 | 
124 |         guard let box = transaction.boundingBox,
125 |               let shareIndex = sorted.firstIndex(where: { $0.cardId == card.id }) else { return nil }
126 |         let shareCount = CGFloat(transaction.shares.count)
127 |         let width = box.width / shareCount
128 |         let indexValue = sorted.distance(from: 0, to: shareIndex)
129 |         let minX = box.minX + CGFloat(indexValue) * width
130 |         let rect = CGRect(x: minX, y: box.minY, width: width, height: box.height)
131 |         var corners: UIRectCorner? = .allCorners
132 |         if indexValue == 0 {
133 |             // left beginning
134 |             corners = [.bottomLeft, .topLeft]
135 |         } else if CGFloat(indexValue) == shareCount - 1 {
136 |             // right ending
137 |             corners = [.bottomRight, .topRight]
138 |         } else {
139 |             // center
140 |             corners = nil
141 |         }
142 |         return (rect: rect, corners: corners)
143 |     }
144 | 
145 |     private func drawRectsOnImage(_ rects: [(rect: CGRect, corners: UIRectCorner?)],
146 |                                   _ image: UIImage,
147 |                                   color: Color,
148 |                                   fill: Bool = true,
149 |                                   stroke: Bool = false) -> CALayer {
150 |         let strokeColor = UIColor(color).cgColor
151 |         let fillColor = UIColor(color.opacity(0.5)).cgColor
152 |         let lineWidth = cvm.lineWidth ?? 3.0
153 |         let layer = CALayer()
154 | 
155 |         for (rect, corners) in rects {
156 |             let sublayer = CAShapeLayer()
157 |             sublayer.contentsScale = UIScreen.main.scale
158 |             let roundRect = UIBezierPath(
159 |                 roundedRect: rect,
160 |                 byRoundingCorners: corners ?? [],
161 |                 cornerRadii: CGSize(width: rect.cornerRadius, height: rect.cornerRadius)
162 |             )
163 |             sublayer.path = roundRect.cgPath
164 |             if fill {
165 |                 sublayer.fillColor = fillColor
166 |             } else {
167 |                 sublayer.fillColor = UIColor.clear.cgColor
168 |             }
169 | 
170 |             if stroke {
171 |                 sublayer.strokeColor = strokeColor
172 |                 sublayer.lineCap = .square
173 |                 sublayer.lineWidth = lineWidth
174 |             }
175 | 
176 |             layer.addSublayer(sublayer)
177 |         }
178 |         return layer
179 |     }
180 | 
181 |     func invertImage(_ image: UIImage) -> UIImage? {
182 |         let imageIdentifier = image.hashValue
183 |         if let cachedImage = Self.invertedImageCache[imageIdentifier] {
184 |             return cachedImage
185 |         }
186 |         guard let cgImage = image.cgImage else { return nil }
187 | 
188 |         let ciImage = CIImage(cgImage: cgImage)
189 |         let filter = CIFilter(name: "CIColorInvert")
190 |         filter?.setValue(ciImage, forKey: kCIInputImageKey)
191 | 
192 |         guard let outputCIImage = filter?.outputImage,
193 |               let outputCGImage = CIContext().createCGImage(outputCIImage, from: outputCIImage.extent) else {
194 |             return nil
195 |         }
196 | 
197 |         let invertedImage = UIImage(cgImage: outputCGImage, scale: image.scale, orientation: image.imageOrientation)
198 |         Self.invertedImageCache[imageIdentifier] = invertedImage
199 | 
200 |         return invertedImage
201 |     }
202 | }
203 | 
204 | enum ExportImageError: Error {
205 |     case noImageFound
206 |     case couldntGetImageData
207 | }
208 | 
--------------------------------------------------------------------------------
/SplitBill/LiveTextInteractionExport.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  LiveTextInteractionExport.swift
  3 | //  SplitBill
  4 | //
  5 | //  Created by fer0n on 20.08.23.
  6 | //
  7 | 
  8 | import SwiftUI
  9 | import Foundation
 10 | import UIKit
 11 | 
 12 | extension LiveTextInteraction {
 13 | 
 14 |     nonisolated func generateExportImage() async throws -> UIImage? {
 15 |         guard let image = await imageView.image else {
 16 |             throw ExportImageError.noImageFound
 17 |         }
 18 |         let layer = await imageView.layer
 19 | 
 20 |         // bottom cards
 21 |         let referenceHeight = min(image.size.width, image.size.height) / 35
 22 |         let cardsLayer = await getCardsSummaryLayerWithBackground(
 23 |             cards: cvm.chosenNormalCards, referenceHeight: referenceHeight
 24 |         )
 25 |         let cardsDrawingHeight = cardsLayer.frame.height - 3
 26 |         cardsLayer.frame = CGRect(x: 0, y: image.size.height, width: image.size.width, height: cardsLayer.frame.height)
 27 | 
 28 |         // clear transaction rects && draw
 29 |         if let drawingLayer = await drawTransactions(
 30 |             cardIsHighlighted: ({ card in card.isChosen }), hideUnselected: true
 31 |         ) {
 32 |             layer.sublayers?.removeAll()
 33 |             layer.addSublayer(drawingLayer)
 34 |             layer.addSublayer(cardsLayer)
 35 |         }
 36 | 
 37 |         // Create a renderer with the size of the view
 38 |         let format = UIGraphicsImageRendererFormat()
 39 |         format.scale = 1
 40 |         let size = CGSize(width: image.size.width, height: image.size.height + cardsDrawingHeight)
 41 |         let renderer = UIGraphicsImageRenderer(size: size, format: format)
 42 | 
 43 |         // Render the image and the layer into an image
 44 |         let imageWithTransactions = renderer.image { ctx in
 45 |             layer.render(in: ctx.cgContext)
 46 |         }
 47 | 
 48 |         return imageWithTransactions
 49 |     }
 50 | 
 51 |     private func getCardsSummaryLayerWithBackground(cards: [Card], referenceHeight: CGFloat) -> CALayer {
 52 |         // values
 53 |         let padding = referenceHeight
 54 |         let seperatorHeight = referenceHeight / 6
 55 |         let seperatorColor = UIColor(Color.exportCardSeperator).cgColor
 56 |         let backgroundColor = UIColor(Color.exportCardBackground).cgColor
 57 | 
 58 |         let layer = CALayer()
 59 | 
 60 |         // cards
 61 |         let cardsLayer = getCardsArrangedLayer(cards: cards,
 62 |                                                referenceHeight: referenceHeight,
 63 |                                                cardPadding: padding / 2,
 64 |                                                maxWidth: imageView.frame.width - (padding * 2))
 65 |         let cardsX = padding
 66 |         let cardsY = padding
 67 |         cardsLayer.frame = CGRect(x: cardsX, y: cardsY, width: cardsLayer.frame.width, height: cardsLayer.frame.height)
 68 | 
 69 |         // background
 70 |         let backgroundLayer = CAShapeLayer()
 71 |         // workaround: +5 to get rid of green line at the bottom
 72 |         let rect = CGRect(x: 0, y: 0, width: imageView.frame.width, height: cardsLayer.frame.height + (padding * 2))
 73 |         backgroundLayer.path = UIBezierPath(rect: rect).cgPath
 74 |         backgroundLayer.fillColor = backgroundColor
 75 | 
 76 |         // seperator color: on top of background
 77 |         let seperatorLayer = CAShapeLayer()
 78 |         let seperatorRect = CGRect(x: 0, y: 0, width: imageView.frame.width, height: seperatorHeight)
 79 |         seperatorLayer.path = UIBezierPath(rect: seperatorRect).cgPath
 80 |         seperatorLayer.fillColor = seperatorColor
 81 | 
 82 |         layer.addSublayer(backgroundLayer)
 83 |         layer.addSublayer(seperatorLayer)
 84 |         layer.addSublayer(cardsLayer)
 85 |         layer.frame = CGRect(x: 0, y: 0, width: imageView.frame.width, height: cardsLayer.frame.height + (padding * 2))
 86 | 
 87 |         return layer
 88 |     }
 89 | 
 90 |     private func getCardsArrangedLayer(cards: [Card],
 91 |                                        referenceHeight: CGFloat,
 92 |                                        cardPadding: CGFloat,
 93 |                                        maxWidth: CGFloat) -> CALayer {
 94 |         let cardsLayer = CALayer()
 95 |         var cardsWidth = 0.0
 96 |         var currentRow = 0.0
 97 |         var cardsInCurrentRow = 0.0
 98 |         var currentRowLayer = CALayer()
 99 |         var cardLayer = CALayer()
100 | 
101 |         for card in cards {
102 |             cardLayer = getCardAsLayer(card: card, referenceHeight: referenceHeight)
103 |             let cardHeight = cardLayer.frame.height
104 |             let currentWidth = cardLayer.bounds.size.width
105 |             let totalWidth = cardsWidth + (cardPadding * (cardsInCurrentRow > 0 ? cardsInCurrentRow - 1 : 0))
106 | 
107 |             if totalWidth + currentWidth + cardPadding > maxWidth {
108 |                 let minY = currentRow * (cardHeight + cardPadding)
109 |                 let minX = (maxWidth - totalWidth) / 2
110 |                 currentRowLayer.frame = CGRect(x: minX, y: minY, width: maxWidth, height: cardHeight)
111 |                 cardsLayer.addSublayer(currentRowLayer)
112 |                 currentRowLayer = CALayer()
113 | 
114 |                 cardsInCurrentRow = 0
115 |                 currentRow += 1
116 |                 cardsWidth = 0
117 |             }
118 | 
119 |             let minX = cardsWidth + (cardPadding * cardsInCurrentRow)
120 |             cardLayer.frame = CGRect(x: minX, y: 0, width: currentWidth, height: cardHeight)
121 |             currentRowLayer.addSublayer(cardLayer)
122 | 
123 |             cardsInCurrentRow += 1
124 |             cardsWidth += currentWidth
125 | 
126 |             if cards.last == card {
127 |                 let totalWidth = cardsWidth + (cardPadding * (cardsInCurrentRow > 0 ? cardsInCurrentRow - 1 : 0))
128 |                 let minY = currentRow * (cardHeight + cardPadding)
129 |                 let minX = (maxWidth - totalWidth) / 2
130 |                 currentRowLayer.frame = CGRect(x: minX, y: minY, width: maxWidth, height: cardHeight)
131 |                 cardsLayer.addSublayer(currentRowLayer)
132 |             }
133 |         }
134 | 
135 |         let cardHeight = cardLayer.frame.height
136 |         cardsLayer.frame = CGRect(x: 0, y: 0,
137 |                                   width: maxWidth,
138 |                                   height: (currentRow + 1) * cardHeight + (currentRow * cardPadding))
139 |         return cardsLayer
140 |     }
141 | 
142 |     private func createTextLayer(text: String,
143 |                                  font: UIFont,
144 |                                  fontSize: CGFloat,
145 |                                  textColor: UIColor,
146 |                                  frame: CGRect) -> CATextLayer {
147 |         let textLayer = CATextLayer()
148 |         textLayer.string = NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: textColor])
149 |         textLayer.alignmentMode = .center
150 |         textLayer.frame = frame
151 |         textLayer.display()
152 |         return textLayer
153 |     }
154 | 
155 |     private func createRoundedRectLayer(rect: CGRect, fillColor: CGColor) -> CAShapeLayer {
156 |         let sublayer = CAShapeLayer()
157 |         sublayer.contentsScale = UIScreen.main.scale
158 |         let cornerRadius: CGFloat = 1000.0
159 |         let roundRect = UIBezierPath(
160 |             roundedRect: rect,
161 |             byRoundingCorners: [.allCorners],
162 |             cornerRadii: CGSize(width: cornerRadius, height: cornerRadius)
163 |         )
164 |         sublayer.path = roundRect.cgPath
165 |         sublayer.fillColor = fillColor
166 |         return sublayer
167 |     }
168 | 
169 |     private func getCardAsLayer(card: Card, referenceHeight: CGFloat) -> CALayer {
170 |         let layer = CALayer()
171 | 
172 |         // Constants for font sizes and padding
173 |         let valueFontSize: CGFloat = referenceHeight * 1.5
174 |         let nameFontSize: CGFloat = referenceHeight * 1
175 |         let horizontalPadding: CGFloat = referenceHeight * 1.5
176 |         let verticalPadding: CGFloat = referenceHeight
177 | 
178 |         // value
179 |         let valueFont = UIFont.rounded(ofSize: valueFontSize, weight: .heavy)
180 |         let valueString = cvm.sumString(of: card)
181 |         let valueWidth = valueString.width(withConstrainedHeight: valueFontSize * 2, font: valueFont)
182 | 
183 |         // name
184 |         let nameFont = UIFont.rounded(ofSize: nameFontSize, weight: .semibold)
185 |         let nameString = card.name
186 |         let nameWidth = nameString.width(withConstrainedHeight: nameFontSize * 2, font: nameFont)
187 | 
188 |         let cardWidth = max(valueWidth, nameWidth) + 2 * horizontalPadding // Add horizontal padding to the card width
189 |         let cardHeight = valueFontSize + nameFontSize + verticalPadding
190 | 
191 |         // Draw rounded rectangle on layer
192 |         let fillColor = UIColor(card.color.dark).cgColor
193 |         let rect = CGRect(x: 0, y: 0, width: cardWidth, height: cardHeight)
194 |         let sublayer = createRoundedRectLayer(rect: rect, fillColor: fillColor)
195 |         layer.addSublayer(sublayer)
196 | 
197 |         // Value
198 |         let valueLayerFrame = CGRect(x: 0, y: cardHeight / 2 - valueFontSize,
199 |                                      width: cardWidth, height: valueFontSize * 2)
200 |         let valueLayer = createTextLayer(text: valueString,
201 |                                          font: valueFont,
202 |                                          fontSize: valueFontSize,
203 |                                          textColor: .white,
204 |                                          frame: valueLayerFrame)
205 |         layer.addSublayer(valueLayer)
206 | 
207 |         // Name
208 |         let nameLayerFrame = CGRect(x: 0, y: cardHeight / 2, width: cardWidth, height: nameFontSize * 2)
209 |         let nameLayer = createTextLayer(text: nameString,
210 |                                         font: nameFont,
211 |                                         fontSize: nameFontSize,
212 |                                         textColor: .white,
213 |                                         frame: nameLayerFrame)
214 |         layer.addSublayer(nameLayer)
215 | 
216 |         // update layer size
217 |         layer.frame = CGRect(x: 0, y: 0, width: cardWidth, height: cardHeight)
218 | 
219 |         return layer
220 |     }
221 | }
222 | 
223 | extension String {
224 |     func width(withConstrainedHeight height: CGFloat, font: UIFont) -> CGFloat {
225 |         let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height)
226 |         let boundingBox = self.boundingRect(with: constraintRect,
227 |                                             options: .usesLineFragmentOrigin,
228 |                                             attributes: [.font: font],
229 |                                             context: nil)
230 |         return ceil(boundingBox.width)
231 |     }
232 | }
233 | 
--------------------------------------------------------------------------------
/SplitBill/MenuOptionsView.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  MenuOptionsView.swift
  3 | //  SplitBill
  4 | //
  5 | 
  6 | import SwiftUI
  7 | 
  8 | struct MenuOptionsView: View {
  9 |     @EnvironmentObject var cvm: ContentViewModel
 10 |     @AppStorage("successfulUserActionCount") var successfulUserActionCount: Int = 0
 11 | 
 12 |     @Binding var showScanner: Bool
 13 |     @Binding var showImagePicker: Bool
 14 |     @Binding var showSettings: Bool
 15 |     @Binding var hasBeenSubtracted: Bool
 16 |     @State var showDeleteImageAlert = false
 17 | 
 18 |     var size: CGFloat
 19 | 
 20 |     var body: some View {
 21 |         Menu {
 22 |             Button(role: .destructive) {
 23 |                 showDeleteImageAlert = true
 24 |             } label: {
 25 |                 Label("clearImage", systemImage: "trash.fill")
 26 |             }
 27 |             .disabled(cvm.image == nil)
 28 | 
 29 |             Divider()
 30 | 
 31 |             Button {
 32 |                 showScanner = true
 33 |                 handleSuccessfulUserActionCount()
 34 |             } label: {
 35 |                 Label("documentScanner", systemImage: "doc.viewfinder.fill")
 36 |             }
 37 |             Button {
 38 |                 showImagePicker = true
 39 |                 handleSuccessfulUserActionCount()
 40 |             } label: {
 41 |                 Label("photoLibrary", systemImage: "photo.fill.on.rectangle.fill")
 42 |             }
 43 |             Divider()
 44 | 
 45 |             Menu {
 46 |                 let img = ImageModel(getImage: getImageWithAnnotations)
 47 |                 ShareLink(item: img, preview: SharePreview(
 48 |                     "shareImage",
 49 |                     image: img
 50 |                 )) {
 51 |                     Text("shareImage")
 52 |                 }
 53 |                 ShareLink(item: cvm.getChosenCardSummary(of: cvm.chosenCards)) {
 54 |                     Text("shareSummary")
 55 |                 }
 56 |             } label: {
 57 |                 Label("share", systemImage: "square.and.arrow.up.fill")
 58 |             }
 59 |             .disabled(cvm.image == nil)
 60 |             .onAppear {
 61 |                 successfulUserActionCount += 1
 62 |             }
 63 | 
 64 |             Button {
 65 |                 showSettings = true
 66 |             } label: {
 67 |                 Label("settings", systemImage: "gearshape.fill")
 68 |             }
 69 |         } label: {
 70 |             Image(systemName: isLoading
 71 |                     ? "progress.indicator"
 72 |                     : "doc.viewfinder.fill")
 73 |                 .contentTransition(.symbolEffect(.replace))
 74 |                 .frame(width: size, height: size)
 75 |                 .myGlassEffect()
 76 |         }
 77 |         .foregroundColor(Color.foregroundColor)
 78 |         .alert("clearImage", isPresented: $showDeleteImageAlert) {
 79 |             Button("delete", role: .destructive) {
 80 |                 cvm.clearImage()
 81 |                 cvm.clearAllTransactionsAndHistory()
 82 |             }
 83 |             Button("cancel", role: .cancel) { }
 84 |         }
 85 |     }
 86 | 
 87 |     func handleSuccessfulUserActionCount() {
 88 |         if !hasBeenSubtracted {
 89 |             successfulUserActionCount -= 1
 90 |             hasBeenSubtracted = true
 91 |         }
 92 |     }
 93 | 
 94 |     var isLoading: Bool {
 95 |         cvm.isLoadingCounter != 0
 96 |     }
 97 | 
 98 |     func getImageWithAnnotations() async -> UIImage? {
 99 |         self.cvm.isLoadingCounter += 1
100 |         guard let generate = cvm.generateExportImage else {
101 |             print("vm.generateExportImage not assigned yet")
102 |             self.cvm.isLoadingCounter -= 1
103 |             return nil
104 |         }
105 |         do {
106 |             guard let image = try await generate() else {
107 |                 throw ExportImageError.noImageFound
108 |             }
109 |             self.cvm.isLoadingCounter -= 1
110 |             return image
111 |         } catch {
112 |             print("Error while trying to export image: \(error)")
113 |         }
114 |         self.cvm.isLoadingCounter -= 1
115 |         return nil
116 |     }
117 | }
118 | 
--------------------------------------------------------------------------------
/SplitBill/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 |   "info" : {
3 |     "author" : "xcode",
4 |     "version" : 1
5 |   }
6 | }
7 | 
--------------------------------------------------------------------------------
/SplitBill/ScannerView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  DocumentScannerView.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 09.12.22.
 6 | //
 7 | 
 8 | import VisionKit
 9 | import SwiftUI
10 | 
11 | struct ScannerView: UIViewControllerRepresentable {
12 |     private let completionHandler: (UIImage?) -> Void
13 | 
14 |     init(completion: @escaping (UIImage?) -> Void) {
15 |         self.completionHandler = completion
16 |     }
17 | 
18 |     typealias UIViewControllerType = VNDocumentCameraViewController
19 | 
20 |     func makeUIViewController(
21 |         context: UIViewControllerRepresentableContext
22 |     ) -> VNDocumentCameraViewController {
23 |         let viewController = VNDocumentCameraViewController()
24 |         viewController.delegate = context.coordinator
25 |         return viewController
26 |     }
27 | 
28 |     func updateUIViewController(_ uiViewController: VNDocumentCameraViewController,
29 |                                 context: UIViewControllerRepresentableContext) {}
30 | 
31 |     func makeCoordinator() -> Coordinator {
32 |         return Coordinator(completion: completionHandler)
33 |     }
34 | 
35 |     final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
36 |         private let completionHandler: (UIImage?) -> Void
37 | 
38 |         init(completion: @escaping (UIImage?) -> Void) {
39 |             self.completionHandler = completion
40 |         }
41 | 
42 |         func documentCameraViewController(_ controller: VNDocumentCameraViewController,
43 |                                           didFinishWith scan: VNDocumentCameraScan) {
44 |             let firstScan = scan.imageOfPage(at: scan.pageCount - 1)
45 |             completionHandler(firstScan)
46 |         }
47 | 
48 |         func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
49 |             completionHandler(nil)
50 |         }
51 | 
52 |         func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: Error) {
53 |             completionHandler(nil)
54 |         }
55 |     }
56 | }
57 | 
--------------------------------------------------------------------------------
/SplitBill/SelectImageView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SelectImageView.swift
 3 | //  SplitBill
 4 | //
 5 | 
 6 | import SwiftUI
 7 | 
 8 | struct SelectImageView: View {
 9 |     @Binding var showImagePicker: Bool
10 |     @Binding var showScanner: Bool
11 | 
12 |     var body: some View {
13 |         VStack {
14 |             Spacer()
15 |                 .frame(height: 40)
16 |             Button {
17 |                 self.showImagePicker = true
18 |             } label: {
19 |                 Image(systemName: "photo.fill.on.rectangle.fill")
20 |                 Spacer()
21 |                 Text("selectImage")
22 |                     .font(.system(size: 14, weight: .semibold, design: .rounded))
23 |             }
24 |             .padding([.vertical], 10)
25 |             .padding([.horizontal], 15)
26 |             .frame(maxWidth: .infinity)
27 |             .background(.white)
28 |             .foregroundColor(Color.mainColor)
29 |             .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
30 | 
31 |             Button {
32 |                 self.showScanner = true
33 |             } label: {
34 |                 Image(systemName: "doc.viewfinder.fill")
35 |                 Spacer()
36 |                 Text("openScanner")
37 |                     .font(.system(size: 14, weight: .semibold, design: .rounded))
38 |             }
39 |             .padding([.vertical], 10)
40 |             .padding([.horizontal], 15)
41 |             .frame(maxWidth: .infinity)
42 |             .background(.white)
43 |             .foregroundColor(Color.mainColor)
44 |             .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
45 |         }
46 |         .fixedSize()
47 |     }
48 | }
49 | 
--------------------------------------------------------------------------------
/SplitBill/SettingsSheet.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SettingsSheet.swift
 3 | //  SplitBill
 4 | //
 5 | 
 6 | import SwiftUI
 7 | 
 8 | struct SettingsSheet: ViewModifier {
 9 |     @Binding var show: Bool
10 | 
11 |     func body(content: Content) -> some View {
12 |         content.sheet(isPresented: $show) {
13 |             SettingsView()
14 |                 .ignoresSafeArea()
15 |         }
16 |     }
17 | }
18 | 
19 | extension View {
20 |     func settingsSheet(show: Binding) -> some View {
21 |         self.modifier(SettingsSheet(show: show))
22 |     }
23 | }
24 | 
25 | #Preview {
26 |     ZStack {
27 |         Color.red
28 |     }
29 |     .settingsSheet(
30 |         show: .constant(true),
31 |         )
32 |     .environmentObject(ContentViewModel())
33 | }
34 | 
--------------------------------------------------------------------------------
/SplitBill/SettingsView.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  SettingsView.swift
  3 | //  SplitBill
  4 | //
  5 | //  Created by fer0n on 21.12.22.
  6 | //
  7 | 
  8 | import SwiftUI
  9 | 
 10 | struct SettingsView: View {
 11 |     @Environment(\.dismiss) var dismiss
 12 |     @EnvironmentObject var cvm: ContentViewModel
 13 |     @AppStorage("startupItem") var startupItem: StartupItem = .scanner
 14 |     @AppStorage("invertImage") var invertImage = true
 15 | 
 16 |     let writeReviewUrl = URL(string: "https://apps.apple.com/app/id6444704240?action=write-review")!
 17 |     let emailUrl = URL(string: "mailto:scores.templates@gmail.com")!
 18 |     let githubUrl = URL(string: "https://github.com/fer0n/SplitBill")!
 19 | 
 20 |     var body: some View {
 21 |         NavigationStack {
 22 |             List {
 23 |                 Section {
 24 |                     Picker("openOnStartUp", selection: $startupItem) {
 25 |                         ForEach(StartupItem.allCases, id: \.self) {
 26 |                             Text($0.description)
 27 |                         }
 28 |                     }
 29 |                     .pickerStyle(.menu)
 30 |                 }
 31 | 
 32 |                 Section {
 33 |                     Toggle(isOn: $cvm.flashTransactionValue) {
 34 |                         Text("flashTransactionValue")
 35 |                     }
 36 |                     .tint(.markerColor)
 37 |                     Toggle(isOn: $invertImage) {
 38 |                         Text("invertImage")
 39 |                     }
 40 |                     .tint(.markerColor)
 41 |                     Picker("previewDuration", selection: $cvm.previewDuration) {
 42 |                         ForEach(PreviewDuration.allCases, id: \.self) {
 43 |                             Text($0.description)
 44 |                         }
 45 |                     }
 46 |                     .pickerStyle(.menu)
 47 |                     .disabled(!cvm.flashTransactionValue)
 48 |                 }
 49 | 
 50 |                 Section {
 51 |                     LinkItemView(destination: writeReviewUrl, label: "rateApp") {
 52 |                         Image(systemName: "star.fill")
 53 |                     }
 54 | 
 55 |                     LinkItemView(destination: emailUrl, label: "contact") {
 56 |                         Image(systemName: "envelope.fill")
 57 |                     }
 58 | 
 59 |                     LinkItemView(destination: githubUrl, label: "github") {
 60 |                         Image("github-logo")
 61 |                             .resizable()
 62 |                     }
 63 |                 }
 64 |             }
 65 |             .navigationBarTitle("settings")
 66 |             .navigationBarTitleDisplayMode(.inline)
 67 |         }
 68 |     }
 69 | }
 70 | 
 71 | enum PreviewDuration: Int, CaseIterable {
 72 |     case short
 73 |     case medium
 74 |     case long
 75 |     case tapAway
 76 | 
 77 |     var description: String {
 78 |         switch self {
 79 |         case .short: return String(localized: "short")
 80 |         case .medium: return String(localized: "medium")
 81 |         case .long: return String(localized: "long")
 82 |         case .tapAway: return String(localized: "tapAway")
 83 |         }
 84 |     }
 85 | 
 86 |     var timeInterval: TimeInterval? {
 87 |         switch self {
 88 |         case .short: return 0.5
 89 |         case .medium: return 2.5
 90 |         case .long: return 5
 91 |         case .tapAway: return nil
 92 |         }
 93 |     }
 94 | }
 95 | 
 96 | enum StartupItem: Int, CaseIterable {
 97 |     case nothing
 98 |     case scanner
 99 |     case imagePicker
100 | 
101 |     var description: String {
102 |         switch self {
103 |         case .nothing: return String(localized: "nothing")
104 |         case .scanner: return String(localized: "scanner")
105 |         case .imagePicker: return String(localized: "imagePicker")
106 |         }
107 |     }
108 | }
109 | 
110 | #Preview {
111 |     SettingsView()
112 |         .environmentObject(ContentViewModel())
113 | }
114 | 
--------------------------------------------------------------------------------
/SplitBill/ShareTextField.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  ShareTextField.swift
 3 | //  SplitBill
 4 | //
 5 | 
 6 | import SwiftUI
 7 | 
 8 | struct ShareTextField: View {
 9 |     let share: Share
10 |     let card: Card
11 |     let cornerRadius: CGFloat
12 | 
13 |     @Binding var floatingTransactionInfo: FloatingTransactionInfo
14 |     @State var shareValue: String
15 |     @State var attempts: Int = 0
16 | 
17 |     let editShare: (_ type: ShareEditType, _ cardId: UUID, _ value: Double?, _ onError: @escaping () -> Void) -> Void
18 | 
19 |     var body: some View {
20 |         let padding = floatingTransactionInfo.padding
21 | 
22 |         HStack(alignment: .center, spacing: 0) {
23 |             if share.manuallyAdjusted {
24 |                 Image(systemName: "arrow.uturn.backward")
25 |                     .onTapGesture {
26 |                         editShare(.reset, card.id, nil, {})
27 |                     }
28 |                     .padding(.leading, padding / 2)
29 |                 Divider()
30 |                     .overlay(card.color.contrast)
31 |                     .padding(padding / 2)
32 |             } else {
33 |                 Spacer()
34 |                     .frame(width: padding)
35 |             }
36 | 
37 |             CalcTextField(
38 |                 "",
39 |                 text: $shareValue,
40 |                 onSubmit: { result in
41 |                     guard let res = result else {
42 |                         print("ERROR: couln't parse result")
43 |                         self.attempts += 1
44 |                         return
45 |                     }
46 |                     if res != share.value {
47 |                         editShare(.edit, card.id, res, {
48 |                             if let value = share.value {
49 |                                 shareValue = String(value)
50 |                             }
51 |                         })
52 |                     }
53 |                 },
54 |                 onEditingChanged: { _ in
55 |                 },
56 |                 accentColor: card.color.uiColorFont,
57 |                 bgColor: UIColor(card.color.dark),
58 |                 textColor: UIColor(card.color.contrast),
59 |                 font: floatingTransactionInfo.uiFont
60 |             )
61 |             .padding(.trailing, padding)
62 |             .fixedSize()
63 |         }
64 |         .fixedSize()
65 |         .background(card.color.light)
66 |         .foregroundColor(card.color.contrast)
67 |         .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
68 |         .modifier(Shake(animatableData: CGFloat(self.attempts)))
69 |     }
70 | }
71 | 
--------------------------------------------------------------------------------
/SplitBill/SingleCardView.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  SingleCardView.swift
  3 | //  SplitBill
  4 | //
  5 | //  Created by fer0n on 19.08.23.
  6 | //
  7 | 
  8 | import SwiftUI
  9 | 
 10 | // NEXT: split the transaction card into its own view
 11 | // maybe the contextmenu as well
 12 | 
 13 | struct SingleCardView: View {
 14 |     @Binding public var showTransactions: Bool
 15 |     @Binding public var showEditCardSheet: Bool
 16 |     @EnvironmentObject var cvm: ContentViewModel
 17 |     var card: Card
 18 |     var toggleTransaction: () -> Void
 19 |     let isSelected: Bool
 20 |     let handleAutoScroll: () -> Void
 21 | 
 22 |     init(showTransactions: Binding,
 23 |          showEditCardSheet: Binding,
 24 |          card: Card,
 25 |          isSelected: Bool,
 26 |          toggleTransaction: @escaping () -> Void,
 27 |          handleAutoScroll: @escaping () -> Void) {
 28 |         self._showTransactions = showTransactions
 29 |         self.card = card
 30 |         self.toggleTransaction = toggleTransaction
 31 |         self.isSelected = isSelected
 32 |         self.handleAutoScroll = handleAutoScroll
 33 |         self._showEditCardSheet = showEditCardSheet
 34 |     }
 35 | 
 36 |     var body: some View {
 37 |         VStack(alignment: .center, spacing: 0) {
 38 |             transactionsCard
 39 |                 .frame(maxWidth: card.isActive && showTransactions ? nil : 0)
 40 |                 .padding(.horizontal, 10)
 41 |             singleCard
 42 |         }
 43 |     }
 44 | 
 45 |     var transactionsCard: some View {
 46 |         VStack(alignment: .center, spacing: 0) {
 47 |             if !(showTransactions && isSelected) {
 48 |                 Spacer()
 49 |                     .frame(height: 20)
 50 |             } else if card.transactionIds.count > 0 {
 51 |                 TransactionsList(card: card, isSelected: isSelected)
 52 |             } else {
 53 |                 Text("empty")
 54 |                     .padding(.vertical, 10)
 55 |                     .padding(.horizontal, 15)
 56 |                     .multilineTextAlignment(.center)
 57 |                     .font(.system(size: 13, weight: .medium, design: .rounded))
 58 |                     .italic()
 59 |                     .opacity(0.9)
 60 |             }
 61 |         }
 62 |         .foregroundColor(.white)
 63 |         .frame(minWidth: 80)
 64 |         .background(card.color.dark)
 65 |         .clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
 66 |         .padding(.bottom, 4)
 67 |         .scaleEffect(showTransactions && isSelected ? 1 : 0.5, anchor: .bottom)
 68 |         .opacity(showTransactions && isSelected ? 1 : 0)
 69 |         .clipped()
 70 |     }
 71 | 
 72 |     var singleCard: some View {
 73 |         HStack(alignment: .center, spacing: 0) {
 74 |             VStack(alignment: isSelected && showTransactions ? .leading : .center, spacing: 0) {
 75 |                 Text(cvm.sumString(of: card))
 76 |                     .minimumScaleFactor(0.01)
 77 |                     .lineLimit(1)
 78 |                     .truncationMode(.tail)
 79 |                     .font(.system(size: 20, weight: .heavy, design: .rounded))
 80 |                 Text(card.stringName)
 81 |                     .font(.system(size: 14, weight: .regular, design: .rounded))
 82 |             }
 83 |             if isSelected && showTransactions {
 84 |                 Spacer()
 85 |             }
 86 |             Image(systemName: "chevron.down.circle.fill")
 87 |                 .scaleEffect(isSelected && showTransactions ? 1 : 0.5)
 88 |                 .opacity(isSelected && showTransactions ? 1 : 0)
 89 |                 .font(.system(size: 20))
 90 |                 .frame(width: isSelected && showTransactions ? nil : 0)
 91 |         }
 92 |         .frame(minWidth: isSelected && showTransactions ? 110 : nil,
 93 |                minHeight: 50,
 94 |                maxHeight: 50)
 95 |         .padding(.leading, 22)
 96 |         .padding(.trailing, isSelected && showTransactions ? 15 : 22)
 97 |         .contentShape(Rectangle())
 98 |         .cardTapInteraction(
 99 |             showTransactions: $showTransactions,
100 |             card: card,
101 |             isSelected: isSelected,
102 |             toggleTransaction: toggleTransaction,
103 |             handleAutoScroll: handleAutoScroll
104 |         )
105 |         .padding([.top, .bottom], 1)
106 |         .foregroundColor(isSelected ? .white : card.color.font)
107 |         .cardBackground(isSelected, card.color.dark, in: clipShape)
108 |         .contextMenu {
109 |             if card.cardType == .total {
110 |                 Button(role: .destructive) {
111 |                     withAnimation {
112 |                         DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
113 |                             withAnimation {
114 |                                 cvm.removeAllTransactionsInAllCards()
115 |                             }
116 |                         }
117 |                     }
118 |                 } label: {
119 |                     Text("clearAllTransactionsInEveryCard")
120 |                     Image(systemName: "xmark.circle.fill")
121 |                 }
122 |             } else {
123 |                 Button(role: .destructive) {
124 |                     withAnimation {
125 |                         DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
126 |                             withAnimation {
127 |                                 cvm.removeAllTransactions(of: card)
128 |                             }
129 |                         }
130 |                     }
131 |                 } label: {
132 |                     Text("clearAllTransactions")
133 |                     Image(systemName: "xmark.circle.fill")
134 |                 }
135 |             }
136 |             Button(role: .destructive) {
137 |                 DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
138 |                     withAnimation {
139 |                         cvm.setCardChosen(card.id, false)
140 |                     }
141 |                 }
142 |             } label: {
143 |                 Text("removeFromSession")
144 |                 Image(systemName: "rectangle.stack.fill.badge.minus")
145 |             }
146 |             Divider()
147 |             ShareLink(item: cvm.sumString(of: card)) {
148 |                 Label("shareResult", systemImage: "123.rectangle.fill")
149 |             }
150 |             Divider()
151 |             Button {
152 |                 withAnimation {
153 |                     DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
154 |                         withAnimation {
155 |                             showEditCardSheet = true
156 |                         }
157 |                     }
158 |                 }
159 |             } label: {
160 |                 Text("editCard")
161 |                 Image(systemName: "pencil.circle.fill")
162 |             }
163 |             Divider()
164 |             if card.isActive && cvm.activeCardsIds.count > 1 && card.cardType != .total {
165 |                 Button {
166 |                     withAnimation {
167 |                         cvm.setActiveCard(card.id, value: false, multiple: true)
168 |                     }
169 |                 } label: {
170 |                     Text("setToInactive")
171 |                     Image(systemName: "rectangle.fill.badge.minus")
172 |                 }
173 |             }
174 |             if !card.isActive && card.cardType != .total {
175 |                 Button {
176 |                     withAnimation {
177 |                         cvm.setActiveCard(card.id, value: true, multiple: true)
178 |                     }
179 |                 } label: {
180 |                     Text("addToActive")
181 |                     Image(systemName: "rectangle.fill.badge.plus")
182 |                 }
183 |             }
184 |         }
185 |     }
186 | 
187 |     private var clipShape: some Shape {
188 |         RoundedRectangle(cornerRadius: 50, style: .continuous)
189 |     }
190 | }
191 | 
192 | #Preview {
193 |     SingleCardView(showTransactions: .constant(false),
194 |                    showEditCardSheet: .constant(false),
195 |                    card: Card(name: "Daniel"),
196 |                    isSelected: true,
197 |                    toggleTransaction: { },
198 |                    handleAutoScroll: { })
199 |         .frame(maxWidth: .infinity, maxHeight: .infinity)
200 |         .background {
201 |             HStack(spacing: 0) {
202 |                 Color.black
203 |                 Color.white
204 |             }
205 |         }
206 |         .environmentObject(ContentViewModel())
207 | }
208 | 
--------------------------------------------------------------------------------
/SplitBill/SplitBill.entitlements:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 | 
 5 | 	com.apple.security.application-groups
 6 | 	
 7 | 		group.splitbill
 8 | 	
 9 | 
10 | 
11 | 
--------------------------------------------------------------------------------
/SplitBill/SplitBill.swift:
--------------------------------------------------------------------------------
 1 | import SwiftUI
 2 | 
 3 | @main
 4 | struct SplitBillApp: App {
 5 |     @State var alerter = Alerter()
 6 |     @StateObject var cvm = ContentViewModel()
 7 | 
 8 |     var body: some Scene {
 9 |         WindowGroup {
10 |             ContentView()
11 |                 .environment(alerter)
12 |                 .alert(isPresented: $alerter.isShowingAlert) {
13 |                     alerter.alert ?? Alert(title: Text(""))
14 |                 }
15 |                 .environmentObject(cvm)
16 |         }
17 |     }
18 | }
19 | 
--------------------------------------------------------------------------------
/SplitBill/TransactionList.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  TransactionList.swift
  3 | //  SplitBill
  4 | //
  5 | //  Created by fer0n on 07.09.23.
  6 | //
  7 | 
  8 | import Foundation
  9 | import SwiftUI
 10 | 
 11 | struct TransactionsList: View {
 12 |     @EnvironmentObject var cvm: ContentViewModel
 13 |     var card: Card
 14 |     var isSelected: Bool
 15 | 
 16 |     @State var attempts: Int = 0
 17 |     @State var transactionToEdit: Transaction?
 18 |     @State var newTransactionValue: String = ""
 19 | 
 20 |     var body: some View {
 21 |         ScrollView(.vertical) {
 22 |             VStack(alignment: .leading, spacing: 0) {
 23 |                 Grid(verticalSpacing: 0) {
 24 |                     ForEach(cvm.sortedTransactions(of: card), id: \.self) { tId in
 25 |                         if let transaction = cvm.getTransaction(tId) {
 26 |                             if transaction.type == .divider {
 27 |                                 CardSpacer()
 28 |                                     .gridCellColumns(2)
 29 |                             } else {
 30 |                                 labelRowItem(transaction)
 31 |                             }
 32 |                         }
 33 |                     }
 34 |                 }
 35 |             }
 36 |             .padding(.horizontal, 15)
 37 |             .padding(.vertical, 10)
 38 |             .frame(minWidth: isSelected ? 100 : nil, alignment: .leading)
 39 |         }
 40 |         .frame(maxHeight: 250)
 41 |         .fixedSize()
 42 |     }
 43 | 
 44 |     func transactionTextField(_ transaction: Transaction) -> some View {
 45 |         CalcTextField(transaction.getStringValue(for: card),
 46 |                       text: (transaction == transactionToEdit)
 47 |                         ? $newTransactionValue
 48 |                         : .constant(transaction.getStringValue(for: card)),
 49 |                       onSubmit: { result in
 50 |                         guard let res = result else {
 51 |                             self.attempts += 1
 52 |                             return
 53 |                         }
 54 |                         if res != 0 {
 55 |                             cvm.editTransaction(transaction.id, value: res, card)
 56 |                         }
 57 |                         newTransactionValue = ""
 58 |                         transactionToEdit = nil
 59 |                       }, onEditingChanged: { edit in
 60 |                         if edit {
 61 |                             transactionToEdit = transaction
 62 |                         } else {
 63 |                             transactionToEdit = nil
 64 |                             newTransactionValue = ""
 65 |                         }
 66 |                       },
 67 |                       accentColor: card.color.uiColorFont,
 68 |                       bgColor: UIColor(card.color.dark),
 69 |                       textColor: UIColor(card.color.contrast)
 70 |         )
 71 |         .padding(.horizontal, 5)
 72 |         .gridColumnAlignment(transaction.label != nil ? .trailing : .leading)
 73 |         .lineLimit(1)
 74 |         .fixedSize()
 75 |         .onDisappear {
 76 |             transactionToEdit = nil
 77 |             newTransactionValue = ""
 78 |         }
 79 |         .disabled(transaction.locked)
 80 |         .background(
 81 |             RoundedRectangle(cornerRadius: 7)
 82 |                 .foregroundColor(Color.black.opacity(0.1))
 83 |         )
 84 |         .padding(.top, 3)
 85 |         .modifier(Shake(animatableData: CGFloat(self.attempts), isActive: transaction == transactionToEdit))
 86 |     }
 87 | 
 88 |     func labelRowItem(_ transaction: Transaction) -> some View {
 89 |         GridRow {
 90 |             Text(transaction.label ?? "")
 91 |                 .font(.system(size: 12, weight: .semibold, design: .rounded))
 92 |                 .opacity(0.6)
 93 |                 .gridColumnAlignment(.leading)
 94 |             transactionTextField(transaction)
 95 |         }
 96 |         .contentShape(Rectangle())
 97 |         .contextMenu {
 98 |             Button {
 99 |                 DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
100 |                     withAnimation {
101 |                         cvm.removeTransaction(transaction.id, of: card.id)
102 |                     }
103 |                 }
104 |             } label: {
105 |                 Text("deleteTransaction")
106 |                 Image(systemName: "minus.circle.fill")
107 |             }
108 |             if transaction.shares.contains(where: { $0.value.manuallyAdjusted }) {
109 |                 Button {
110 |                     withAnimation {
111 |                         cvm.resetShare(transaction, of: card)
112 |                     }
113 |                 } label: {
114 |                     Text("resetManualShare")
115 |                     Image(systemName: "eraser.fill")
116 |                 }
117 |             }
118 |         }
119 |     }
120 | }
121 | 
--------------------------------------------------------------------------------
/SplitBill/TransactionModel.swift:
--------------------------------------------------------------------------------
  1 | //
  2 | //  TransactionModel.swift
  3 | //  SplitBill
  4 | //
  5 | //  Created by fer0n on 21.08.23.
  6 | //
  7 | 
  8 | import Foundation
  9 | import SwiftUI
 10 | 
 11 | // MARK: Share
 12 | struct Share: Codable, Hashable {
 13 |     var value: Double?
 14 |     var manuallyAdjusted: Bool = false
 15 |     var cardId: UUID
 16 |     var locked: Bool = false
 17 | }
 18 | 
 19 | enum EditShareError: Error {
 20 |     case shareForCardNotFound
 21 |     case lastShareCannotBeAdjustedManually
 22 |     case numberTooLarge
 23 | }
 24 | 
 25 | // MARK: Transaction
 26 | enum TransActionType: Int {
 27 |     case normal
 28 |     case total
 29 |     case freeForm
 30 |     case divider
 31 |     case cardSummary
 32 | }
 33 | 
 34 | enum TransactionCodingKeys: CodingKey {
 35 |     case id
 36 |     case rawValue
 37 |     case boundingBox
 38 |     case type
 39 |     case rawLabel
 40 |     case locked
 41 |     case shares
 42 | }
 43 | 
 44 | struct Transaction: Identifiable, Equatable, Codable {
 45 |     let id: UUID
 46 |     var rawValue: Double
 47 |     let boundingBox: CGRect?
 48 |     var type: TransActionType = .normal
 49 |     var rawLabel: String?
 50 |     var locked: Bool = false
 51 |     var shares: [UUID: Share] = [:]
 52 | 
 53 |     init(from decoder: Decoder) throws {
 54 |         let container = try decoder.container(keyedBy: TransactionCodingKeys.self)
 55 |         id = try container.decode(UUID.self, forKey: .id)
 56 |         rawValue = try container.decode(Double.self, forKey: .rawValue)
 57 |         boundingBox = try container.decodeIfPresent(CGRect.self, forKey: .boundingBox)
 58 |         let rawTransactionType = try container.decode(Int.self, forKey: .type)
 59 |         type = TransActionType(rawValue: rawTransactionType) ?? .normal
 60 |         rawLabel = try container.decodeIfPresent(String.self, forKey: .rawLabel)
 61 |         locked = try container.decodeIfPresent(Bool.self, forKey: .locked) ?? false
 62 |         shares = try container.decodeIfPresent([UUID: Share].self, forKey: .shares) ?? [:]
 63 |     }
 64 | 
 65 |     func encode(to encoder: Encoder) throws {
 66 |         var container = encoder.container(keyedBy: TransactionCodingKeys.self)
 67 |         try container.encode(id, forKey: .id)
 68 |         try container.encode(rawValue, forKey: .rawValue)
 69 |         try container.encodeIfPresent(boundingBox, forKey: .boundingBox)
 70 |         let rawTransactionType = type.rawValue
 71 |         try container.encode(rawTransactionType, forKey: .type)
 72 |         try container.encodeIfPresent(rawLabel, forKey: .rawLabel)
 73 |         try container.encodeIfPresent(locked, forKey: .locked)
 74 |         try container.encode(shares, forKey: .shares)
 75 |     }
 76 | 
 77 |     var value: Double {
 78 |         get {
 79 |             type == .total ? -rawValue : rawValue
 80 |         }
 81 |         set {
 82 |             if !locked {
 83 |                 rawValue = newValue
 84 |             }
 85 |         }
 86 |     }
 87 | 
 88 |     func getValue(for card: Card) -> Double {
 89 |         shares[card.id]?.value ?? value
 90 |     }
 91 | 
 92 |     func getStringValue(for card: Card) -> String {
 93 |         let val = shares[card.id]?.value ?? value
 94 |         return String(round(100 * val) / 100)
 95 |     }
 96 | 
 97 |     var stringValue: String {
 98 |         String(round(100 * value) / 100)
 99 |     }
100 | 
101 |     var description: String {
102 |         type != .divider ? "\(label ?? ""): \(value)" : ""
103 |     }
104 | 
105 |     mutating func addShare(cardId: UUID, share: Double? = nil) throws {
106 |         shares[cardId] = Share(value: share, cardId: cardId)
107 |         try refreshShares()
108 |     }
109 | 
110 |     mutating func removeShare(cardId: UUID) throws {
111 |         shares[cardId] = nil
112 |         try refreshShares()
113 |     }
114 | 
115 |     mutating func resetShare(cardId: UUID) throws {
116 |         shares[cardId]?.manuallyAdjusted = false
117 |         try refreshShares()
118 |     }
119 | 
120 |     mutating func editShare(cardId: UUID, value: Double?) throws {
121 |         guard var share = shares[cardId] else {
122 |             throw EditShareError.shareForCardNotFound
123 |         }
124 |         if !share.manuallyAdjusted && shares.count > 1 && hasOnlyOneNotManuallyAdjustedShare {
125 |             throw EditShareError.lastShareCannotBeAdjustedManually
126 |         }
127 | 
128 |         share.value = value
129 |         share.manuallyAdjusted = true
130 |         shares[cardId] = share
131 |         try refreshShares()
132 |     }
133 | 
134 |     var hasOnlyOneNotManuallyAdjustedShare: Bool {
135 |         let manuallyAdjustedShares = shares.filter { !$0.value.manuallyAdjusted }
136 |         return manuallyAdjustedShares.count == 1
137 |     }
138 | 
139 |     mutating func refreshShares() throws {
140 |         var remaining = value
141 |         var count = 0
142 |         for (_, share) in shares {
143 |             if share.manuallyAdjusted {
144 |                 remaining -= share.value!
145 |             } else {
146 |                 count += 1
147 |             }
148 |         }
149 |         guard count > 0 else { return }
150 |         let splits = try splitAmount(amount: remaining, numParts: count)
151 |         for (id, share) in shares where !share.manuallyAdjusted {
152 |             count -= 1
153 |             shares[id]?.value = splits[safe: count]
154 |         }
155 |     }
156 | 
157 |     func splitAmount(amount: Double, numParts: Int) throws -> [Double] {
158 |         if !(numParts > 0) { return [] }
159 |         let roundedAmount = round(amount * 100)
160 |         if roundedAmount > Double(Int.max) {
161 |             throw EditShareError.numberTooLarge
162 |         }
163 |         var result: [Double] = []
164 |         var remainder = Int(roundedAmount) // throw away more than two decimals
165 |         for index in stride(from: numParts, to: 0, by: -1) {
166 |             let res = remainder / index
167 |             remainder -= res
168 |             result.append(Double(res) / 100.0)
169 |         }
170 |         return result
171 |     }
172 | 
173 |     var label: String? {
174 |         get {
175 |             if type == .total {
176 |                 return String(localized: "total")
177 |             } else if shares.count > 1 {
178 |                 return String(localized: "shared")
179 |             } else {
180 |                 return rawLabel
181 |             }
182 |         }
183 |         set {
184 |             rawLabel = newValue
185 |         }
186 |     }
187 | 
188 |     init(value: Double,
189 |          boundingBox: CGRect? = nil,
190 |          label: String? = nil,
191 |          transactionType: TransActionType? = nil,
192 |          locked: Bool? = nil,
193 |          id: UUID? = nil) {
194 |         self.rawValue = value
195 |         self.boundingBox = boundingBox
196 |         self.rawLabel = label
197 |         self.type = transactionType ?? .normal
198 |         self.locked = locked ?? false
199 |         self.id = id ?? UUID()
200 |     }
201 | 
202 |     init(from transaction: Transaction,
203 |          value: Double? = nil,
204 |          boundingBox: CGRect? = nil,
205 |          label: String? = nil,
206 |          transactionType: TransActionType? = nil,
207 |          locked: Bool? = nil,
208 |          id: UUID? = nil) {
209 |         self.rawValue = value ?? transaction.value
210 |         self.boundingBox = boundingBox ?? transaction.boundingBox
211 |         self.rawLabel = label ?? transaction.label
212 |         self.type = transactionType ?? transaction.type
213 |         self.locked = locked ?? transaction.locked
214 |         self.id = id ?? transaction.id
215 |     }
216 | 
217 |     static func == (lhs: Transaction, rhs: Transaction) -> Bool {
218 |         lhs.id == rhs.id
219 |     }
220 | }
221 | 
--------------------------------------------------------------------------------
/SplitBill/UndoRedoStackView.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  UndoRedoStackView.swift
 3 | //  SplitBill
 4 | //
 5 | 
 6 | import SwiftUI
 7 | 
 8 | struct UndoRedoStackView: View {
 9 |     @Environment(\.undoManager) var undoManager
10 |     @EnvironmentObject var cvm: ContentViewModel
11 |     var size: CGFloat
12 | 
13 |     var body: some View {
14 |         let canUndo = undoManager?.canUndo ?? false
15 |         let canRedo = undoManager?.canRedo ?? false
16 |         let undoDisabled = canRedo && !canUndo
17 | 
18 |         return VStack(alignment: .trailing, spacing: 10) {
19 |             Button {
20 |                 withAnimation {
21 |                     undoManager?.undo()
22 |                 }
23 |             } label: {
24 |                 Image(systemName: "arrow.uturn.backward")
25 |                     .frame(width: size, height: size)
26 |                     .foregroundColor(Color.foregroundColor.opacity(undoDisabled ? 0.3 : 1))
27 |                     .animation(nil, value: UUID())
28 |             }
29 |             .myGlassEffect(interactive: true)
30 |             .disabled(undoDisabled)
31 |             .animation(nil, value: UUID())
32 |             .opacity(canUndo || canRedo ? 1 : 0)
33 | 
34 |             if canRedo {
35 |                 Button {
36 |                     withAnimation {
37 |                         undoManager?.redo()
38 |                     }
39 |                 } label: {
40 |                     Image(systemName: "arrow.uturn.forward")
41 |                         .frame(width: size, height: size)
42 |                 }
43 |                 .myGlassEffect(interactive: true)
44 |                 .animation(nil, value: UUID())
45 |             }
46 |         }
47 |     }
48 | }
49 | 
--------------------------------------------------------------------------------
/SplitBill/Util/Alerter.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Alerter.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 20.02.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | import Observation
11 | 
12 | @Observable class Alerter {
13 |     var alert: Alert? {
14 |         didSet { isShowingAlert = alert != nil }
15 |     }
16 |     var isShowingAlert = false
17 | }
18 | 
--------------------------------------------------------------------------------
/SplitBill/Util/Colors.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Colors.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 12.12.22.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | 
11 | extension Color {
12 |     static let mainColor = Color("MainColor")
13 |     static let markerColor = Color("MarkerColor")
14 |     static let backgroundColor = Color(UIColor.systemGray6)
15 |     static let foregroundColor = Color(UIColor.label)
16 |     static let labelColor = Color("LabelColor")
17 |     static let exportCardBackground = Color("exportCardBackground")
18 |     static let exportCardSeperator = Color("exportCardSeperator")
19 | }
20 | 
21 | enum ColorKeys: Int, CaseIterable {
22 |     case neutralDark
23 |     case cardRed
24 |     case cardBlue
25 |     case cardLightBlue
26 |     case neutralGray
27 |     case cardEmerald
28 |     case cardYellow
29 |     case cardThistle
30 | }
31 | 
32 | struct ColorInfo {
33 |     let dark: String
34 |     let light: String
35 |     let font: String
36 | }
37 | 
38 | struct CardColor {
39 |     var id: ColorKeys
40 |     let dark: Color
41 |     let light: Color
42 |     let font: Color
43 |     var contrast: Color = .white
44 |     let uiColorFont: UIColor
45 | 
46 |     static let colorInfo: [ColorKeys: ColorInfo] = [
47 |         .neutralDark: ColorInfo(dark: "cardDark", light: "cardDarkLight", font: "cardDarkFont"),
48 |         .neutralGray: ColorInfo(dark: "cardGray", light: "cardGrayLight", font: "cardGrayFont"),
49 |         .cardBlue: ColorInfo(dark: "cardBlue", light: "cardBlueLight", font: "cardBlueFont"),
50 |         .cardYellow: ColorInfo(dark: "cardYellow", light: "cardYellowLight", font: "cardYellowFont"),
51 |         .cardRed: ColorInfo(dark: "cardRed", light: "cardRedLight", font: "cardRedFont"),
52 |         .cardLightBlue: ColorInfo(dark: "cardLightBlue", light: "cardLightBlueLight", font: "cardLightBlueFont"),
53 |         .cardEmerald: ColorInfo(dark: "cardEmerald", light: "cardEmeraldLight", font: "cardEmeraldFont"),
54 |         .cardThistle: ColorInfo(dark: "cardThistle", light: "cardThistleLight", font: "cardThistleFont")
55 |     ]
56 | 
57 |     static func get(_ id: ColorKeys) -> CardColor {
58 |         if let colorInfo = colorInfo[id] {
59 |             let darkColor = Color(colorInfo.dark)
60 |             let lightColor = Color(colorInfo.light)
61 |             let fontColor = Color(colorInfo.font)
62 |             let uiColorFont = UIColor(named: colorInfo.font) ?? UIColor(fontColor)
63 | 
64 |             return CardColor(id: id, dark: darkColor, light: lightColor, font: fontColor, uiColorFont: uiColorFont)
65 |         } else {
66 |             print("Error: couldn't find color \(id)")
67 |             return CardColor(id: id, dark: .black, light: .white, font: .white, uiColorFont: .white)
68 |         }
69 |     }
70 | }
71 | 
--------------------------------------------------------------------------------
/SplitBill/Util/OneHandedZoomGesture.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  OneHandedZoomGesture.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 09.12.22.
 6 | //
 7 | 
 8 | import Foundation
 9 | import UIKit
10 | 
11 | enum ZoomGestureStatus {
12 |     case unknown
13 |     case firstTouchDown
14 |     case touchUp
15 |     case secondTouchDown
16 | }
17 | 
18 | class OneHandedZoomGestureRecognizer: UIGestureRecognizer {
19 |     public static var doubleTapGestureThreshold: CFTimeInterval = 0.3
20 | 
21 |     var ignoreTapsAt: ((_ point: CGPoint) -> Bool)?
22 | 
23 |     private var lastTouchTime: CFTimeInterval = CACurrentMediaTime()
24 |     private(set) var status = ZoomGestureStatus.unknown
25 |     private(set) var locationTapThreshold: Float = 15
26 |     private var lastTapLocation: CGPoint?
27 | 
28 |     var yOffset: CGFloat = 0
29 | 
30 |     override func touchesBegan(_ touches: Set, with event: UIEvent) {
31 |         let currentTime = CACurrentMediaTime()
32 | 
33 |         let location = touches.first?.location(in: self.view?.window)
34 | 
35 |         let timeDiff: CFTimeInterval = currentTime - lastTouchTime
36 | 
37 |         if status == .touchUp, timeDiff < OneHandedZoomGestureRecognizer.doubleTapGestureThreshold,
38 |            let location = location,
39 |            let lastTapLocation = lastTapLocation {
40 |             let distDiff = hypotf(Float((location.x - lastTapLocation.x)), Float((location.y - lastTapLocation.y)))
41 | 
42 |             if let ignoreTapsAt = ignoreTapsAt,
43 |                let viewLocation = touches.first?.location(in: self.view) {
44 |                 let ignore = ignoreTapsAt(viewLocation)
45 |                 if ignore {
46 |                     return
47 |                 }
48 |             }
49 | 
50 |             if distDiff < locationTapThreshold {
51 |                 status = .secondTouchDown
52 |                 super.touchesBegan(touches, with: event)
53 |                 self.state = .began
54 |                 self.lastTapLocation = location
55 |             }
56 | 
57 |         } else {
58 |             status = .firstTouchDown
59 |             let location = touches.first?.location(in: self.view?.window)
60 |             lastTapLocation = location
61 |         }
62 |         lastTouchTime = currentTime
63 |     }
64 | 
65 |     override func touchesMoved(_ touches: Set, with event: UIEvent) {
66 |         if status == .secondTouchDown {
67 |             let location = touches.first?.location(in: self.view?.window)
68 |             guard let lastTapLocation = lastTapLocation,
69 |                   let location = location else { return }
70 |             yOffset = location.y - lastTapLocation.y
71 | 
72 |             super.touchesMoved(touches, with: event)
73 |             self.state = .changed
74 |         }
75 |     }
76 | 
77 |     override func touchesEnded(_ touches: Set, with event: UIEvent) {
78 |         if status == .firstTouchDown {
79 |             status = .touchUp
80 |         } else if status == .secondTouchDown {
81 |             status = .unknown
82 |             super.touchesEnded(touches, with: event)
83 |             self.state = .ended
84 |         }
85 |     }
86 | }
87 | 
--------------------------------------------------------------------------------
/SplitBill/Util/util.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  util.swift
 3 | //  SplitBill
 4 | //
 5 | //  Created by fer0n on 26.11.22.
 6 | //
 7 | 
 8 | import Foundation
 9 | import SwiftUI
10 | 
11 | func validateNumberFormat(_ str: String) throws -> String? {
12 |     let regexUsNumber = try NSRegularExpression(pattern: "^(- ?)?\\d+(?:,\\d{3})*\\.?\\d*$")
13 |     let matchUsNumber = regexUsNumber.firstMatch(in: str, range: NSRange(str.startIndex..., in: str))
14 |     var matchEuNumber: NSTextCheckingResult?
15 | 
16 |     if matchUsNumber == nil {
17 |         let regexEuNumber = try NSRegularExpression(pattern: "^(- ?)?\\d+(?:\\.\\d{3})*,?\\d*$")
18 |         matchEuNumber = regexEuNumber.firstMatch(in: str, range: NSRange(str.startIndex..., in: str))
19 | 
20 |         if matchEuNumber == nil {
21 |             throw NSError(domain: "Invalid number format",
22 |                           code: 0,
23 |                           userInfo: [NSLocalizedDescriptionKey: "Invalid number format \(str)"])
24 |         }
25 |     }
26 | 
27 |     if matchUsNumber != nil {
28 |         return "."
29 |     } else if matchEuNumber != nil {
30 |         return ","
31 |     } else {
32 |         return nil
33 |     }
34 | }
35 | 
36 | func findNumberIndices(_ input: String) -> [(range: Range, decimalPoint: String?)] {
37 |     guard let fuzzyRegex = try? NSRegularExpression(pattern: "(?:- ?)?\\d+(?:[,.]\\d{3})*(?:[,.]\\d+)?\\b") else {
38 |         return []
39 |     }
40 |     let potentialNumbers = fuzzyRegex.matches(in: input, range: NSRange(input.startIndex..., in: input))
41 |     var matchIndices: [(range: Range, decimalPoint: String?)] = []
42 | 
43 |     for number in potentialNumbers {
44 |         let match = input[Range(number.range, in: input)!]
45 |         var decimalPoint: String?
46 |         do {
47 |             decimalPoint = try validateNumberFormat(String(match))
48 |         } catch {
49 |             continue
50 |         }
51 |         let range = Range(number.range, in: input)!
52 |         matchIndices.append((range, decimalPoint))
53 |     }
54 | 
55 |     return matchIndices
56 | }
57 | 
58 | func cleanNumberString(input: String, decimalPoint: String?) -> Double? {
59 |     var str = input.replacingOccurrences(of: " ", with: "")
60 |     if decimalPoint == "." {
61 |         str = str.replacingOccurrences(of: ",", with: "")
62 |     } else if decimalPoint == "," {
63 |         str = str.replacingOccurrences(of: ".", with: "")
64 |     }
65 |     str = str.replacingOccurrences(of: ",", with: ".")
66 |     return Double.parse(from: str)
67 | }
68 | 
--------------------------------------------------------------------------------
/SplitBill/ZoomableScrollView.swift:
--------------------------------------------------------------------------------
  1 | import SwiftUI
  2 | import Combine
  3 | 
  4 | class CenteringScrollView: UIScrollView {
  5 |     func centerContent() {
  6 |         assert(subviews.count == 1)
  7 |         mutate(&subviews[0].frame) {
  8 |             // not clear why view.center.{x,y} = bounds.mid{X,Y} doesn't work -- maybe transform?
  9 |             $0.origin.x = max(0, bounds.width - $0.width) / 2
 10 |             $0.origin.y = max(0, bounds.height - $0.height) / 2
 11 |         }
 12 |     }
 13 | 
 14 |     override func layoutSubviews() {
 15 |         super.layoutSubviews()
 16 |         centerContent()
 17 |     }
 18 | }
 19 | 
 20 | struct ZoomableScrollView: View {
 21 |     let content: Content
 22 |     let contentPadding: CGFloat
 23 |     let ignoreTapsAt: (CGPoint) -> Bool
 24 |     let onGestureHasBegun: () -> Void
 25 |     weak var contentChanged: PassthroughSubject?
 26 | 
 27 |     init(contentPadding: CGFloat,
 28 |          ignoreTapsAt: @escaping (CGPoint) -> Bool,
 29 |          onGestureHasBegun: @escaping () -> Void,
 30 |          contentChanged: PassthroughSubject,
 31 |          @ViewBuilder content: () -> Content) {
 32 |         self.content = content()
 33 |         self.ignoreTapsAt = ignoreTapsAt
 34 |         self.contentPadding = contentPadding
 35 |         self.contentChanged = contentChanged
 36 |         self.onGestureHasBegun = onGestureHasBegun
 37 |     }
 38 | 
 39 |     var body: some View {
 40 |         ZoomableScrollViewImpl(content: content,
 41 |                                contentPadding: contentPadding,
 42 |                                ignoreTapsAt: self.ignoreTapsAt,
 43 |                                onGestureHasBegun: self.onGestureHasBegun,
 44 |                                contentChanged: contentChanged?.eraseToAnyPublisher())
 45 |     }
 46 | }
 47 | 
 48 | private struct ZoomableScrollViewImpl: UIViewControllerRepresentable {
 49 |     let content: Content
 50 |     let contentPadding: CGFloat
 51 |     let ignoreTapsAt: (CGPoint) -> Bool
 52 |     let onGestureHasBegun: () -> Void
 53 |     let contentChanged: AnyPublisher?
 54 | 
 55 |     func makeUIViewController(context: Context) -> ViewController {
 56 |         return ViewController(coordinator: context.coordinator,
 57 |                               contentPadding: contentPadding,
 58 |                               ignoreTapsAt: self.ignoreTapsAt,
 59 |                               onGestureHasBegun: self.onGestureHasBegun,
 60 |                               contentChanged: contentChanged)
 61 |     }
 62 | 
 63 |     func makeCoordinator() -> Coordinator {
 64 |         return Coordinator(hostingController: UIHostingController(rootView: self.content))
 65 |     }
 66 | 
 67 |     func updateUIViewController(_ viewController: ViewController, context: Context) {
 68 |         viewController.update(content: self.content, contentChanged: contentChanged)
 69 |     }
 70 | 
 71 |     // MARK: - ViewController
 72 |     class ViewController: UIViewController, UIScrollViewDelegate {
 73 |         let contentPadding: CGFloat
 74 |         let coordinator: Coordinator
 75 |         private var oldZoomScale: CGFloat?
 76 |         let scrollView = CenteringScrollView()
 77 |         let onGestureHasBegun: () -> Void
 78 | 
 79 |         var requestZoomAndScrollReset: Bool = false
 80 |         var contentChangedCancellable: Cancellable?
 81 |         var updateConstraintsCancellable: Cancellable?
 82 | 
 83 |         private var hostedView: UIView { coordinator.hostingController.view! }
 84 | 
 85 |         private var contentSizeConstraints: [NSLayoutConstraint] = [] {
 86 |             willSet { NSLayoutConstraint.deactivate(contentSizeConstraints) }
 87 |             didSet { NSLayoutConstraint.activate(contentSizeConstraints) }
 88 |         }
 89 | 
 90 |         required init?(coder: NSCoder) { fatalError() }
 91 |         init(coordinator: Coordinator,
 92 |              contentPadding: CGFloat,
 93 |              ignoreTapsAt: @escaping (CGPoint) -> Bool,
 94 |              onGestureHasBegun: @escaping () -> Void,
 95 |              contentChanged: AnyPublisher?) {
 96 |             self.coordinator = coordinator
 97 |             self.contentPadding = contentPadding
 98 |             self.onGestureHasBegun = onGestureHasBegun
 99 |             super.init(nibName: nil, bundle: nil)
100 |             self.view = scrollView
101 | 
102 |             let gesture = OneHandedZoomGestureRecognizer(target: self, action: #selector(handleZoomGesture))
103 |             gesture.ignoreTapsAt = ignoreTapsAt
104 |             scrollView.addGestureRecognizer(gesture)
105 | 
106 |             scrollView.delegate = self  // for viewForZooming(in:)
107 |             scrollView.maximumZoomScale = 10
108 |             scrollView.minimumZoomScale = 1
109 |             scrollView.bouncesZoom = true
110 |             scrollView.showsHorizontalScrollIndicator = false
111 |             scrollView.showsVerticalScrollIndicator = false
112 |             scrollView.clipsToBounds = false
113 |             scrollView.scrollsToTop = false
114 | 
115 |             let hostedView = coordinator.hostingController.view!
116 |             hostedView.translatesAutoresizingMaskIntoConstraints = false
117 |             scrollView.addSubview(hostedView)
118 |             NSLayoutConstraint.activate([
119 |                 hostedView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
120 |                 hostedView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
121 |                 hostedView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
122 |                 hostedView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor)
123 |             ])
124 | 
125 |             updateConstraintsCancellable = scrollView.publisher(for: \.bounds).map(\.size).removeDuplicates()
126 |                 .sink { [unowned self] _ in
127 |                     view.setNeedsUpdateConstraints()
128 |                 }
129 |             contentChangedCancellable = contentChanged?.sink { [unowned self] in handleContentChanged() }
130 |         }
131 | 
132 |         @objc func handleZoomGesture(_ sender: OneHandedZoomGestureRecognizer) {
133 |             if sender.state == .began {
134 |                 self.onGestureHasBegun()
135 |                 oldZoomScale = scrollView.zoomScale
136 |             } else if sender.state == .changed {
137 |                 guard let oldZoomScale = oldZoomScale else { return }
138 | 
139 |                 let zoomFactor: CGFloat = 0.005
140 |                 let zoomChange = sender.yOffset * zoomFactor
141 | 
142 |                 // Calculate the new zoom scale using a logarithmic approach
143 |                 // (otherwise zooming while being close feels slow and zooming while being further away feel too fast)
144 |                 let logOldZoomScale = log(oldZoomScale)
145 |                 let logNewZoomScale = logOldZoomScale - zoomChange
146 |                 let newZoomScale = exp(logNewZoomScale)
147 |                 scrollView.setZoomScale(newZoomScale, animated: true)
148 |             }
149 |         }
150 | 
151 |         func handleContentChanged() {
152 |             requestZoomAndScrollReset = true
153 |         }
154 | 
155 |         func update(content: Content, contentChanged: AnyPublisher?) {
156 |             coordinator.hostingController.rootView = content
157 |             scrollView.setNeedsUpdateConstraints()
158 |             contentChangedCancellable = contentChanged?.sink { [unowned self] in handleContentChanged() }
159 |         }
160 | 
161 |         override func updateViewConstraints() {
162 |             super.updateViewConstraints()
163 |             let hostedContentSize = coordinator.hostingController.sizeThatFits(in: view.bounds.size)
164 |             contentSizeConstraints = [
165 |                 hostedView.widthAnchor.constraint(equalToConstant: hostedContentSize.width),
166 |                 hostedView.heightAnchor.constraint(equalToConstant: hostedContentSize.height)
167 |             ]
168 |         }
169 | 
170 |         func resetZoom() {
171 |             let padding = self.contentPadding
172 |             let bounds = hostedView.bounds
173 |             let viewRect = CGRect(x: bounds.minX + padding,
174 |                                   y: bounds.minY + padding,
175 |                                   width: bounds.width - (padding * 2),
176 |                                   height: bounds.height - (padding * 2))
177 |             scrollView.zoom(to: viewRect, animated: false)
178 |         }
179 | 
180 |         override func viewDidAppear(_ animated: Bool) {
181 |             resetZoom()
182 |         }
183 | 
184 |         override func viewDidLayoutSubviews() {
185 |             super.viewDidLayoutSubviews()
186 |             let hostedContentSize = coordinator.hostingController.sizeThatFits(in: view.bounds.size)
187 |             scrollView.minimumZoomScale = min(
188 |                 scrollView.bounds.width / hostedContentSize.width,
189 |                 scrollView.bounds.height / hostedContentSize.height)
190 | 
191 |             if requestZoomAndScrollReset {
192 |                 resetZoom()
193 |                 self.scrollView.centerContent()
194 |                 requestZoomAndScrollReset = false
195 |             }
196 |         }
197 | 
198 |         func scrollViewDidZoom(_ scrollView: UIScrollView) {
199 |             self.scrollView.centerContent()
200 |         }
201 | 
202 |         override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
203 |             coordinator.animate { [self] _ in
204 |                 scrollView.zoom(to: hostedView.bounds, animated: false)
205 |             }
206 |         }
207 | 
208 |         func viewForZooming(in scrollView: UIScrollView) -> UIView? {
209 |             return hostedView
210 |         }
211 |     }
212 | 
213 |     // MARK: - Coordinator
214 |     class Coordinator: NSObject, UIScrollViewDelegate {
215 |         var hostingController: UIHostingController
216 | 
217 |         init(hostingController: UIHostingController) {
218 |             self.hostingController = hostingController
219 |         }
220 |     }
221 | }
222 | 
223 | public func mutate(_ arg: inout T, _ body: (inout T) -> Void) {
224 |     body(&arg)
225 | }
226 | 
--------------------------------------------------------------------------------
/SplitBill/de.lproj/Localizable.strings:
--------------------------------------------------------------------------------
 1 | /* 
 2 |   Localizable.strings
 3 |   SplitBill
 4 | 
 5 |   Created by fer0n on 21.12.22.
 6 |   
 7 | */
 8 | 
 9 | // Shared
10 | "settings" = "Einstellungen";
11 | "delete" = "Löschen";
12 | "cancel" = "Abbrechen";
13 | 
14 | // ContentView
15 | "selectImage" = "Bild auswählen";
16 | "openScanner" = "Scanner öffnen";
17 | "replaceImage" = "Ein neues Bild ist verfügbar, soll das aktuelle Bild ersetzt werden?";
18 | "replaceNo" = "Aktuelles Bild behalten";
19 | "replaceYes" = "Neues Bild verwenden";
20 | 
21 | // CardModel
22 | "empty" = "Leer";
23 | "sum" = "Summe";
24 | "remaining" = "Verbleiben";
25 | "unnamed" = "Unnamed";
26 | "total" = "Gesamt";
27 | "shared" = "Geteilt";
28 | 
29 | // CardsView
30 | "deleteTransaction" = "Transaktion löschen";
31 | "editCard" = "Karte bearbeiten";
32 | "removeFromSession" = "Aus Session entfernen";
33 | "clearAllTransactions" = "Alle Transaktionen löschen";
34 | "shareResult" = "Wert teilen";
35 | "resetManualShare" = "Aufteilung zurücksetzen";
36 | "addToActive" = "Ebenfalls aktiv machen";
37 | "setToInactive" = "Inaktiv setzen";
38 | "clearAllTransactionsInEveryCard" = "Alle Transaktionen in allen Kartn löschen";
39 | 
40 | // EditCardsView
41 | "newCard" = "Neue Karte";
42 | "specialCard" = "Besondere Karte";
43 | "editCards" = "Karten Bearbeiten";
44 | 
45 | // SettingsView
46 | "openOnStartUp" = "Beim Start öffnen";
47 | "nothing" = "Nichts";
48 | "invertImage" = "Adaptive Bild Inversion";
49 | "scanner" = "Scanner";
50 | "imagePicker" = "Bild Auswahl";
51 | "flashTransactionValue" = "Erkannte Zahl anzeigen";
52 | "contact" = "Kontakt";
53 | "rateApp" = "Bitte bewerte SplitBill";
54 | "github" = "SplitBill auf Github";
55 | "previewDuration" = "Anzeige Dauer";
56 | "short" = "Kurz";
57 | "medium" = "Mittel";
58 | "long" = "Lang";
59 | "tapAway" = "Durch Tippen";
60 | 
61 | // ButtonsOverlayView
62 | "clearImage" = "Bild löschen";
63 | "documentScanner" = "Dokument Scanner";
64 | "photoLibrary" = "Foto Bibliothek";
65 | "share" = "Share";
66 | "shareSummary" = "Text Zusammenfassung";
67 | "shareImage" = "Bild mit Anmerkungen";
68 | "total" = "Gesamt";
69 | 
70 | // ShareExtension
71 | "openInApp" = "App öffnen";
72 | "imageSaved" = "Bild zu SplitBill hinzugefügt";
73 | "openInAppExplanation" = "Beim nächsten Öffnen der App wird das Bild automatisch geladen";
74 | "error" = "Ein Fehler ist aufgetreten";
75 | "errorMessage" = "Bitte versuche es erneut oder importiere das Bild von der App aus";
76 | 
77 | // Alerts
78 | "cannotEditShare" = "Anteil kann nicht bearbeitet werden";
79 | "lastShareCannotBeAdjustedManually" = "Alle Anteile müssen zusammen den gesamten Wert ergeben und alle anderen Anteile wurden bereits manuell angepasst. Du kannst den gesamten Wert anpassen oder andere Anteile ändern/zurücksetzen, um diesen Anteil zu ändern.";
80 | "numberTooLargeTitle" = "Die ausgewählte Zahl ist zu groß";
81 | "numberTooLargeMessage" = "Die Zahl wurde eventuell falsch erkannt. Drücke länger auf die Zahl, um sie zu korrigieren.";
82 | "unknown" = "Ein unbekannter Fehler ist aufgetreten";
83 | 
84 | // Info.plist
85 | "NSPhotoLibraryAddUsageDescription" = "Export des Bildes mit Anmerkungen";
86 | 
--------------------------------------------------------------------------------
/SplitBill/en.lproj/Localizable.strings:
--------------------------------------------------------------------------------
 1 | /* 
 2 |   Localizable.strings
 3 |   SplitBill
 4 | 
 5 |   Created by fer0n on 21.12.22.
 6 |   
 7 | */
 8 | 
 9 | // Shared
10 | "settings" = "Settings";
11 | "delete" = "Delete";
12 | "cancel" = "Cancel";
13 | 
14 | // ContentView
15 | "selectImage" = "Select Image";
16 | "openScanner" = "Open Scanner";
17 | "replaceImage" = "New Image Available, Replace the Current One?";
18 | "replaceNo" = "Keep Current Image";
19 | "replaceYes" = "Use New Image";
20 | 
21 | // CardModel
22 | "empty" = "Empty";
23 | "sum" = "Sum";
24 | "remaining" = "Remaining";
25 | "unnamed" = "Unnamed";
26 | "total" = "Total";
27 | "shared" = "Shared";
28 | 
29 | // CardsView
30 | "deleteTransaction" = "Delete Transaction";
31 | "editCard" = "Edit Card";
32 | "removeFromSession" = "Remove from Session";
33 | "clearAllTransactions" = "Clear All Transactions";
34 | "shareResult" = "Share Value";
35 | "resetManualShare" = "Reset Share";
36 | "addToActive" = "Make Active as Well";
37 | "setToInactive" = "Make Inactive";
38 | "clearAllTransactionsInEveryCard" = "Clear All Transactions for All Cards";
39 | 
40 | // EditCardsView
41 | "newCard" = "New Card";
42 | "specialCard" = "Special Card";
43 | "editCards" = "Edit Cards";
44 | 
45 | // SettingsView
46 | "openOnStartUp" = "Open on Startup";
47 | "nothing" = "Nothing";
48 | "invertImage" = "Adaptive Image Inversion";
49 | "scanner" = "Scanner";
50 | "imagePicker" = "Image Picker";
51 | "flashTransactionValue" = "Preview Recognized Number";
52 | "contact" = "Contact Us";
53 | "rateApp" = "Please Rate SplitBill";
54 | "github" = "SplitBill on Github";
55 | "previewDuration" = "Preview Duration";
56 | "short" = "Short";
57 | "medium" = "Medium";
58 | "long" = "Long";
59 | "tapAway" = "Tap Away";
60 | 
61 | // ButtonsOverlayView
62 | "clearImage" = "Clear Image";
63 | "documentScanner" = "Document Scanner";
64 | "photoLibrary" = "Photo Library";
65 | "share" = "Share";
66 | "shareSummary" = "Text Summary";
67 | "shareImage" = "Image with Annotations";
68 | "total" = "Total";
69 | 
70 | // ShareExtension
71 | "openInApp" = "Open App";
72 | "imageSaved" = "Image Added to SplitBill";
73 | "openInAppExplanation" = "Next time you open the app the image will be loaded automatically";
74 | "error" = "An Error Occurred";
75 | "errorMessage" = "Please try again or import the image from within the app";
76 | 
77 | // Alerts
78 | "cannotEditShare" = "Share cannot be edited";
79 | "lastShareCannotBeAdjustedManually" = "All shares have to add up to the value and all other shares have already been adjusted manually. You can edit the total value or change/reset other shares to adjust this one.";
80 | "numberTooLargeTitle" = "The selected number is too large";
81 | "numberTooLargeMessage" = "The number may have been recognized incorrectly. Long press the item to correct it.";
82 | "unknown" = "An unknown error occurred";
83 | 
84 | // Info.plist
85 | "NSPhotoLibraryAddUsageDescription" = "Export the image with annotations";
86 | 
--------------------------------------------------------------------------------
/SplitBill/id.lproj/Localizable.strings:
--------------------------------------------------------------------------------
 1 | /* 
 2 |   Localizable.strings
 3 |   SplitBill
 4 | 
 5 |   Created by fer0n on 21.12.22.
 6 |   
 7 | */
 8 | 
 9 | // Shared
10 | "settings" = "Pengaturan";
11 | "delete" = "Hapus";
12 | "cancel" = "Batal";
13 | 
14 | // ContentView
15 | "selectImage" = "Pilih gambar";
16 | "openScanner" = "Buka pemindai";
17 | "replaceImage" = "Gambar baru tersedia, ganti yang sekarang?";
18 | "replaceNo" = "Pertahankan gambar saat ini";
19 | "replaceYes" = "Gunakan gambar baru";
20 | 
21 | // CardModel
22 | "empty" = "Kosong";
23 | "sum" = "Jumlah";
24 | "remaining" = "Sisa";
25 | "unnamed" = "Tanpa Nama";
26 | "total" = "Total";
27 | "shared" = "Dibagi";
28 | 
29 | // CardsView
30 | "deleteTransaction" = "Hapus transaksi";
31 | "editCard" = "Edit kartu";
32 | "removeFromSession" = "Hapus dari sesi";
33 | "clearAllTransactions" = "Hapus semua transaksi";
34 | "shareResult" = "Bagikan nilai";
35 | "resetManualShare" = "Atur ulang pembagian";
36 | "addToActive" = "Jadikan aktif juga";
37 | "setToInactive" = "Jadikan tidak aktif";
38 | "clearAllTransactionsInEveryCard" = "Hapus semua transaksi dari semua kartu";
39 | 
40 | // EditCardsView
41 | "newCard" = "Kartu Baru";
42 | "specialCard" = "Kartu Spesial";
43 | "editCards" = "Sunting Kartu";
44 | 
45 | // SettingsView
46 | "openOnStartUp" = "Buka saat startup";
47 | "nothing" = "Tidak ada";
48 | "invertImage" = "Pembalikan Gambar Adaptif";
49 | "scanner" = "Pemindai";
50 | "imagePicker" = "Pemilih Gambar";
51 | "flashTransactionValue" = "Pratinjau angka yang dikenali";
52 | "contact" = "Hubungi kami";
53 | "rateApp" = "Berikan Rating untuk SplitBill";
54 | "github" = "SplitBill di Github";
55 | "previewDuration" = "Durasi Pratinjau";
56 | "short" = "Singkat";
57 | "medium" = "Sedang";
58 | "long" = "Panjang";
59 | "tapAway" = "Sampai Anda ketuk di luar";
60 | 
61 | // ButtonsOverlayView
62 | "clearImage" = "Hapus gambar";
63 | "documentScanner" = "Pemindai Dokumen";
64 | "photoLibrary" = "Galeri Foto";
65 | "share" = "Bagikan";
66 | "shareSummary" = "Ringkasan teks";
67 | "shareImage" = "Gambar dengan anotasi";
68 | "total" = "Total";
69 | 
70 | // ShareExtension
71 | "openInApp" = "Buka Aplikasi";
72 | "imageSaved" = "Gambar ditambahkan ke SplitBill";
73 | "openInAppExplanation" = "Saat Anda membuka aplikasi, gambar akan dimuat secara otomatis";
74 | "error" = "Terjadi kesalahan";
75 | "errorMessage" = "Silakan coba lagi atau impor gambar dari dalam aplikasi";
76 | 
77 | // Alerts
78 | "cannotEditShare" = "Bagian tidak dapat diedit";
79 | "lastShareCannotBeAdjustedManually" = "Semua bagian harus mencapai nilai total, dan semua bagian lainnya telah diatur ulang secara manual. Anda dapat mengedit nilai total atau mengubah/mengatur ulang bagian lainnya untuk menyesuaikan ini.";
80 | "numberTooLargeTitle" = "Angka yang dipilih terlalu besar";
81 | "numberTooLargeMessage" = "Angka mungkin tidak dikenali dengan benar. Tekan lama pada item untuk mengoreksi.";
82 | "unknown" = "Terjadi kesalahan yang tidak diketahui";
83 | 
84 | // Info.plist
85 | "NSPhotoLibraryAddUsageDescription" = "Ekspor gambar dengan anotasi";
86 | 
--------------------------------------------------------------------------------
/SplitBillShared/Shared.swift:
--------------------------------------------------------------------------------
 1 | //
 2 | //  Shared.swift
 3 | //  SplitBillShared
 4 | //
 5 | //  Created by fer0n on 03.08.23.
 6 | //
 7 | 
 8 | import Foundation
 9 | import UIKit
10 | 
11 | public func saveImageDataToSplitBill(_ image: UIImage, isHeic: Bool?, isPreservation: Bool?) throws -> Data {
12 |     guard let data = image.jpegData(compressionQuality: 0.6) else {
13 |         throw NSError(domain: "Invalid image data",
14 |                       code: 0,
15 |                       userInfo: [NSLocalizedDescriptionKey: "Invalid image data"])
16 |     }
17 |     try saveImage(data, isHeic, isPreservation)
18 |     return data
19 | }
20 | 
21 | public func saveImage(_ data: Data, _ isHeic: Bool?, _ isPreservation: Bool?) throws {
22 |     let encoded = try PropertyListEncoder().encode(data)
23 |     if let userDefaults = UserDefaults(suiteName: "group.splitbill") {
24 |         userDefaults.set(encoded, forKey: "imageData")
25 |         userDefaults.set(isHeic, forKey: "isHeic")
26 |         userDefaults.set(isPreservation, forKey: "imageIsPreserved")
27 |     }
28 | }
29 | 
--------------------------------------------------------------------------------
/SplitBillShared/SplitBillShared.h:
--------------------------------------------------------------------------------
 1 | //
 2 | //  SplitBillShared.h
 3 | //  SplitBillShared
 4 | //
 5 | //  Created by fer0n on 03.08.23.
 6 | //
 7 | 
 8 | #import 
 9 | 
10 | //! Project version number for SplitBillShared.
11 | FOUNDATION_EXPORT double SplitBillSharedVersionNumber;
12 | 
13 | //! Project version string for SplitBillShared.
14 | FOUNDATION_EXPORT const unsigned char SplitBillSharedVersionString[];
15 | 
16 | // In this header, you should import all the public headers of your framework using statements like #import 
17 | 
18 | 
--------------------------------------------------------------------------------