├── .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 | SplitBill logo 4 | 5 |

6 | 7 |

SplitBill

8 | 9 |

10 | An iOS App to split transactions from an image 11 |

12 | 13 |

14 | 15 | SplitBill screenshots 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 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 | --------------------------------------------------------------------------------