From e4538f84e232ec5f3fa98f89054fb04b39d48822 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Sun, 23 Mar 2025 00:27:49 -0300 Subject: [PATCH] Version 1.0.0 --- .gitignore | 8 + LICENSE.md | 24 + Package.swift | 16 + README.md | 70 ++ Sources/LCEssentials/Classes/GifHelper.swift | 247 ++++ .../Classes/LCEssentials+API.swift | 419 +++++++ .../LCEssentials/Classes/LCEssentials.swift | 602 ++++++++++ .../LCEssentials/Classes/LCSingleton.swift | 27 + .../Extensions/LCEssentials+Array.swift | 171 +++ ...LCEssentials+BidirectionalCollection.swift | 50 + .../LCEssentials+BinaryFloatingPoint.swift | 51 + .../LCEssentials+BinaryInteger.swift | 64 + .../Extensions/LCEssentials+Bool.swift | 49 + .../Extensions/LCEssentials+CGFloat.swift | 88 ++ .../Extensions/LCEssentials+CGPoint.swift | 37 + .../Extensions/LCEssentials+CGRect.swift | 84 ++ .../Extensions/LCEssentials+CGSize.swift | 285 +++++ .../Extensions/LCEssentials+Character.swift | 126 ++ .../Extensions/LCEssentials+Collection.swift | 193 +++ .../Extensions/LCEssentials+Comparable.swift | 50 + .../Extensions/LCEssentials+Crypto.swift | 107 ++ .../Extensions/LCEssentials+Data.swift | 132 +++ .../Extensions/LCEssentials+Date.swift | 1056 +++++++++++++++++ .../Extensions/LCEssentials+Decimal.swift | 37 + .../Extensions/LCEssentials+Dictionary.swift | 324 +++++ .../Extensions/LCEssentials+Double.swift | 77 ++ .../LCEssentials+EncodableDecodable.swift | 104 ++ .../Extensions/LCEssentials+FileManager.swift | 128 ++ .../Extensions/LCEssentials+Float.swift | 79 ++ .../Extensions/LCEssentials+Int.swift | 254 ++++ ...Essentials+LosslessStringConvertible.swift | 29 + .../LCEssentials+NSAttributedString.swift | 45 + .../Extensions/LCEssentials+NSError.swift | 41 + .../LCEssentials+NSLayoutConstraints.swift | 74 ++ ...Essentials+NSMutableAttributedString.swift | 191 +++ .../Extensions/LCEssentials+NSString.swift | 45 + .../Extensions/LCEssentials+Optional.swift | 182 +++ ...ssentials+RangeReplaceableCollection.swift | 220 ++++ .../Extensions/LCEssentials+Sequence.swift | 330 ++++++ .../LCEssentials+SignedNumeric.swift | 75 ++ .../Extensions/LCEssentials+String.swift | 977 +++++++++++++++ .../LCEssentials+StringProtocol.swift | 30 + .../LCEssentials+UIApplication.swift | 107 ++ .../Extensions/LCEssentials+UIButton.swift | 191 +++ .../LCEssentials+UICollectionView.swift | 259 ++++ .../Extensions/LCEssentials+UIColor.swift | 106 ++ .../Extensions/LCEssentials+UIDevice.swift | 124 ++ .../Extensions/LCEssentials+UIImage.swift | 186 +++ .../Extensions/LCEssentials+UIImageView.swift | 69 ++ .../Extensions/LCEssentials+UILabel.swift | 48 + .../LCEssentials+UINavigationController.swift | 93 ++ .../Extensions/LCEssentials+UIResponder.swift | 46 + .../LCEssentials+UIScrollView.swift | 137 +++ .../Extensions/LCEssentials+UIStackView.swift | 99 ++ .../LCEssentials+UITabBarController.swift | 173 +++ .../Extensions/LCEssentials+UITableView.swift | 151 +++ .../LCEssentials+UITapGestureRecognizer.swift | 60 + .../Extensions/LCEssentials+UITextField.swift | 63 + .../Extensions/LCEssentials+UIView.swift | 714 +++++++++++ .../LCEssentials+UIViewController.swift | 172 +++ .../Extensions/LCEssentials+URL.swift | 36 + .../LCEssentials+UserDefaults.swift | 93 ++ .../Extensions/RIPEMD160+Extension.swift | 393 ++++++ .../HUDAlert/HUDAlertController.swift | 271 +++++ .../LCEssentials/HUDAlert/HUDAlertView.swift | 158 +++ .../HUDAlert/Storyboard/dual_loading.gif | Bin 0 -> 70033 bytes .../HUDAlert/Storyboard/loading.gif | Bin 0 -> 76794 bytes .../ImagePicker/ImagePickerController.swift | 211 ++++ .../LCEssentials/ImageZoom/ImageZoom.swift | 205 ++++ .../LCEssentials/Message/LCSnackBarView.swift | 439 +++++++ .../Repositorio/Repositorio.swift | 4 + .../SwiftUI/LCENavigationView.swift | 274 +++++ Sources/LCEssentials/SwiftUI/View+Ext.swift | 81 ++ loverde_company_logo_full.png | Bin 0 -> 9167 bytes 74 files changed, 12161 insertions(+) create mode 100644 .gitignore create mode 100755 LICENSE.md create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/LCEssentials/Classes/GifHelper.swift create mode 100644 Sources/LCEssentials/Classes/LCEssentials+API.swift create mode 100644 Sources/LCEssentials/Classes/LCEssentials.swift create mode 100644 Sources/LCEssentials/Classes/LCSingleton.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Array.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+BidirectionalCollection.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+BinaryFloatingPoint.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+BinaryInteger.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Bool.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+CGFloat.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+CGPoint.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+CGRect.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+CGSize.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Character.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Collection.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Comparable.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Crypto.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Data.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Date.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Decimal.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Double.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+FileManager.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Float.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Int.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+LosslessStringConvertible.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+NSAttributedString.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+NSError.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+NSLayoutConstraints.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+NSMutableAttributedString.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+NSString.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Optional.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+RangeReplaceableCollection.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+Sequence.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+SignedNumeric.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+String.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+StringProtocol.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UIApplication.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UIButton.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UICollectionView.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UIColor.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UIDevice.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UIImage.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UIImageView.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UILabel.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UINavigationController.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UIResponder.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UIScrollView.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UIStackView.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UITabBarController.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UITableView.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UITapGestureRecognizer.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UITextField.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UIView.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UIViewController.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+URL.swift create mode 100644 Sources/LCEssentials/Extensions/LCEssentials+UserDefaults.swift create mode 100644 Sources/LCEssentials/Extensions/RIPEMD160+Extension.swift create mode 100644 Sources/LCEssentials/HUDAlert/HUDAlertController.swift create mode 100644 Sources/LCEssentials/HUDAlert/HUDAlertView.swift create mode 100644 Sources/LCEssentials/HUDAlert/Storyboard/dual_loading.gif create mode 100644 Sources/LCEssentials/HUDAlert/Storyboard/loading.gif create mode 100644 Sources/LCEssentials/ImagePicker/ImagePickerController.swift create mode 100644 Sources/LCEssentials/ImageZoom/ImageZoom.swift create mode 100644 Sources/LCEssentials/Message/LCSnackBarView.swift create mode 100644 Sources/LCEssentials/Repositorio/Repositorio.swift create mode 100644 Sources/LCEssentials/SwiftUI/LCENavigationView.swift create mode 100644 Sources/LCEssentials/SwiftUI/View+Ext.swift create mode 100755 loverde_company_logo_full.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 0000000..1411d1a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,24 @@ +![](loverde_company_logo_full.png) + +Copyright (C) Loverde Company - All Rights Reserved +---------- + +Licensed under the MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..8145101 --- /dev/null +++ b/Package.swift @@ -0,0 +1,16 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "LCEssentials", + products: [ + .library( + name: "LCEssentials", + targets: ["LCEssentials"]), + ], + targets: [ + .target( + name: "LCEssentials"), + + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d24e646 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ + +![](loverde_company_logo_full.png) +Loverde Co. Essentials Swift Scripts +---- + +This is a repository of essential scripts written in Swift for Loverde Co. used to save time on re-writing and keeping it on all other projects. So this Cocoapods will evolve with Swift and will improve with every release! + +## Requirements +- iOS 15.* or newer, Swift 5.* or newer. + +## Features +- [x] Many usefull scripts extensions + + +Installation +---- +#### Swift Package Manager (SPM) +``` swift +dependencies: [ + .package(url: "https://github.com/loverde-co/LCEssentials.git", .upToNextMajor(from: "1.0.0")) +] +``` + +You can also add it via XCode SPM editor with URL: + +``` swift +https://github.com/loverde-co/LCEssentials.git +``` + +## Usage example + +* Background Trhead + +```swift +LCEssentials.backgroundThread(delay: 0.6, background: { + //Do something im background + }) { + //When finish, update UI + } +``` +* NavigationController with Completion Handler + +```swift +self.navigationController?.popViewControllerWithHandler(completion: { + //Do some stuff after pop + }) + +//or more simple +self.navigationController?.popViewControllerWithHandler { + //Do some stuff after pop +} +``` +## Another components +> LCESnackBarView - **great way to send feedback to user** + +And then import `LCEssentials ` wherever you import UIKit or SwiftUI + +``` swift +import LCEssentials +``` + +Author: +---- + +Any question or doubts, please send thru email + +Daniel Arantes Loverde - + +[![Alt text](https://loverde.com.br/_signature/loverde_github_mail.gif "My Resume")](https://github.com/loverde-co/resume/) +[![Alt text](https://loverde.com.br/_signature/loverde_github_mail.gif "Loverde Co. Github")](https://github.com/loverde-co) diff --git a/Sources/LCEssentials/Classes/GifHelper.swift b/Sources/LCEssentials/Classes/GifHelper.swift new file mode 100644 index 0000000..41528cd --- /dev/null +++ b/Sources/LCEssentials/Classes/GifHelper.swift @@ -0,0 +1,247 @@ +// +// iOSDevCenters+GIF.swift +// GIF-Swift +// +// Created by iOSDevCenters on 11/12/15. +// Copyright © 2016 iOSDevCenters. All rights reserved. +// +import UIKit +#if os(iOS) || os(macOS) +import ImageIO + +//let jeremyGif = UIImage.gifWithName("jeremy") + +//let imageView = UIImageView(...) + +// Uncomment the next line to prevent stretching the image +// imageView.contentMode = .ScaleAspectFit +// Uncomment the next line to set a gray color. +// You can also set a default image which get's displayed +// after the animation +// imageView.backgroundColor = UIColor.grayColor() + + // Set the images from the UIImage +//imageView.animationImages = jeremyGif?.images + // Set the duration of the UIImage +//imageView.animationDuration = jeremyGif!.duration + // Set the repetitioncount +//imageView.animationRepeatCount = 1 + // Start the animation +//imageView.startAnimating() + +extension UIImageView { + + public func loadGif(name: String) { + DispatchQueue.global().async { + let image = UIImage.gif(name: name) + DispatchQueue.main.async { + self.image = image + } + } + } + + @available(iOS 9.0, *) + public func loadGif(asset: String) { + DispatchQueue.global().async { + let image = UIImage.gif(asset: asset) + DispatchQueue.main.async { + self.image = image + } + } + } + +} + +extension UIImage { + + public class func gif(data: Data) -> UIImage? { + // Create source from data + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + print("SwiftGif: Source for the image does not exist") + return nil + } + + return UIImage.animatedImageWithSource(source) + } + + public class func gif(url: String) -> UIImage? { + // Validate URL + guard let bundleURL = URL(string: url) else { + print("SwiftGif: This image named \"\(url)\" does not exist") + return nil + } + + // Validate data + guard let imageData = try? Data(contentsOf: bundleURL) else { + print("SwiftGif: Cannot turn image named \"\(url)\" into NSData") + return nil + } + + return gif(data: imageData) + } + + public class func gif(name: String) -> UIImage? { + // Check for existance of gif + guard let bundleURL = Bundle.main + .url(forResource: name, withExtension: "gif") else { + print("SwiftGif: This image named \"\(name)\" does not exist") + return nil + } + + // Validate data + guard let imageData = try? Data(contentsOf: bundleURL) else { + print("SwiftGif: Cannot turn image named \"\(name)\" into NSData") + return nil + } + + return gif(data: imageData) + } + + @available(iOS 9.0, *) + public class func gif(asset: String) -> UIImage? { + // Create source from assets catalog + guard let dataAsset = NSDataAsset(name: asset) else { + print("SwiftGif: Cannot turn image named \"\(asset)\" into NSDataAsset") + return nil + } + + return gif(data: dataAsset.data) + } + + internal class func delayForImageAtIndex(_ index: Int, source: CGImageSource!) -> Double { + var delay = 0.1 + + // Get dictionaries + let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) + let gifPropertiesPointer = UnsafeMutablePointer.allocate(capacity: 0) + defer { + gifPropertiesPointer.deallocate() + } + let unsafePointer = Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque() + if CFDictionaryGetValueIfPresent(cfProperties, unsafePointer, gifPropertiesPointer) == false { + return delay + } + + let gifProperties: CFDictionary = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self) + + // Get delay time + var delayObject: AnyObject = unsafeBitCast( + CFDictionaryGetValue(gifProperties, + Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()), + to: AnyObject.self) + if delayObject.doubleValue == 0 { + delayObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, + Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()), to: AnyObject.self) + } + + if let delayObject = delayObject as? Double, delayObject > 0 { + delay = delayObject + } else { + delay = 0.1 // Make sure they're not too fast + } + + return delay + } + + internal class func gcdForPair(_ lhs: Int?, _ rhs: Int?) -> Int { + var lhs = lhs + var rhs = rhs + // Check if one of them is nil + if rhs == nil || lhs == nil { + if rhs != nil { + return rhs! + } else if lhs != nil { + return lhs! + } else { + return 0 + } + } + + // Swap for modulo + if lhs! < rhs! { + let ctp = lhs + lhs = rhs + rhs = ctp + } + + // Get greatest common divisor + var rest: Int + while true { + rest = lhs! % rhs! + + if rest == 0 { + return rhs! // Found it + } else { + lhs = rhs + rhs = rest + } + } + } + + internal class func gcdForArray(_ array: [Int]) -> Int { + if array.isEmpty { + return 1 + } + + var gcd = array[0] + + for val in array { + gcd = UIImage.gcdForPair(val, gcd) + } + + return gcd + } + + internal class func animatedImageWithSource(_ source: CGImageSource) -> UIImage? { + let count = CGImageSourceGetCount(source) + var images = [CGImage]() + var delays = [Int]() + + // Fill arrays + for index in 0.. { + case success(Value) + case failure(Error) +} + +public enum httpMethod: String { + case post = "POST" + case get = "GET" + case put = "PUT" + case delete = "DELETE" +} +/// Loverde Co.: API generic struct for simple requests +@available(iOS 13.0.0, *) +@MainActor +public struct API { + + private static var certData: Data? + private static var certPassword: String? + + static let defaultError = NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE, + description: LCEssentials.DEFAULT_ERROR_MSG, + reasonForError: LCEssentials.DEFAULT_ERROR_MSG) + + public static var persistConnectionDelay: Double = 3 + public static var defaultParams: [String:Any] = [String: Any]() + var defaultHeaders: [String: String] = ["Accept": "application/json", + "Content-Type": "application/json; charset=UTF-8", + "Accept-Encoding": "gzip"] + + public static let shared = API() + + private init(){} + + public func request(url: String, + params: Any? = nil, + method: httpMethod, + headers: [String: String] = [:], + jsonEncoding: Bool = true, + debug: Bool = true, + timeoutInterval: TimeInterval = 30, + networkServiceType: URLRequest.NetworkServiceType = .default, + persistConnection: Bool = false) async throws -> T { + + if let urlReq = URL(string: url.replaceURL(params as? [String: Any] ?? [:] )) { + var request = URLRequest(url: urlReq, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30) + if method == .post || method == .put || method == .delete { + if let params = params as? [String: Any], + let pathFile = params["file"] as? String, + let fileURL = URL(string: pathFile) { + let boundary = UUID().uuidString + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var body = Data() + + // Adiciona campos adicionais (se houver) + for (key, value) in params where key != "file" { + let stringValue = "\(value)" + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + body.append("\(stringValue)\r\n".data(using: .utf8)!) + } + + // Adiciona o arquivo + let fileName = fileURL.lastPathComponent + let mimeType = mimeTypeForPath(path: fileName) + do { + let fileData = try Data(contentsOf: fileURL) + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) + body.append(fileData) + body.append("\r\n".data(using: .utf8)!) + } catch { + printError(title: "Upload File", msg: error.localizedDescription) + } + + // Finaliza o corpo da requisição + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") + // Logs de depuração + printLog(title: "Boundary", msg: boundary) + if let bodyString = String(data: body, encoding: .utf8) { + printLog(title: "Body Content", msg: bodyString) + } + } else if jsonEncoding, let params = params as? [String: Any] { + let requestObject = try JSONSerialization.data(withJSONObject: params) + request.httpBody = requestObject + } else if let params = params as? [String: Any] { + var bodyComponents = URLComponents() + params.forEach({ (key, value) in + bodyComponents.queryItems?.append(URLQueryItem(name: key, value: value as? String)) + }) + request.httpBody = bodyComponents.query?.data(using: .utf8) + } else if let params = params as? Data { + request.httpBody = params + } + } + request.httpMethod = method.rawValue + request.timeoutInterval = timeoutInterval + request.networkServiceType = networkServiceType + + // - Put Default Headers togheter with user defined params + if !headers.isEmpty { + // - Add it to request + headers.forEach { (key, value) in + request.addValue(value, forHTTPHeaderField: key) + } + }else{ + defaultHeaders.forEach { (key, value) in + request.addValue(value, forHTTPHeaderField: key) + } + } + if debug { + API.requestLOG(method: method, request: request) + } + + let session = URLSession( + configuration: .default, + delegate: URLSessionDelegateHandler( + certData: API.certData, + password: API.certPassword + ), + delegateQueue: nil + ) + do { + let (data, response) = try await session.data(for: request) + + + var code: Int = LCEssentials.DEFAULT_ERROR_CODE + let httpResponse = response as? HTTPURLResponse ?? HTTPURLResponse() + code = httpResponse.statusCode + let error = URLError(URLError.Code(rawValue: code)) + switch code { + case 200..<300: + // - Debug LOG + if debug { + API.responseLOG(method: method, request: request, data: data, statusCode: code, error: nil) + } + + // - Check if is JSON result and try decode it + if let string = data.string as? T, T.self == String.self { + return string + } + // - Normal decoding + do { + return try JSONDecoder.decode(data: data) + } catch { + printError(title: "JSONDecoder", msg: error.localizedDescription) + throw error + } + case 400..<500: + // - Debug LOG + if debug { + API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error) + } + if persistConnection { + printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST") + let persist: T = try await self.request( + url: url, + params: params, + method: method, + headers: headers, + jsonEncoding: jsonEncoding, + debug: debug, + timeoutInterval: timeoutInterval, + networkServiceType: networkServiceType, + persistConnection: persistConnection + ) + return persist + } else { + if debug { + API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error) + } + let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "") + throw friendlyError + } + default: + // - Debug LOG + if debug { + API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error) + } + let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "") + throw friendlyError + } + } catch { + throw error + } + } + throw API.defaultError + } + + public func setupCertificationRequest(certData: Data, password: String = "") { + API.certData = certData + API.certPassword = password + } +} + +#if canImport(Security) +@available(iOS 13.0.0, *) +@MainActor +private class URLSessionDelegateHandler: NSObject, URLSessionDelegate { + + private var certData: Data? + private var certPass: String? + + init(certData: Data? = nil, password: String? = nil) { + super.init() + self.certData = certData + self.certPass = password + } + + func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate { + // Carregar o certificado do cliente + guard let identity = getIdentity() else { + return (URLSession.AuthChallengeDisposition.performDefaultHandling, nil) + } + + // Criar o URLCredential com a identidade + let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession) + return (URLSession.AuthChallengeDisposition.useCredential, credential) + } else if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { + // Validar o certificado do servidor + if let serverTrust = challenge.protectionSpace.serverTrust { + let serverCredential = URLCredential(trust: serverTrust) + return (URLSession.AuthChallengeDisposition.useCredential, serverCredential) + } else { + return (URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, nil) + } + } else { + return (URLSession.AuthChallengeDisposition.performDefaultHandling, nil) + } + } + + private func getIdentity() -> SecIdentity? { + guard let certData = self.certData else { return nil } + // Especifique a senha usada ao exportar o .p12 + let options: [String: Any] = [kSecImportExportPassphrase as String: self.certPass ?? ""] + var items: CFArray? + + // Importar o certificado .p12 para obter a identidade + let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items) + + if status == errSecSuccess, + let item = (items as? [[String: Any]])?.first, + let identityRef = item[kSecImportItemIdentity as String] as CFTypeRef?, + CFGetTypeID(identityRef) == SecIdentityGetTypeID() { + return (identityRef as! SecIdentity) + } else { + print("Erro ao importar a identidade do certificado: \(status)") + return nil + } + + } + + private func isPKCS12(data: Data, password: String) -> Bool { + let options: [String: Any] = [kSecImportExportPassphrase as String: password] + + var items: CFArray? + let status = SecPKCS12Import(data as CFData, options as CFDictionary, &items) + + return status == errSecSuccess + } +} +#endif + +extension Error { + public var statusCode: Int { + get{ + return self._code + } + } +} + +@available(iOS 13.0.0, *) +extension API { + + fileprivate static func requestLOG(method: httpMethod, request: URLRequest) { + + print("\n<========================= 🟠 INTERNET CONNECTION - REQUEST =========================>") + printLog(title: "DATE AND TIME", msg: Date().debugDescription) + printLog(title: "METHOD", msg: method.rawValue) + printLog(title: "REQUEST", msg: String(describing: request)) + printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "") + + // + if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson { + printLog(title: "PARAMETERS", msg: prettyJson) + } else if let dataBody = request.httpBody { + printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-") + } + // + print("<======================================================================================>") + } + + fileprivate static func responseLOG(method: httpMethod, request: URLRequest, data: Data?, statusCode: Int, error: Error?) { + /// + let icon = error != nil ? "🔴" : "🟢" + print("\n<========================= \(icon) INTERNET CONNECTION - RESPONSE =========================>") + printLog(title: "DATE AND TIME", msg: Date().debugDescription) + printLog(title: "METHOD", msg: method.rawValue) + printLog(title: "REQUEST", msg: String(describing: request)) + printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "") + + // + if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson { + printLog(title: "PARAMETERS", msg: prettyJson) + } else if let dataBody = request.httpBody { + printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-") + } + // + printLog(title: "STATUS CODE", msg: String(describing: statusCode)) + // + if let dataResponse = data, let prettyJson = dataResponse.prettyJson { + printLog(title: "RESPONSE", msg: prettyJson) + } else { + printLog(title: "RESPONSE", msg: String(data: data ?? Data(), encoding: .utf8) ?? "-") + } + // + if let error = error { + switch error.statusCode { + case NSURLErrorTimedOut: + printError(title: "RESPONSE ERROR TIMEOUT", msg: "DESCRICAO: \(error.localizedDescription)") + + case NSURLErrorNotConnectedToInternet: + printError(title: "RESPONSE ERROR NO INTERNET", msg: "DESCRICAO: \(error.localizedDescription)") + + case NSURLErrorNetworkConnectionLost: + printError(title: "RESPONSE ERROR CONNECTION LOST", msg: "DESCRICAO: \(error.localizedDescription)") + + case NSURLErrorCancelledReasonUserForceQuitApplication: + printError(title: "RESPONSE ERROR APP QUIT", msg: "DESCRICAO: \(error.localizedDescription)") + + case NSURLErrorCancelledReasonBackgroundUpdatesDisabled: + printError(title: "RESPONSE ERROR BG DISABLED", msg: "DESCRICAO: \(error.localizedDescription)") + + case NSURLErrorBackgroundSessionWasDisconnected: + printError(title: "RESPONSE ERROR BG SESSION DISCONNECTED", msg: "DESCRICAO: \(error.localizedDescription)") + + default: + printError(title: "GENERAL", msg: error.localizedDescription) + } + }else if let data = data, statusCode != 200 { + // - Check if is JSON result + if let jsonString = String(data: data, encoding: .utf8) { + printError(title: "JSON STATUS CODE \(statusCode)", msg: jsonString) + }else{ + printError(title: "DATA STATUS CODE \(statusCode)", msg: data.debugDescription) + } + } + // + print("<======================================================================================>") + } + + func mimeTypeForPath(path: String) -> String { + let url = URL(fileURLWithPath: path) + let pathExtension = url.pathExtension.lowercased() + + // Dicionário de extensões e MIME types comuns + let mimeTypes: [String: String] = [ + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "png": "image/png", + "gif": "image/gif", + "pdf": "application/pdf", + "txt": "text/plain", + "html": "text/html", + "htm": "text/html", + "json": "application/json", + "xml": "application/xml", + "zip": "application/zip", + "mp3": "audio/mpeg", + "mp4": "video/mp4", + "mov": "video/quicktime", + "doc": "application/msword", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls": "application/vnd.ms-excel", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt": "application/vnd.ms-powerpoint", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ] + + // Retorna o MIME type correspondente à extensão, ou "application/octet-stream" como padrão + return mimeTypes[pathExtension] ?? "application/octet-stream" + } +} +#endif diff --git a/Sources/LCEssentials/Classes/LCEssentials.swift b/Sources/LCEssentials/Classes/LCEssentials.swift new file mode 100644 index 0000000..65ada18 --- /dev/null +++ b/Sources/LCEssentials/Classes/LCEssentials.swift @@ -0,0 +1,602 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit +import AVFoundation +#if os(watchOS) +import WatchKit +#endif +//import CommonCrypto + + +precedencegroup PowerPrecedence { higherThan: MultiplicationPrecedence } +infix operator ^^ : PowerPrecedence +public func ^^ (radix: Float, power: Float) -> Float { + return Float(pow(Double(radix), Double(power))) +} + + +/// Loverde Co: Custom Logs +public func printLog( + title: String, + msg: Any, + prettyPrint: Bool = false +){ + if prettyPrint { + print( + "\n<========================= \(title) - START =========================>" + ) + print( + msg + ) + print( + "\n<========================= \(title) - END ===========================>" + ) + }else{ + print( + "\(title): \(msg)" + ) + } +} + +public func printInfo( + title: String, + msg: Any, + prettyPrint: Bool = false, + function: String = #function, + file: String = #file, + line: Int = #line, + column: Int = #column +){ + if prettyPrint { + print( + "\n<========================= ℹ️ INFO: \(title) - START =========================>" + ) + print( + "[\(file): FUNC: \(function): LINE: \(line) - COLUMN: \(column)]\n" + ) + print( + msg + ) + print( + "\n<========================= ℹ️ INFO: \(title) - END ===========================>" + ) + }else{ + print( + "ℹ️ INFO: \(title): \(msg)" + ) + } +} +public func printWarn( + title: String, + msg: Any, + prettyPrint: Bool = false, + function: String = #function, + file: String = #file, + line: Int = #line, + column: Int = #column +){ + if prettyPrint { + print( + "\n<========================= 🟡 WARN: \(title) - START =========================>" + ) + print( + "[\(file): FUNC: \(function): LINE: \(line) - COLUMN: \(column)]\n" + ) + print( + msg + ) + print( + "\n<========================= 🟡 WARN: \(title) - END ===========================>" + ) + }else{ + print( + "🟡 WARN: \(title): \(msg)" + ) + } +} +public func printError( + title: String, + msg: Any, + prettyPrint: Bool = false, + function: String = #function, + file: String = #file, + line: Int = #line, + column: Int = #column +){ + if prettyPrint { + print( + "\n<========================= 🔴 ERROR: \(title) - START =========================>" + ) + print( + "[\(file): FUNC: \(function): LINE: \(line) - COLUMN: \(column)]\n" + ) + print( + msg + ) + print( + "\n<========================= 🔴 ERROR: \(title) - END ===========================>" + ) + }else{ + print( + "🔴 ERROR: \(title): \(msg)" + ) + } +} + +public struct LCEssentials { + public static let DEFAULT_ERROR_DOMAIN = "LoverdeCoErrorDomain" + public static let DEFAULT_ERROR_CODE = -99 + public static let DEFAULT_ERROR_MSG = "Error Unknow" + + private static let cache = URLCache( + memoryCapacity: 50 * 1024 * 1024, // 50 MB em memória + diskCapacity: 200 * 1024 * 1024, // 200 MB em disco + diskPath: "file_cache" + ) + + #if os(iOS) || os(macOS) + /// Extract the file name from the file path + /// + /// - Parameter filePath: Full file path in bundle + /// - Returns: File Name with extension + public static func sourceFileName(filePath: String) -> String { + let components = filePath.components(separatedBy: "/") + return components.isEmpty ? "" : components.last! + } + #endif + + #if !os(macOS) + /// - LoverdeCo: App's name (if applicable). + @MainActor + public static var appDisplayName: String? { + return UIApplication.displayName + } + #endif + + #if !os(macOS) + /// - LoverdeCo: App's bundle ID (if applicable). + public static var appBundleID: String? { + return Bundle.main.bundleIdentifier + } + #endif + + #if !os(macOS) + /// - LoverdeCo: App current build number (if applicable). + @MainActor + public static var appBuild: String? { + return UIApplication.buildNumber + } + #endif + + #if os(iOS) || os(tvOS) + /// - LoverdeCo: Application icon badge current number. + @MainActor + public static var applicationIconBadgeNumber: Int { + get { + return UIApplication.shared.applicationIconBadgeNumber + } + set { + UIApplication.shared.applicationIconBadgeNumber = newValue + } + } + #endif + + #if !os(macOS) + /// - LoverdeCo: App's current version (if applicable). + @MainActor + public static var appVersion: String? { + return UIApplication.version + } + #endif + + #if os(iOS) + /// - LoverdeCo: Current battery level. + @MainActor + public static var batteryLevel: Float { + return UIDevice.current.batteryLevel + } + #endif + + #if os(iOS) || os(tvOS) + /// - LoverdeCo: Shared instance of current device. + @MainActor + public static var currentDevice: UIDevice { + return UIDevice.current + } + #elseif os(watchOS) + /// - LoverdeCo: Shared instance of current device. + public static var currentDevice: WKInterfaceDevice { + return WKInterfaceDevice.current() + } + #endif + + #if !os(macOS) + /// - LoverdeCo: Screen height. + @MainActor + public static var screenHeight: CGFloat { + #if os(iOS) || os(tvOS) + return UIScreen.main.bounds.height + #elseif os(watchOS) + return currentDevice.screenBounds.height + #endif + } + #endif + + #if os(iOS) + /// - LoverdeCo: Current orientation of device. + @MainActor + public static var deviceOrientation: UIDeviceOrientation { + return currentDevice.orientation + } + #endif + + #if !os(macOS) + /// - LoverdeCo: Screen width. + @MainActor + public static var screenWidth: CGFloat { + #if os(iOS) || os(tvOS) + return UIScreen.main.bounds.width + #elseif os(watchOS) + return currentDevice.screenBounds.width + #endif + } + #endif + + /// - LoverdeCo: Check if app is running in debug mode. + @MainActor + public static var isInDebuggingMode: Bool { + return UIApplication.inferredEnvironment == .debug + } + + #if !os(macOS) + /// - LoverdeCo: Check if app is running in TestFlight mode. + @MainActor + public static var isInTestFlight: Bool { + return UIApplication.inferredEnvironment == .testFlight + } + #endif + + #if os(iOS) + /// - LoverdeCo: Check if multitasking is supported in current device. + @MainActor + public static var isMultitaskingSupported: Bool { + return UIDevice.current.isMultitaskingSupported + } + #endif + + #if os(iOS) + /// - LoverdeCo: Check if device is iPad. + @MainActor + public static var isPad: Bool { + return UIDevice.current.userInterfaceIdiom == .pad + } + #endif + + #if os(iOS) + /// - LoverdeCo: Check if device is iPhone. + @MainActor + public static var isPhone: Bool { + return UIDevice.current.userInterfaceIdiom == .phone + } + #endif + + #if os(iOS) || os(tvOS) + /// - LoverdeCo: Check if device is registered for remote notifications for current app (read-only). + @MainActor + public static var isRegisteredForRemoteNotifications: Bool { + return UIApplication.shared.isRegisteredForRemoteNotifications + } + #endif + + /// - LoverdeCo: Check if application is running on simulator (read-only). + @MainActor + public static var isRunningOnSimulator: Bool { + // http://stackoverflow.com/questions/24869481/detect-if-app-is-being-built-for-device-or-simulator-in-swift + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } + + #if os(iOS) + ///- LoverdeCo: Status bar visibility state. + @MainActor + public static var isStatusBarHidden: Bool { + get { + if #available(iOS 13.0, *) { + let window = LCEssentials.keyWindow + return window?.windowScene?.statusBarManager?.isStatusBarHidden ?? true + } else { + return UIApplication.shared.isStatusBarHidden + } + } + } + #endif + + #if os(iOS) || os(tvOS) + /// - LoverdeCo: Key window (read only, if applicable). + @MainActor + public static var keyWindow: UIWindow? { + if #available(iOS 13.0, *) { + return UIApplication.shared.windows.filter {$0.isKeyWindow}.first + } else { + return UIApplication.shared.keyWindow + } + } + #endif + + #if os(iOS) || os(tvOS) + /// - LoverdeCo: Most top view controller (if applicable). + @MainActor + public static func getTopViewController(base: UIViewController? = nil, aboveBars: Bool = true) -> UIViewController? { + var viewController = base + + if viewController == nil { + // iOS 13+ - Obter o rootViewController da UIWindow correta + if #available(iOS 13.0, *) { + // Acessa a janela ativa na cena principal + if let windowScene = UIApplication.shared.connectedScenes + .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) { + viewController = window.rootViewController + } + } else { + // Pré-iOS 13 + viewController = UIApplication.shared.keyWindow?.rootViewController + } + } + + // Navegação - UINavigationController + if let nav = viewController as? UINavigationController { + if aboveBars { + return nav + } + return getTopViewController(base: nav.visibleViewController) + } + + // Tabs - UITabBarController + if let tab = viewController as? UITabBarController, + let selected = tab.selectedViewController { + if aboveBars { + return tab + } + return getTopViewController(base: selected) + } + + // Apresentações modais + if let presented = viewController?.presentedViewController { + return getTopViewController(base: presented) + } + + return viewController + } + #endif + + #if os(iOS) || os(tvOS) + /// - LoverdeCo: Shared instance UIApplication. + @MainActor + public static var sharedApplication: UIApplication { + return UIApplication.shared + } + #endif + + #if !os(macOS) + /// - LoverdeCo: System current version (read-only). + @MainActor + public static var systemVersion: String { + return currentDevice.systemVersion + } + #endif + + public init(){} +} + +// MARK: - Methods +public extension LCEssentials { + + #if os(iOS) || os(macOS) + /// - LoverdeCo: Share link with message + /// + /// - Parameters: + /// - message: String with message you whant to send + /// - url: String with url you want to share + @MainActor + static func shareApp(message: String = "", url: String = ""){ + let textToShare = message + let root = self.getTopViewController(aboveBars: true) + + var objectsToShare = [textToShare] as [Any] + if let myWebsite = NSURL(string: url) { + objectsToShare.append(myWebsite) + } + let activityVC = UIActivityViewController(activityItems: objectsToShare, applicationActivities: nil) + + activityVC.popoverPresentationController?.sourceView = root?.view + root?.modalPresentationStyle = .fullScreen + root?.present(activityVC, animated: true, completion: nil) + } + /// - LoverdeCo: Make a call + /// + /// - Parameters: + /// - number: string phone number + @MainActor + static func call(_ number: String!) { + UIApplication.openURL(urlStr: "tel://" + number) + } + /// - LoverdeCo: Open link on Safari + /// + /// - Parameters: + /// - urlStr: url string to open + @MainActor + static func openSafari(_ urlStr: String){ + UIApplication.openURL(urlStr: urlStr) + } + #endif + + // MARK: - Background Thread + @MainActor + static func backgroundThread(delay: Double = 0.0, background: (@Sendable () -> Void)? = nil, completion: (@Sendable () -> Void)? = nil) { + DispatchQueue.global().async { + background?() + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + completion?() + } + } + } + + @MainActor + static func dispatchAsync(completion: @escaping () -> Void) { + DispatchQueue.main.async { + completion() + } + } + + /// - LoverdeCo: Delay function or closure call. + /// + /// - Parameters: + /// - milliseconds: execute closure after the given delay. + /// - queue: a queue that completion closure should be executed on (default is DispatchQueue.main). + /// - completion: closure to be executed after delay. + /// - Returns: DispatchWorkItem task. You can call .cancel() on it to cancel delayed execution. + @discardableResult + static func delay(milliseconds: Double, + queue: DispatchQueue = .main, + completion: @escaping () -> Void) -> DispatchWorkItem { + let task = DispatchWorkItem { completion() } + queue.asyncAfter(deadline: .now() + (milliseconds/1000), execute: task) + return task + } + + /// - LoverdeCo: Debounce function or closure call. + /// + /// - Parameters: + /// - millisecondsOffset: allow execution of method if it was not called since millisecondsOffset. + /// - queue: a queue that action closure should be executed on (default is DispatchQueue.main). + /// - action: closure to be executed in a debounced way. + @MainActor + static func debounce(millisecondsDelay: Int, + queue: DispatchQueue = .main, + action: @escaping (() -> Void)) -> () -> Void { + // http://stackoverflow.com/questions/27116684/how-can-i-debounce-a-method-call + var lastFireTime = DispatchTime.now() + let dispatchDelay = DispatchTimeInterval.milliseconds(millisecondsDelay) + let dispatchTime: DispatchTime = lastFireTime + dispatchDelay + return { + queue.asyncAfter(deadline: dispatchTime) { + let when: DispatchTime = lastFireTime + dispatchDelay + let now = DispatchTime.now() + if now.rawValue >= when.rawValue { + lastFireTime = DispatchTime.now() + action() + } + } + } + } + + #if os(iOS) || os(tvOS) + /// - LoverdeCo: Called when user takes a screenshot + /// + /// - Parameter action: a closure to run when user takes a screenshot + @MainActor + static func didTakeScreenShot(_ action: @escaping (_ notification: Notification) -> Void) { + // http://stackoverflow.com/questions/13484516/ios-detection-of-screenshot + _ = NotificationCenter.default.addObserver(forName: UIApplication.userDidTakeScreenshotNotification, + object: nil, + queue: OperationQueue.main) { notification in + action(notification) + } + } + #endif + + #if os(iOS) + static func cachedFileURL(for url: URL) -> URL? { + let request = URLRequest(url: url) + guard let cachedResponse = cache.cachedResponse(for: request) else { return nil } + + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent(url.lastPathComponent) + + do { + try cachedResponse.data.write(to: tempFile) + return tempFile + } catch { + return nil + } + } + + @MainActor + static func downloadFileWithCache(from url: URL, completion: @escaping (Result) -> Void) { + // Verifica se já existe no cache + if let cachedURL = cachedFileURL(for: url) { + completion(.success(cachedURL)) + return + } + + let request = URLRequest(url: url) + let task = URLSession.shared.dataTask(with: request) { data, response, error in + guard let data = data, let response = response else { + completion(.failure(error ?? URLError(.badServerResponse))) + return + } + + // Armazena no cache + let cachedResponse = CachedURLResponse(response: response, data: data) + cache.storeCachedResponse(cachedResponse, for: request) + + // Escreve no diretório temporário + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent(url.lastPathComponent) + + do { + try data.write(to: tempFile) + completion(.success(tempFile)) + } catch { + completion(.failure(error)) + } + } + task.resume() + } + + static func cleanExpiredCache(expiration: TimeInterval = 7 * 24 * 60 * 60) { // 1 semana padrão + cache.removeCachedResponses(since: Date().addingTimeInterval(-expiration)) + + // Limpa arquivos temporários antigos + let tempDir = FileManager.default.temporaryDirectory + do { + let files = try FileManager.default.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: [.creationDateKey]) + let expirationDate = Date().addingTimeInterval(-expiration) + + for file in files { + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + if let creationDate = attributes[.creationDate] as? Date, creationDate < expirationDate { + try FileManager.default.removeItem(at: file) + } + } + } catch { + print("Erro ao limpar cache: \(error)") + } + } + #endif +} diff --git a/Sources/LCEssentials/Classes/LCSingleton.swift b/Sources/LCEssentials/Classes/LCSingleton.swift new file mode 100644 index 0000000..d183a11 --- /dev/null +++ b/Sources/LCEssentials/Classes/LCSingleton.swift @@ -0,0 +1,27 @@ +// +// Copyright (c) 2021 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Foundation +import UIKit + +@objc public protocol LCESingletonDelegate: AnyObject { + @objc optional func singleton(object: Any?, withData: Any) +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Array.swift b/Sources/LCEssentials/Extensions/LCEssentials+Array.swift new file mode 100644 index 0000000..ed7d22e --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Array.swift @@ -0,0 +1,171 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit + +// MARK: - Methods (Equatable) +public extension Array where Element: Equatable { + + var unique: [Element] { + var uniqueValues: [Element] = [] + forEach { item in + if !uniqueValues.contains(item) { + uniqueValues += [item] + } + } + return uniqueValues + } + + /// Returns an array without nil elements. + var removeNilElements: [Element] { + return self.compactMap { $0 } + } + + /// Remove all instances of an item from array. + /// + /// [1, 2, 2, 3, 4, 5].removeAll(2) -> [1, 3, 4, 5] + /// ["h", "e", "l", "l", "o"].removeAll("l") -> ["h", "e", "o"] + /// + /// - Parameter item: item to remove. + /// - Returns: self after removing all instances of item. + @discardableResult + mutating func removeAll(_ item: Element) -> [Element] { + removeAll(where: { $0 == item }) + return self + } + + /// Remove all instances contained in items parameter from array. + /// + /// [1, 2, 2, 3, 4, 5].removeAll([2,5]) -> [1, 3, 4] + /// ["h", "e", "l", "l", "o"].removeAll(["l", "h"]) -> ["e", "o"] + /// + /// - Parameter items: items to remove. + /// - Returns: self after removing all instances of all items in given array. + @discardableResult + mutating func removeAll(_ items: [Element]) -> [Element] { + guard !items.isEmpty else { return self } + removeAll(where: { items.contains($0) }) + return self + } + + /// Remove all duplicate elements from Array. + /// + /// [1, 2, 2, 3, 4, 5].removeDuplicates() -> [1, 2, 3, 4, 5] + /// ["h", "e", "l", "l", "o"]. removeDuplicates() -> ["h", "e", "l", "o"] + /// + /// - Returns: Return array with all duplicate elements removed. + @discardableResult + mutating func removeDuplicates() -> [Element] { + // Thanks to https://github.com/sairamkotha for improving the method + self = reduce(into: [Element]()) { + if !$0.contains($1) { + $0.append($1) + } + } + return self + } + + /// Insert an element at the beginning of array. + /// + /// [2, 3, 4, 5].prepend(1) -> [1, 2, 3, 4, 5] + /// ["e", "l", "l", "o"].prepend("h") -> ["h", "e", "l", "l", "o"] + /// + /// - Parameter newElement: element to insert. + mutating func prepend(_ newElement: Element) { + insert(newElement, at: 0) + } + + /// Safely swap values at given index positions. + /// + /// [1, 2, 3, 4, 5].safeSwap(from: 3, to: 0) -> [4, 2, 3, 1, 5] + /// ["h", "e", "l", "l", "o"].safeSwap(from: 1, to: 0) -> ["e", "h", "l", "l", "o"] + /// + /// - Parameters: + /// - index: index of first element. + /// - otherIndex: index of other element. + mutating func safeSwap(from index: Index, to otherIndex: Index) { + guard index != otherIndex else { return } + guard startIndex.. [1, 2, 3, 4, 5]) + /// ["h", "e", "l", "l", "o"].withoutDuplicates() -> ["h", "e", "l", "o"]) + /// + /// - Returns: an array of unique elements. + /// + func withoutDuplicates() -> [Element] { + // Thanks to https://github.com/sairamkotha for improving the method + return reduce(into: [Element]()) { + if !$0.contains($1) { + $0.append($1) + } + } + } + + /// Returns an array with all duplicate elements removed using KeyPath to compare. + /// + /// - Parameter path: Key path to compare, the value must be Equatable. + /// - Returns: an array of unique elements. + func withoutDuplicates(keyPath path: KeyPath) -> [Element] { + return reduce(into: [Element]()) { (result, element) in + if !result.contains(where: { $0[keyPath: path] == element[keyPath: path] }) { + result.append(element) + } + } + } + + /// Returns an array with all duplicate elements removed using KeyPath to compare. + /// + /// - Parameter path: Key path to compare, the value must be Hashable. + /// - Returns: an array of unique elements. + func withoutDuplicates(keyPath path: KeyPath) -> [Element] { + var set = Set() + return filter { set.insert($0[keyPath: path]).inserted } + } +} + +extension Array where Element == NSLayoutConstraint { + + @MainActor + func filtered(view: UIView, anchor: NSLayoutYAxisAnchor) -> [NSLayoutConstraint] { + return filter { constraint in + constraint.matches(view: view, anchor: anchor) + } + } + @MainActor + func filtered(view: UIView, anchor: NSLayoutXAxisAnchor) -> [NSLayoutConstraint] { + return filter { constraint in + constraint.matches(view: view, anchor: anchor) + } + } + @MainActor + func filtered(view: UIView, anchor: NSLayoutDimension) -> [NSLayoutConstraint] { + return filter { constraint in + constraint.matches(view: view, anchor: anchor) + } + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+BidirectionalCollection.swift b/Sources/LCEssentials/Extensions/LCEssentials+BidirectionalCollection.swift new file mode 100644 index 0000000..32e2107 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+BidirectionalCollection.swift @@ -0,0 +1,50 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +public extension BidirectionalCollection { + /// Returns the element at the specified position. If offset is negative, the `n`th element from the + /// end will be returned where `n` is the result of `abs(distance)`. + /// + /// let arr = [1, 2, 3, 4, 5] + /// arr[offset: 1] -> 2 + /// arr[offset: -2] -> 4 + /// + /// - Parameter distance: The distance to offset. + subscript(offset distance: Int) -> Element { + let index = distance >= 0 ? startIndex : endIndex + return self[indices.index(index, offsetBy: distance)] + } + + /// Returns the last element of the sequence with having property by given key path equals to given + /// `value`. + /// + /// - Parameters: + /// - keyPath: The `KeyPath` of property for `Element` to compare. + /// - value: The value to compare with `Element` property + /// - Returns: The last element of the collection that has property by given key path equals to given `value` or + /// `nil` if there is no such element. + func last(where keyPath: KeyPath, equals value: T) -> Element? { + return last { $0[keyPath: keyPath] == value } + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+BinaryFloatingPoint.swift b/Sources/LCEssentials/Extensions/LCEssentials+BinaryFloatingPoint.swift new file mode 100644 index 0000000..3ab37eb --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+BinaryFloatingPoint.swift @@ -0,0 +1,51 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +#if canImport(Foundation) +import Foundation + +// MARK: - Methods + +public extension BinaryFloatingPoint { + #if canImport(Foundation) + /// Returns a rounded value with the specified number of decimal places and rounding rule. If + /// `numberOfDecimalPlaces` is negative, `0` will be used. + /// + /// let num = 3.1415927 + /// num.rounded(numberOfDecimalPlaces: 3, rule: .up) -> 3.142 + /// num.rounded(numberOfDecimalPlaces: 3, rule: .down) -> 3.141 + /// num.rounded(numberOfDecimalPlaces: 2, rule: .awayFromZero) -> 3.15 + /// num.rounded(numberOfDecimalPlaces: 4, rule: .towardZero) -> 3.1415 + /// num.rounded(numberOfDecimalPlaces: -1, rule: .toNearestOrEven) -> 3 + /// + /// - Parameters: + /// - numberOfDecimalPlaces: The expected number of decimal places. + /// - rule: The rounding rule to use. + /// - Returns: The rounded value. + func rounded(numberOfDecimalPlaces: Int, rule: FloatingPointRoundingRule) -> Self { + let factor = Self(pow(10.0, Double(max(0, numberOfDecimalPlaces)))) + return (self * factor).rounded(rule) / factor + } + #endif +} + +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+BinaryInteger.swift b/Sources/LCEssentials/Extensions/LCEssentials+BinaryInteger.swift new file mode 100644 index 0000000..5b3e513 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+BinaryInteger.swift @@ -0,0 +1,64 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + + +public extension BinaryInteger { + /// The raw bytes of the integer. + /// + /// var number = Int16(-128) + /// print(number.bytes) + /// // prints "[255, 128]" + /// + var bytes: [UInt8] { + var result = [UInt8]() + result.reserveCapacity(MemoryLayout.size) + var value = self + for _ in 0...size { + result.append(UInt8(truncatingIfNeeded: value)) + value >>= 8 + } + return result.reversed() + } +} + +// MARK: - Initializers + +public extension BinaryInteger { + /// Creates a `BinaryInteger` from a raw byte representation. + /// + /// var number = Int16(bytes: [0xFF, 0b1111_1101]) + /// print(number!) + /// // prints "-3" + /// + /// - Parameter bytes: An array of bytes representing the value of the integer. + init?(bytes: [UInt8]) { + // https://stackoverflow.com/a/43518567/9506784 + precondition(bytes.count <= MemoryLayout.size, + "Integer with a \(bytes.count) byte binary representation of '\(bytes.map { String($0, radix: 2) }.joined(separator: " "))' overflows when stored into a \(MemoryLayout.size) byte '\(Self.self)'") + var value: Self = 0 + for byte in bytes { + value <<= 8 + value |= Self(byte) + } + self.init(exactly: value) + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Bool.swift b/Sources/LCEssentials/Extensions/LCEssentials+Bool.swift new file mode 100644 index 0000000..be00555 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Bool.swift @@ -0,0 +1,49 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +// MARK: - Properties +public extension Bool { + + /// Loverde Co: Return 1 if true, or 0 if false. + /// + /// false.int -> 0 + /// true.int -> 1 + /// + var int: Int { + return self ? 1 : 0 + } + + /// Loverde Co: Return "true" if true, or "false" if false. + /// + /// false.string -> "false" + /// true.string -> "true" + /// + var string: String { + return self ? "true" : "false" + } + + var data: Data { + Data([self ? 1 : 0]) + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+CGFloat.swift b/Sources/LCEssentials/Extensions/LCEssentials+CGFloat.swift new file mode 100644 index 0000000..587c1d3 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+CGFloat.swift @@ -0,0 +1,88 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +#if canImport(CoreGraphics) +import CoreGraphics + +#if canImport(Foundation) +import Foundation +#endif + +// MARK: - Properties + +public extension CGFloat { + /// Absolute of CGFloat value. + var abs: CGFloat { + return Swift.abs(self) + } + + #if canImport(Foundation) + /// Ceil of CGFloat value. + var ceil: CGFloat { + return Foundation.ceil(self) + } + #endif + + /// Radian value of degree input. + var degreesToRadians: CGFloat { + return .pi * self / 180.0 + } + + #if canImport(Foundation) + /// Floor of CGFloat value. + var floor: CGFloat { + return Foundation.floor(self) + } + #endif + + /// Check if CGFloat is positive. + var isPositive: Bool { + return self > 0 + } + + /// Check if CGFloat is negative. + var isNegative: Bool { + return self < 0 + } + + /// Int. + var int: Int { + return Int(self) + } + + /// Float. + var float: Float { + return Float(self) + } + + /// Double. + var double: Double { + return Double(self) + } + + /// Degree value of radian input. + var radiansToDegrees: CGFloat { + return self * 180 / CGFloat.pi + } +} + +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+CGPoint.swift b/Sources/LCEssentials/Extensions/LCEssentials+CGPoint.swift new file mode 100644 index 0000000..da34de0 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+CGPoint.swift @@ -0,0 +1,37 @@ +// +// Copyright (c) 2024 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +#if canImport(SwiftUI) +extension CGPoint: Hashable { + // Implementação manual de Hashable + public func hash(into hasher: inout Hasher) { + hasher.combine(x) + hasher.combine(y) + } + + static func == (lhs: CGPoint, rhs: CGPoint) -> Bool { + return lhs.x == rhs.x && lhs.y == rhs.y + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+CGRect.swift b/Sources/LCEssentials/Extensions/LCEssentials+CGRect.swift new file mode 100644 index 0000000..2483e94 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+CGRect.swift @@ -0,0 +1,84 @@ +// +// Copyright (c) 2024 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +#if canImport(CoreGraphics) +import CoreGraphics + +// MARK: - Properties + +public extension CGRect { + /// Return center of rect. + var center: CGPoint { CGPoint(x: midX, y: midY) } +} +#if canImport(SwiftUI) +extension CGRect: Hashable { + // Implementação manual de Hashable + public func hash(into hasher: inout Hasher) { + hasher.combine(origin) + hasher.combine(size) + } + + static func == (lhs: CGRect, rhs: CGRect) -> Bool { + return lhs.origin == rhs.origin && lhs.size == rhs.size + } +} +#endif + +// MARK: - Initializers + +public extension CGRect { + /// Create a `CGRect` instance with center and size. + /// - Parameters: + /// - center: center of the new rect. + /// - size: size of the new rect. + init(center: CGPoint, size: CGSize) { + let origin = CGPoint(x: center.x - size.width / 2.0, y: center.y - size.height / 2.0) + self.init(origin: origin, size: size) + } +} + +// MARK: - Methods + +public extension CGRect { + /// Create a new `CGRect` by resizing with specified anchor. + /// - Parameters: + /// - size: new size to be applied. + /// - anchor: specified anchor, a point in normalized coordinates - + /// '(0, 0)' is the top left corner of rect,'(1, 1)' is the bottom right corner of rect, + /// defaults to '(0.5, 0.5)'. Example: + /// + /// anchor = CGPoint(x: 0.0, y: 1.0): + /// + /// A2------B2 + /// A----B | | + /// | | --> | | + /// C----D C-------D2 + /// + func resizing(to size: CGSize, anchor: CGPoint = CGPoint(x: 0.5, y: 0.5)) -> CGRect { + let sizeDelta = CGSize(width: size.width - width, height: size.height - height) + return CGRect(origin: CGPoint(x: minX - sizeDelta.width * anchor.x, + y: minY - sizeDelta.height * anchor.y), + size: size) + } +} + +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+CGSize.swift b/Sources/LCEssentials/Extensions/LCEssentials+CGSize.swift new file mode 100644 index 0000000..b865242 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+CGSize.swift @@ -0,0 +1,285 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +#if canImport(CoreGraphics) +import CoreGraphics + +// MARK: - Methods + +public extension CGSize { + /// Returns the aspect ratio. + var aspectRatio: CGFloat { + guard height != 0 else { return 0 } + return width / height + } + + /// Returns width or height, whichever is the bigger value. + var maxDimension: CGFloat { + return max(width, height) + } + + /// Returns width or height, whichever is the smaller value. + var minDimension: CGFloat { + return min(width, height) + } +} +#if canImport(SwiftUI) +extension CGSize: @retroactive Hashable { + // Implementação manual de Hashable + public func hash(into hasher: inout Hasher) { + hasher.combine(width) + hasher.combine(height) + } + + static func == (lhs: CGSize, rhs: CGSize) -> Bool { + return lhs.width == rhs.width && lhs.height == rhs.height + } +} +#endif + +// MARK: - Methods + +public extension CGSize { + /// Aspect fit CGSize. + /// + /// let rect = CGSize(width: 120, height: 80) + /// let parentRect = CGSize(width: 100, height: 50) + /// let newRect = rect.aspectFit(to: parentRect) + /// // newRect.width = 75 , newRect = 50 + /// + /// - Parameter boundingSize: bounding size to fit self to. + /// - Returns: self fitted into given bounding size. + func aspectFit(to boundingSize: CGSize) -> CGSize { + let minRatio = min(boundingSize.width / width, boundingSize.height / height) + return CGSize(width: width * minRatio, height: height * minRatio) + } + + /// Aspect fill CGSize. + /// + /// let rect = CGSize(width: 20, height: 120) + /// let parentRect = CGSize(width: 100, height: 60) + /// let newRect = rect.aspectFit(to: parentRect) + /// // newRect.width = 100 , newRect = 60 + /// + /// - Parameter boundingSize: bounding size to fill self to. + /// - Returns: self filled into given bounding size. + func aspectFill(to boundingSize: CGSize) -> CGSize { + let minRatio = max(boundingSize.width / width, boundingSize.height / height) + let aWidth = min(width * minRatio, boundingSize.width) + let aHeight = min(height * minRatio, boundingSize.height) + return CGSize(width: aWidth, height: aHeight) + } +} + +// MARK: - Operators + +public extension CGSize { + /// Add two CGSize. + /// + /// let sizeA = CGSize(width: 5, height: 10) + /// let sizeB = CGSize(width: 3, height: 4) + /// let result = sizeA + sizeB + /// // result = CGSize(width: 8, height: 14) + /// + /// - Parameters: + /// - lhs: CGSize to add to. + /// - rhs: CGSize to add. + /// - Returns: The result comes from the addition of the two given CGSize struct. + static func + (lhs: CGSize, rhs: CGSize) -> CGSize { + return CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height) + } + + /// Add a tuple to CGSize. + /// + /// let sizeA = CGSize(width: 5, height: 10) + /// let result = sizeA + (5, 4) + /// // result = CGSize(width: 10, height: 14) + /// + /// - Parameters: + /// - lhs: CGSize to add to. + /// - tuple: tuple value. + /// - Returns: The result comes from the addition of the given CGSize and tuple. + static func + (lhs: CGSize, tuple: (width: CGFloat, height: CGFloat)) -> CGSize { + return CGSize(width: lhs.width + tuple.width, height: lhs.height + tuple.height) + } + + /// Add a CGSize to self. + /// + /// var sizeA = CGSize(width: 5, height: 10) + /// let sizeB = CGSize(width: 3, height: 4) + /// sizeA += sizeB + /// // sizeA = CGPoint(width: 8, height: 14) + /// + /// - Parameters: + /// - lhs: `self`. + /// - rhs: CGSize to add. + static func += (lhs: inout CGSize, rhs: CGSize) { + lhs.width += rhs.width + lhs.height += rhs.height + } + + /// Add a tuple to self. + /// + /// var sizeA = CGSize(width: 5, height: 10) + /// sizeA += (3, 4) + /// // result = CGSize(width: 8, height: 14) + /// + /// - Parameters: + /// - lhs: `self`. + /// - tuple: tuple value. + static func += (lhs: inout CGSize, tuple: (width: CGFloat, height: CGFloat)) { + lhs.width += tuple.width + lhs.height += tuple.height + } + + /// Subtract two CGSize. + /// + /// let sizeA = CGSize(width: 5, height: 10) + /// let sizeB = CGSize(width: 3, height: 4) + /// let result = sizeA - sizeB + /// // result = CGSize(width: 2, height: 6) + /// + /// - Parameters: + /// - lhs: CGSize to subtract from. + /// - rhs: CGSize to subtract. + /// - Returns: The result comes from the subtract of the two given CGSize struct. + static func - (lhs: CGSize, rhs: CGSize) -> CGSize { + return CGSize(width: lhs.width - rhs.width, height: lhs.height - rhs.height) + } + + /// Subtract a tuple from CGSize. + /// + /// let sizeA = CGSize(width: 5, height: 10) + /// let result = sizeA - (3, 2) + /// // result = CGSize(width: 2, height: 8) + /// + /// - Parameters: + /// - lhs: CGSize to subtract from. + /// - tuple: tuple value. + /// - Returns: The result comes from the subtract of the given CGSize and tuple. + static func - (lhs: CGSize, tuple: (width: CGFloat, heoght: CGFloat)) -> CGSize { + return CGSize(width: lhs.width - tuple.width, height: lhs.height - tuple.heoght) + } + + /// Subtract a CGSize from self. + /// + /// var sizeA = CGSize(width: 5, height: 10) + /// let sizeB = CGSize(width: 3, height: 4) + /// sizeA -= sizeB + /// // sizeA = CGPoint(width: 2, height: 6) + /// + /// - Parameters: + /// - lhs: `self`. + /// - rhs: CGSize to subtract. + static func -= (lhs: inout CGSize, rhs: CGSize) { + lhs.width -= rhs.width + lhs.height -= rhs.height + } + + /// Subtract a tuple from self. + /// + /// var sizeA = CGSize(width: 5, height: 10) + /// sizeA -= (2, 4) + /// // result = CGSize(width: 3, height: 6) + /// + /// - Parameters: + /// - lhs: `self`. + /// - tuple: tuple value. + static func -= (lhs: inout CGSize, tuple: (width: CGFloat, height: CGFloat)) { + lhs.width -= tuple.width + lhs.height -= tuple.height + } + + /// Multiply two CGSize. + /// + /// let sizeA = CGSize(width: 5, height: 10) + /// let sizeB = CGSize(width: 3, height: 4) + /// let result = sizeA * sizeB + /// // result = CGSize(width: 15, height: 40) + /// + /// - Parameters: + /// - lhs: CGSize to multiply. + /// - rhs: CGSize to multiply with. + /// - Returns: The result comes from the multiplication of the two given CGSize structs. + static func * (lhs: CGSize, rhs: CGSize) -> CGSize { + return CGSize(width: lhs.width * rhs.width, height: lhs.height * rhs.height) + } + + /// Multiply a CGSize with a scalar. + /// + /// let sizeA = CGSize(width: 5, height: 10) + /// let result = sizeA * 5 + /// // result = CGSize(width: 25, height: 50) + /// + /// - Parameters: + /// - lhs: CGSize to multiply. + /// - scalar: scalar value. + /// - Returns: The result comes from the multiplication of the given CGSize and scalar. + static func * (lhs: CGSize, scalar: CGFloat) -> CGSize { + return CGSize(width: lhs.width * scalar, height: lhs.height * scalar) + } + + /// Multiply a CGSize with a scalar. + /// + /// let sizeA = CGSize(width: 5, height: 10) + /// let result = 5 * sizeA + /// // result = CGSize(width: 25, height: 50) + /// + /// - Parameters: + /// - scalar: scalar value. + /// - rhs: CGSize to multiply. + /// - Returns: The result comes from the multiplication of the given scalar and CGSize. + static func * (scalar: CGFloat, rhs: CGSize) -> CGSize { + return CGSize(width: scalar * rhs.width, height: scalar * rhs.height) + } + + /// Multiply self with a CGSize. + /// + /// var sizeA = CGSize(width: 5, height: 10) + /// let sizeB = CGSize(width: 3, height: 4) + /// sizeA *= sizeB + /// // result = CGSize(width: 15, height: 40) + /// + /// - Parameters: + /// - lhs: `self`. + /// - rhs: CGSize to multiply. + static func *= (lhs: inout CGSize, rhs: CGSize) { + lhs.width *= rhs.width + lhs.height *= rhs.height + } + + /// Multiply self with a scalar. + /// + /// var sizeA = CGSize(width: 5, height: 10) + /// sizeA *= 3 + /// // result = CGSize(width: 15, height: 30) + /// + /// - Parameters: + /// - lhs: `self`. + /// - scalar: scalar value. + static func *= (lhs: inout CGSize, scalar: CGFloat) { + lhs.width *= scalar + lhs.height *= scalar + } +} + +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Character.swift b/Sources/LCEssentials/Extensions/LCEssentials+Character.swift new file mode 100644 index 0000000..eb3056e --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Character.swift @@ -0,0 +1,126 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +public extension Character { + /// Check if character is emoji. + /// + /// Character("😀").isEmoji -> true + /// + var isEmoji: Bool { + // http://stackoverflow.com/questions/30757193/find-out-if-character-in-string-is-emoji + let scalarValue = String(self).unicodeScalars.first!.value + switch scalarValue { + case 0x1F600...0x1F64F, // Emoticons + 0x1F300...0x1F5FF, // Misc Symbols and Pictographs + 0x1F680...0x1F6FF, // Transport and Map + 0x1F1E6...0x1F1FF, // Regional country flags + 0x2600...0x26FF, // Misc symbols + 0x2700...0x27BF, // Dingbats + 0xE0020...0xE007F, // Tags + 0xFE00...0xFE0F, // Variation Selectors + 0x1F900...0x1F9FF, // Supplemental Symbols and Pictographs + 127_000...127_600, // Various asian characters + 65024...65039, // Variation selector + 9100...9300, // Misc items + 8400...8447: // Combining Diacritical Marks for Symbols + return true + default: + return false + } + } + + /// Integer from character (if applicable). + /// + /// Character("1").int -> 1 + /// Character("A").int -> nil + /// + var int: Int? { + return Int(String(self)) + } + + /// String from character. + /// + /// Character("a").string -> "a" + /// + var string: String { + return String(self) + } + + /// Return the character lowercased. + /// + /// Character("A").lowercased -> Character("a") + /// + var lowercased: Character { + return String(self).lowercased().first! + } + + /// Return the character uppercased. + /// + /// Character("a").uppercased -> Character("A") + /// + var uppercased: Character { + return String(self).uppercased().first! + } +} + +// MARK: - Methods + +public extension Character { + /// Random character. + /// + /// Character.random() -> k + /// + /// - Returns: A random character. + static func randomAlphanumeric() -> Character { + return "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()! + } +} + +// MARK: - Operators + +public extension Character { + /// Repeat character multiple times. + /// + /// Character("-") * 10 -> "----------" + /// + /// - Parameters: + /// - lhs: character to repeat. + /// - rhs: number of times to repeat character. + /// - Returns: string with character repeated n times. + static func * (lhs: Character, rhs: Int) -> String { + guard rhs > 0 else { return "" } + return String(repeating: String(lhs), count: rhs) + } + + /// Repeat character multiple times. + /// + /// 10 * Character("-") -> "----------" + /// + /// - Parameters: + /// - lhs: number of times to repeat character. + /// - rhs: character to repeat. + /// - Returns: string with character repeated n times. + static func * (lhs: Int, rhs: Character) -> String { + guard lhs > 0 else { return "" } + return String(repeating: String(rhs), count: lhs) + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Collection.swift b/Sources/LCEssentials/Extensions/LCEssentials+Collection.swift new file mode 100644 index 0000000..d566050 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Collection.swift @@ -0,0 +1,193 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +#if canImport(Dispatch) +import Dispatch +#endif + +// MARK: - Properties + +public extension Collection { + /// The full range of the collection. + var fullRange: Range { startIndex.. Void) { + DispatchQueue.concurrentPerform(iterations: count) { + each(self[index(startIndex, offsetBy: $0)]) + } + } + #endif + + /// Safe protects the array from out of bounds by use of optional. + /// + /// let arr = [1, 2, 3, 4, 5] + /// arr[safe: 1] -> 2 + /// arr[safe: 10] -> nil + /// + /// - Parameter index: index of element to access element. + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } + + /// Returns an array of slices of length "size" from the array. If array can't be split evenly, the + /// final slice will be the remaining elements. + /// + /// [0, 2, 4, 7].group(by: 2) -> [[0, 2], [4, 7]] + /// [0, 2, 4, 7, 6].group(by: 2) -> [[0, 2], [4, 7], [6]] + /// + /// - Parameter size: The size of the slices to be returned. + /// - Returns: grouped self. + func group(by size: Int) -> [[Element]]? { + // Inspired by: https://lodash.com/docs/4.17.4#chunk + guard size > 0, !isEmpty else { return nil } + var start = startIndex + var slices = [[Element]]() + while start != endIndex { + let end = index(start, offsetBy: size, limitedBy: endIndex) ?? endIndex + slices.append(Array(self[start.. [0, 2, 5] + /// + /// - Parameter condition: condition to evaluate each element against. + /// - Returns: all indices where the specified condition evaluates to true (optional). + func indices(where condition: (Element) throws -> Bool) rethrows -> [Index]? { + let indices = try self.indices.filter { try condition(self[$0]) } + return indices.isEmpty ? nil : indices + } + + /// Calls the given closure with an array of size of the parameter slice. + /// + /// [0, 2, 4, 7].forEach(slice: 2) { print($0) } -> // print: [0, 2], [4, 7] + /// [0, 2, 4, 7, 6].forEach(slice: 2) { print($0) } -> // print: [0, 2], [4, 7], [6] + /// + /// - Parameters: + /// - slice: size of array in each interation. + /// - body: a closure that takes an array of slice size as a parameter. + func forEach(slice: Int, body: ([Element]) throws -> Void) rethrows { + var start = startIndex + while case let end = index(start, offsetBy: slice, limitedBy: endIndex) ?? endIndex, + start != end { + try body(Array(self[start.. AnySequence<(Element, Element)> { + guard var index1 = index(startIndex, offsetBy: 0, limitedBy: endIndex), + var index2 = index(index1, offsetBy: 1, limitedBy: endIndex) else { + return AnySequence { + EmptyCollection.Iterator() + } + } + return AnySequence { + AnyIterator { + if index1 >= endIndex || index2 >= endIndex { + return nil + } + defer { + index2 = self.index(after: index2) + if index2 >= endIndex { + index1 = self.index(after: index1) + index2 = self.index(after: index1) + } + } + return (self[index1], self[index2]) + } + } + } +} + +public extension Collection where Indices.Iterator.Element == Index { + subscript (exist index: Index) -> Iterator.Element? { + return indices.contains(index) ? self[index] : nil + } +} + +// MARK: - Methods (Equatable) + +public extension Collection where Element: Equatable { + /// All indices of specified item. + /// + /// [1, 2, 2, 3, 4, 2, 5].indices(of 2) -> [1, 2, 5] + /// [1.2, 2.3, 4.5, 3.4, 4.5].indices(of 2.3) -> [1] + /// ["h", "e", "l", "l", "o"].indices(of "l") -> [2, 3] + /// + /// - Parameter item: item to check. + /// - Returns: an array with all indices of the given item. + func indices(of item: Element) -> [Index] { + return indices.filter { self[$0] == item } + } +} + +// MARK: - Methods (BinaryInteger) + +public extension Collection where Element: BinaryInteger { + /// Average of all elements in array. + /// + /// - Returns: the average of the array's elements. + func average() -> Double { + // http://stackoverflow.com/questions/28288148/making-my-function-calculate-average-of-array-swift + guard !isEmpty else { return .zero } + return Double(reduce(.zero, +)) / Double(count) + } +} + +// MARK: - Methods (FloatingPoint) + +public extension Collection where Element: FloatingPoint { + /// Average of all elements in array. + /// + /// [1.2, 2.3, 4.5, 3.4, 4.5].average() = 3.18 + /// + /// - Returns: average of the array's elements. + func average() -> Element { + guard !isEmpty else { return .zero } + return reduce(.zero, +) / Element(count) + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Comparable.swift b/Sources/LCEssentials/Extensions/LCEssentials+Comparable.swift new file mode 100644 index 0000000..d9b93c1 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Comparable.swift @@ -0,0 +1,50 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +public extension Comparable { + /// Returns true if value is in the provided range. + /// + /// 1.isBetween(5...7) // false + /// 7.isBetween(6...12) // true + /// date.isBetween(date1...date2) + /// "c".isBetween(a...d) // true + /// 0.32.isBetween(0.31...0.33) // true + /// + /// - Parameter range: Closed range against which the value is checked to be included. + /// - Returns: `true` if the value is included in the range, `false` otherwise. + func isBetween(_ range: ClosedRange) -> Bool { + return range ~= self + } + + /// Returns value limited within the provided range. + /// + /// 1.clamped(to: 3...8) // 3 + /// 4.clamped(to: 3...7) // 4 + /// "c".clamped(to: "e"..."g") // "e" + /// 0.32.clamped(to: 0.1...0.29) // 0.29 + /// + /// - Parameter range: Closed range that limits the value. + /// - Returns: A value limited to the range, i.e. between `range.lowerBound` and `range.upperBound`. + func clamped(to range: ClosedRange) -> Self { + return max(range.lowerBound, min(self, range.upperBound)) + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Crypto.swift b/Sources/LCEssentials/Extensions/LCEssentials+Crypto.swift new file mode 100644 index 0000000..365045d --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Crypto.swift @@ -0,0 +1,107 @@ +// +// Copyright (c) 2025 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +//import CryptoKit +// +//struct AESUtils { +// +// // MARK: - Métodos Existente (AES Key Generation) +// static func generateAESKeyFrom(_ characters: String) -> String { +// let binaryData = Data(characters.utf8) +// let hexString = binaryData.map { String(format: "%02hhx", $0) }.joined() +// return Data(hexString.utf8).base64EncodedString() +// } +// +// static func degenerateAESKeyFrom(_ base64Encoded: String) -> String? { +// guard let debaseData = Data(base64Encoded: base64Encoded), +// let hexString = String(data: debaseData, encoding: .utf8) else { +// return nil +// } +// var binaryData = Data() +// for i in stride(from: 0, to: hexString.count, by: 2) { +// let start = hexString.index(hexString.startIndex, offsetBy: i) +// let end = hexString.index(start, offsetBy: 2, limitedBy: hexString.endIndex) ?? hexString.endIndex +// let byteString = String(hexString[start.. String? { +// guard let originalData = originalBase64.data(using: .utf8) else { return nil } +// +// // Gera um pad (OTP) aleatório do mesmo tamanho que originalData +// var pad = Data(count: originalData.count) +// let result = pad.withUnsafeMutableBytes { +// SecRandomCopyBytes(kSecRandomDefault, originalData.count, $0.baseAddress!) +// } +// guard result == errSecSuccess else { return nil } +// +// // Aplica XOR entre originalData e pad +// let xorResult = zip(originalData, pad).map { $0 ^ $1 } +// +// // Combina pad + xorResult e codifica em Base64 +// let combinedData = pad + Data(xorResult) +// return combinedData.base64EncodedString() +// } +// +// static func verifyOTPAndDecode(_ otpEncoded: String) -> String? { +// guard let combinedData = Data(base64Encoded: otpEncoded) else { return nil } +// +// // Divide pad e xorResult (metade para cada) +// let halfLength = combinedData.count / 2 +// guard halfLength > 0 else { return nil } +// +// let pad = combinedData[0..? { + do { + return try JSONSerialization.jsonObject(with: self, options: []) as? [String: Any] + } catch { + print(error.localizedDescription) + return nil + } + } + + var toHexString: String { + String(self.map { String(format: "%02hhx", $0) }.joined()) + } + + var bool: Bool { + first != 0 + } + + init?(hexString: String) { + let cleanHex = hexString.replacingOccurrences(of: " ", with: "") + let length = cleanHex.count / 2 + var data = Data(capacity: length) + + for i in 0.. Data { + var hmac = HMAC.init(key: SymmetricKey(data: key)) + hmac.update(data: self) + return Data(hmac.finalize()) + } + + func SHA512() -> Data { + var digest = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH)) + self.withUnsafeBytes { + _ = CC_SHA512($0.baseAddress, CC_LONG(self.count), &digest) + } + return Data(digest) + } + + func XOR(with other: Data) -> Data { + return Data(zip(self, other).map { $0 ^ $1 }) + } + + func SHA256() -> Data { + var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + self.withUnsafeBytes { + _ = CC_SHA256($0.baseAddress, CC_LONG(self.count), &digest) + } + return Data(digest) + } + + func object() -> T? { + do { + let outPut: T = try JSONDecoder.decode(data: self) + return outPut + } catch { + printError(title: "DATA DECODE ERROR", msg: error.localizedDescription, prettyPrint: true) + return nil + } + } + + func toHexadecimalString() -> String { + return `lazy`.reduce("") { + var s = String($1, radix: 16) + if s.count == 1 { + s = "0" + s + } + return $0 + s + } + } + + ///Loverde Co.: MD5 - Data + /////Test: + ///let md5Data = Data.MD5(string:"Hello") + /// + ///let md5Hex = md5Data.toHexString() + ///print("md5Hex: \(md5Hex)") + @available(iOS 13.0, *) + static func MD5(string: String) -> Data { + let messageData = string.data(using: .utf8)! + let digestData = Insecure.MD5.hash (data: messageData) + let digestHex = String(digestData.map { String(format: "%02hhx", $0) }.joined().prefix(32)) + return Data(digestHex.utf8) + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Date.swift b/Sources/LCEssentials/Extensions/LCEssentials+Date.swift new file mode 100644 index 0000000..209488b --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Date.swift @@ -0,0 +1,1056 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +#if canImport(Foundation) +import Foundation + +#if os(macOS) || os(iOS) +import Darwin +#elseif os(Linux) +import Glibc +#endif + +// MARK: - Enums + +public extension Date { + /// Day name format. + /// + /// - threeLetters: 3 letter day abbreviation of day name. + /// - oneLetter: 1 letter day abbreviation of day name. + /// - full: Full day name. + enum DayNameStyle { + /// 3 letter day abbreviation of day name. + case threeLetters + + /// 1 letter day abbreviation of day name. + case oneLetter + + /// Full day name. + case full + } + + /// Month name format. + /// + /// - threeLetters: 3 letter month abbreviation of month name. + /// - oneLetter: 1 letter month abbreviation of month name. + /// - full: Full month name. + enum MonthNameStyle { + /// 3 letter month abbreviation of month name. + case threeLetters + + /// 1 letter month abbreviation of month name. + case oneLetter + + /// Full month name. + case full + } +} + +// MARK: - Properties + +public extension Date { + /// User’s current calendar. + var calendar: Calendar { Calendar.current } + + /// Era. + /// + /// Date().era -> 1 + /// + var era: Int { + return calendar.component(.era, from: self) + } + + #if !os(Linux) + /// Quarter. + /// + /// Date().quarter -> 3 // date in third quarter of the year. + /// + var quarter: Int { + let month = Double(calendar.component(.month, from: self)) + let numberOfMonths = Double(calendar.monthSymbols.count) + let numberOfMonthsInQuarter = numberOfMonths / 4 + return Int(ceil(month / numberOfMonthsInQuarter)) + } + #endif + + /// Week of year. + /// + /// Date().weekOfYear -> 2 // second week in the year. + /// + var weekOfYear: Int { + return calendar.component(.weekOfYear, from: self) + } + + /// Week of month. + /// + /// Date().weekOfMonth -> 3 // date is in third week of the month. + /// + var weekOfMonth: Int { + return calendar.component(.weekOfMonth, from: self) + } + + /// Year. + /// + /// Date().year -> 2017 + /// + /// var someDate = Date() + /// someDate.year = 2000 // sets someDate's year to 2000 + /// + var year: Int { + get { + return calendar.component(.year, from: self) + } + set { + guard newValue > 0 else { return } + let currentYear = calendar.component(.year, from: self) + let yearsToAdd = newValue - currentYear + if let date = calendar.date(byAdding: .year, value: yearsToAdd, to: self) { + self = date + } + } + } + + /// Month. + /// + /// Date().month -> 1 + /// + /// var someDate = Date() + /// someDate.month = 10 // sets someDate's month to 10. + /// + var month: Int { + get { + return calendar.component(.month, from: self) + } + set { + let allowedRange = calendar.range(of: .month, in: .year, for: self)! + guard allowedRange.contains(newValue) else { return } + + let currentMonth = calendar.component(.month, from: self) + let monthsToAdd = newValue - currentMonth + if let date = calendar.date(byAdding: .month, value: monthsToAdd, to: self) { + self = date + } + } + } + + /// Day. + /// + /// Date().day -> 12 + /// + /// var someDate = Date() + /// someDate.day = 1 // sets someDate's day of month to 1. + /// + var day: Int { + get { + return calendar.component(.day, from: self) + } + set { + let allowedRange = calendar.range(of: .day, in: .month, for: self)! + guard allowedRange.contains(newValue) else { return } + + let currentDay = calendar.component(.day, from: self) + let daysToAdd = newValue - currentDay + if let date = calendar.date(byAdding: .day, value: daysToAdd, to: self) { + self = date + } + } + } + + /// Weekday. + /// + /// The weekday units are the numbers 1 through N (where for the Gregorian calendar N=7 and 1 is Sunday). + /// + /// Date().weekday -> 5 // fifth day in the current week, e.g. Thursday in the Gregorian calendar + /// + var weekday: Int { + calendar.component(.weekday, from: self) + } + + /// Hour. + /// + /// Date().hour -> 17 // 5 pm + /// + /// var someDate = Date() + /// someDate.hour = 13 // sets someDate's hour to 1 pm. + /// + var hour: Int { + get { + return calendar.component(.hour, from: self) + } + set { + let allowedRange = calendar.range(of: .hour, in: .day, for: self)! + guard allowedRange.contains(newValue) else { return } + + let currentHour = calendar.component(.hour, from: self) + let hoursToAdd = newValue - currentHour + if let date = calendar.date(byAdding: .hour, value: hoursToAdd, to: self) { + self = date + } + } + } + + /// Minutes. + /// + /// Date().minute -> 39 + /// + /// var someDate = Date() + /// someDate.minute = 10 // sets someDate's minutes to 10. + /// + var minute: Int { + get { + return calendar.component(.minute, from: self) + } + set { + let allowedRange = calendar.range(of: .minute, in: .hour, for: self)! + guard allowedRange.contains(newValue) else { return } + + let currentMinutes = calendar.component(.minute, from: self) + let minutesToAdd = newValue - currentMinutes + if let date = calendar.date(byAdding: .minute, value: minutesToAdd, to: self) { + self = date + } + } + } + + /// Seconds. + /// + /// Date().second -> 55 + /// + /// var someDate = Date() + /// someDate.second = 15 // sets someDate's seconds to 15. + /// + var second: Int { + get { + return calendar.component(.second, from: self) + } + set { + let allowedRange = calendar.range(of: .second, in: .minute, for: self)! + guard allowedRange.contains(newValue) else { return } + + let currentSeconds = calendar.component(.second, from: self) + let secondsToAdd = newValue - currentSeconds + if let date = calendar.date(byAdding: .second, value: secondsToAdd, to: self) { + self = date + } + } + } + + /// Nanoseconds. + /// + /// Date().nanosecond -> 981379985 + /// + /// var someDate = Date() + /// someDate.nanosecond = 981379985 // sets someDate's seconds to 981379985. + /// + var nanosecond: Int { + get { + return calendar.component(.nanosecond, from: self) + } + set { + #if targetEnvironment(macCatalyst) + // The `Calendar` implementation in `macCatalyst` does not know that a nanosecond is 1/1,000,000,000th of a + // second + let allowedRange = 0..<1_000_000_000 + #else + let allowedRange = calendar.range(of: .nanosecond, in: .second, for: self)! + #endif + guard allowedRange.contains(newValue) else { return } + + let currentNanoseconds = calendar.component(.nanosecond, from: self) + let nanosecondsToAdd = newValue - currentNanoseconds + + if let date = calendar.date(byAdding: .nanosecond, value: nanosecondsToAdd, to: self) { + self = date + } + } + } + + /// Milliseconds. + /// + /// Date().millisecond -> 68 + /// + /// var someDate = Date() + /// someDate.millisecond = 68 // sets someDate's nanosecond to 68000000. + /// + var millisecond: Int { + get { + return calendar.component(.nanosecond, from: self) / 1_000_000 + } + set { + let nanoSeconds = newValue * 1_000_000 + #if targetEnvironment(macCatalyst) + // The `Calendar` implementation in `macCatalyst` does not know that a nanosecond is 1/1,000,000,000th of a + // second + let allowedRange = 0..<1_000_000_000 + #else + let allowedRange = calendar.range(of: .nanosecond, in: .second, for: self)! + #endif + guard allowedRange.contains(nanoSeconds) else { return } + + if let date = calendar.date(bySetting: .nanosecond, value: nanoSeconds, of: self) { + self = date + } + } + } + + /// Check if date is in future. + /// + /// Date(timeInterval: 100, since: Date()).isInFuture -> true + /// + var isInFuture: Bool { + return self > Date() + } + + /// Check if date is in past. + /// + /// Date(timeInterval: -100, since: Date()).isInPast -> true + /// + var isInPast: Bool { + return self < Date() + } + + /// Check if date is within today. + /// + /// Date().isInToday -> true + /// + var isInToday: Bool { + return calendar.isDateInToday(self) + } + + /// Check if date is within yesterday. + /// + /// Date().isInYesterday -> false + /// + var isInYesterday: Bool { + return calendar.isDateInYesterday(self) + } + + /// Check if date is within tomorrow. + /// + /// Date().isInTomorrow -> false + /// + var isInTomorrow: Bool { + return calendar.isDateInTomorrow(self) + } + + /// Check if date is within a weekend period. + var isInWeekend: Bool { + return calendar.isDateInWeekend(self) + } + + /// Check if date is within a weekday period. + var isWorkday: Bool { + return !calendar.isDateInWeekend(self) + } + + /// Check if date is within the current week. + var isInCurrentWeek: Bool { + return calendar.isDate(self, equalTo: Date(), toGranularity: .weekOfYear) + } + + /// Check if date is within the current month. + var isInCurrentMonth: Bool { + return calendar.isDate(self, equalTo: Date(), toGranularity: .month) + } + + /// Check if date is within the current year. + var isInCurrentYear: Bool { + return calendar.isDate(self, equalTo: Date(), toGranularity: .year) + } + + /// ISO8601 string of format (yyyy-MM-dd'T'HH:mm:ss.SSS) from date. + /// + /// Date().iso8601String -> "2017-01-12T14:51:29.574Z" + /// + var iso8601String: String { + // https://github.com/justinmakaila/NSDate-ISO-8601/blob/master/NSDateISO8601.swift + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(abbreviation: "GMT") + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + + return dateFormatter.string(from: self).appending("Z") + } + + /// Nearest five minutes to date. + /// + /// var date = Date() // "5:54 PM" + /// date.minute = 32 // "5:32 PM" + /// date.nearestFiveMinutes // "5:30 PM" + /// + /// date.minute = 44 // "5:44 PM" + /// date.nearestFiveMinutes // "5:45 PM" + /// + var nearestFiveMinutes: Date { + var components = calendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second, .nanosecond], + from: self) + let min = components.minute! + components.minute! = min % 5 < 3 ? min - min % 5 : min + 5 - (min % 5) + components.second = 0 + components.nanosecond = 0 + return calendar.date(from: components)! + } + + /// Nearest ten minutes to date. + /// + /// var date = Date() // "5:57 PM" + /// date.minute = 34 // "5:34 PM" + /// date.nearestTenMinutes // "5:30 PM" + /// + /// date.minute = 48 // "5:48 PM" + /// date.nearestTenMinutes // "5:50 PM" + /// + var nearestTenMinutes: Date { + var components = calendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second, .nanosecond], + from: self) + let min = components.minute! + components.minute? = min % 10 < 5 ? min - min % 10 : min + 10 - (min % 10) + components.second = 0 + components.nanosecond = 0 + return calendar.date(from: components)! + } + + /// Nearest quarter hour to date. + /// + /// var date = Date() // "5:57 PM" + /// date.minute = 34 // "5:34 PM" + /// date.nearestQuarterHour // "5:30 PM" + /// + /// date.minute = 40 // "5:40 PM" + /// date.nearestQuarterHour // "5:45 PM" + /// + var nearestQuarterHour: Date { + var components = calendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second, .nanosecond], + from: self) + let min = components.minute! + components.minute! = min % 15 < 8 ? min - min % 15 : min + 15 - (min % 15) + components.second = 0 + components.nanosecond = 0 + return calendar.date(from: components)! + } + + /// Nearest half hour to date. + /// + /// var date = Date() // "6:07 PM" + /// date.minute = 41 // "6:41 PM" + /// date.nearestHalfHour // "6:30 PM" + /// + /// date.minute = 51 // "6:51 PM" + /// date.nearestHalfHour // "7:00 PM" + /// + var nearestHalfHour: Date { + var components = calendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second, .nanosecond], + from: self) + let min = components.minute! + components.minute! = min % 30 < 15 ? min - min % 30 : min + 30 - (min % 30) + components.second = 0 + components.nanosecond = 0 + return calendar.date(from: components)! + } + + /// Nearest hour to date. + /// + /// var date = Date() // "6:17 PM" + /// date.nearestHour // "6:00 PM" + /// + /// date.minute = 36 // "6:36 PM" + /// date.nearestHour // "7:00 PM" + /// + var nearestHour: Date { + let min = calendar.component(.minute, from: self) + let components: Set = [.year, .month, .day, .hour] + let date = calendar.date(from: calendar.dateComponents(components, from: self))! + + if min < 30 { + return date + } + return calendar.date(byAdding: .hour, value: 1, to: date)! + } + + /// Yesterday date. + /// + /// let date = Date() // "Oct 3, 2018, 10:57:11" + /// let yesterday = date.yesterday // "Oct 2, 2018, 10:57:11" + /// + var yesterday: Date { + return calendar.date(byAdding: .day, value: -1, to: self) ?? Date() + } + + /// Tomorrow's date. + /// + /// let date = Date() // "Oct 3, 2018, 10:57:11" + /// let tomorrow = date.tomorrow // "Oct 4, 2018, 10:57:11" + /// + var tomorrow: Date { + return calendar.date(byAdding: .day, value: 1, to: self) ?? Date() + } + + /// UNIX timestamp from date. + /// + /// Date().unixTimestamp -> 1484233862.826291 + /// + var unixTimestamp: Double { + return timeIntervalSince1970 + } +} + +// MARK: - Methods + +public extension Date { + /// Date by adding multiples of calendar component. + /// + /// let date = Date() // "Jan 12, 2017, 7:07 PM" + /// let date2 = date.adding(.minute, value: -10) // "Jan 12, 2017, 6:57 PM" + /// let date3 = date.adding(.day, value: 4) // "Jan 16, 2017, 7:07 PM" + /// let date4 = date.adding(.month, value: 2) // "Mar 12, 2017, 7:07 PM" + /// let date5 = date.adding(.year, value: 13) // "Jan 12, 2030, 7:07 PM" + /// + /// - Parameters: + /// - component: component type. + /// - value: multiples of components to add. + /// - Returns: original date + multiples of component added. + func adding(_ component: Calendar.Component, value: Int) -> Date { + return calendar.date(byAdding: component, value: value, to: self)! + } + + /// Add calendar component to date. + /// + /// var date = Date() // "Jan 12, 2017, 7:07 PM" + /// date.add(.minute, value: -10) // "Jan 12, 2017, 6:57 PM" + /// date.add(.day, value: 4) // "Jan 16, 2017, 7:07 PM" + /// date.add(.month, value: 2) // "Mar 12, 2017, 7:07 PM" + /// date.add(.year, value: 13) // "Jan 12, 2030, 7:07 PM" + /// + /// - Parameters: + /// - component: component type. + /// - value: multiples of component to add. + mutating func add(_ component: Calendar.Component, value: Int) { + if let date = calendar.date(byAdding: component, value: value, to: self) { + self = date + } + } + + // swiftlint:disable cyclomatic_complexity + /// Date by changing value of calendar component. + /// + /// let date = Date() // "Jan 12, 2017, 7:07 PM" + /// let date2 = date.changing(.minute, value: 10) // "Jan 12, 2017, 7:10 PM" + /// let date3 = date.changing(.day, value: 4) // "Jan 4, 2017, 7:07 PM" + /// let date4 = date.changing(.month, value: 2) // "Feb 12, 2017, 7:07 PM" + /// let date5 = date.changing(.year, value: 2000) // "Jan 12, 2000, 7:07 PM" + /// + /// - Parameters: + /// - component: component type. + /// - value: new value of component to change. + /// - Returns: original date after changing given component to given value. + func changing(_ component: Calendar.Component, value: Int) -> Date? { + switch component { + case .nanosecond: + #if targetEnvironment(macCatalyst) + // The `Calendar` implementation in `macCatalyst` does not know that a nanosecond is 1/1,000,000,000th of a + // second + let allowedRange = 0..<1_000_000_000 + #else + let allowedRange = calendar.range(of: .nanosecond, in: .second, for: self)! + #endif + guard allowedRange.contains(value) else { return nil } + let currentNanoseconds = calendar.component(.nanosecond, from: self) + let nanosecondsToAdd = value - currentNanoseconds + return calendar.date(byAdding: .nanosecond, value: nanosecondsToAdd, to: self) + + case .second: + let allowedRange = calendar.range(of: .second, in: .minute, for: self)! + guard allowedRange.contains(value) else { return nil } + let currentSeconds = calendar.component(.second, from: self) + let secondsToAdd = value - currentSeconds + return calendar.date(byAdding: .second, value: secondsToAdd, to: self) + + case .minute: + let allowedRange = calendar.range(of: .minute, in: .hour, for: self)! + guard allowedRange.contains(value) else { return nil } + let currentMinutes = calendar.component(.minute, from: self) + let minutesToAdd = value - currentMinutes + return calendar.date(byAdding: .minute, value: minutesToAdd, to: self) + + case .hour: + let allowedRange = calendar.range(of: .hour, in: .day, for: self)! + guard allowedRange.contains(value) else { return nil } + let currentHour = calendar.component(.hour, from: self) + let hoursToAdd = value - currentHour + return calendar.date(byAdding: .hour, value: hoursToAdd, to: self) + + case .day: + let allowedRange = calendar.range(of: .day, in: .month, for: self)! + guard allowedRange.contains(value) else { return nil } + let currentDay = calendar.component(.day, from: self) + let daysToAdd = value - currentDay + return calendar.date(byAdding: .day, value: daysToAdd, to: self) + + case .month: + let allowedRange = calendar.range(of: .month, in: .year, for: self)! + guard allowedRange.contains(value) else { return nil } + let currentMonth = calendar.component(.month, from: self) + let monthsToAdd = value - currentMonth + return calendar.date(byAdding: .month, value: monthsToAdd, to: self) + + case .year: + guard value > 0 else { return nil } + let currentYear = calendar.component(.year, from: self) + let yearsToAdd = value - currentYear + return calendar.date(byAdding: .year, value: yearsToAdd, to: self) + + default: + return calendar.date(bySetting: component, value: value, of: self) + } + } + + // swiftlint:enable cyclomatic_complexity + + #if !os(Linux) + + /// Data at the beginning of calendar component. + /// + /// let date = Date() // "Jan 12, 2017, 7:14 PM" + /// let date2 = date.beginning(of: .hour) // "Jan 12, 2017, 7:00 PM" + /// let date3 = date.beginning(of: .month) // "Jan 1, 2017, 12:00 AM" + /// let date4 = date.beginning(of: .year) // "Jan 1, 2017, 12:00 AM" + /// + /// - Parameter component: calendar component to get date at the beginning of. + /// - Returns: date at the beginning of calendar component (if applicable). + func beginning(of component: Calendar.Component) -> Date? { + if component == .day { + return calendar.startOfDay(for: self) + } + + var components: Set { + switch component { + case .second: + return [.year, .month, .day, .hour, .minute, .second] + + case .minute: + return [.year, .month, .day, .hour, .minute] + + case .hour: + return [.year, .month, .day, .hour] + + case .weekOfYear, .weekOfMonth: + return [.yearForWeekOfYear, .weekOfYear] + + case .month: + return [.year, .month] + + case .year: + return [.year] + + default: + return [] + } + } + + guard !components.isEmpty else { return nil } + return calendar.date(from: calendar.dateComponents(components, from: self)) + } + #endif + + /// Date at the end of calendar component. + /// + /// let date = Date() // "Jan 12, 2017, 7:27 PM" + /// let date2 = date.end(of: .day) // "Jan 12, 2017, 11:59 PM" + /// let date3 = date.end(of: .month) // "Jan 31, 2017, 11:59 PM" + /// let date4 = date.end(of: .year) // "Dec 31, 2017, 11:59 PM" + /// + /// - Parameter component: calendar component to get date at the end of. + /// - Returns: date at the end of calendar component (if applicable). + func end(of component: Calendar.Component) -> Date? { + switch component { + case .second: + var date = adding(.second, value: 1) + date = calendar.date(from: + calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date))! + date.add(.second, value: -1) + return date + + case .minute: + var date = adding(.minute, value: 1) + let after = calendar.date(from: + calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date))! + date = after.adding(.second, value: -1) + return date + + case .hour: + var date = adding(.hour, value: 1) + let after = calendar.date(from: + calendar.dateComponents([.year, .month, .day, .hour], from: date))! + date = after.adding(.second, value: -1) + return date + + case .day: + var date = adding(.day, value: 1) + date = calendar.startOfDay(for: date) + date.add(.second, value: -1) + return date + + case .weekOfYear, .weekOfMonth: + var date = self + let beginningOfWeek = calendar.date(from: + calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))! + date = beginningOfWeek.adding(.day, value: 7).adding(.second, value: -1) + return date + + case .month: + var date = adding(.month, value: 1) + let after = calendar.date(from: + calendar.dateComponents([.year, .month], from: date))! + date = after.adding(.second, value: -1) + return date + + case .year: + var date = adding(.year, value: 1) + let after = calendar.date(from: + calendar.dateComponents([.year], from: date))! + date = after.adding(.second, value: -1) + return date + + default: + return nil + } + } + + /// Check if date is in current given calendar component. + /// + /// Date().isInCurrent(.day) -> true + /// Date().isInCurrent(.year) -> true + /// + /// - Parameter component: calendar component to check. + /// - Returns: true if date is in current given calendar component. + func isInCurrent(_ component: Calendar.Component) -> Bool { + return calendar.isDate(self, equalTo: Date(), toGranularity: component) + } + + /// Date string from date. + /// + /// Date().string(withFormat: "dd/MM/yyyy") -> "1/12/17" + /// Date().string(withFormat: "HH:mm") -> "23:50" + /// Date().string(withFormat: "dd/MM/yyyy HH:mm") -> "1/12/17 23:50" + /// + /// - Parameter format: Date format (default is "dd/MM/yyyy"). + /// - Returns: date string. + func string(withFormat format: String = "dd/MM/yyyy HH:mm") -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = format + return dateFormatter.string(from: self) + } + + /// Date string from date. + /// + /// Date().dateString(ofStyle: .short) -> "1/12/17" + /// Date().dateString(ofStyle: .medium) -> "Jan 12, 2017" + /// Date().dateString(ofStyle: .long) -> "January 12, 2017" + /// Date().dateString(ofStyle: .full) -> "Thursday, January 12, 2017" + /// + /// - Parameter style: DateFormatter style (default is .medium). + /// - Returns: date string. + func dateString(ofStyle style: DateFormatter.Style = .medium) -> String { + let dateFormatter = DateFormatter() + dateFormatter.timeStyle = .none + dateFormatter.dateStyle = style + return dateFormatter.string(from: self) + } + + /// Date and time string from date. + /// + /// Date().dateTimeString(ofStyle: .short) -> "1/12/17, 7:32 PM" + /// Date().dateTimeString(ofStyle: .medium) -> "Jan 12, 2017, 7:32:00 PM" + /// Date().dateTimeString(ofStyle: .long) -> "January 12, 2017 at 7:32:00 PM GMT+3" + /// Date().dateTimeString(ofStyle: .full) -> "Thursday, January 12, 2017 at 7:32:00 PM GMT+03:00" + /// + /// - Parameter style: DateFormatter style (default is .medium). + /// - Returns: date and time string. + func dateTimeString(ofStyle style: DateFormatter.Style = .medium) -> String { + let dateFormatter = DateFormatter() + dateFormatter.timeStyle = style + dateFormatter.dateStyle = style + return dateFormatter.string(from: self) + } + + /// Time string from date. + /// + /// Date().timeString(ofStyle: .short) -> "7:37 PM" + /// Date().timeString(ofStyle: .medium) -> "7:37:02 PM" + /// Date().timeString(ofStyle: .long) -> "7:37:02 PM GMT+3" + /// Date().timeString(ofStyle: .full) -> "7:37:02 PM GMT+03:00" + /// + /// - Parameter style: DateFormatter style (default is .medium). + /// - Returns: time string. + func timeString(ofStyle style: DateFormatter.Style = .medium) -> String { + let dateFormatter = DateFormatter() + dateFormatter.timeStyle = style + dateFormatter.dateStyle = .none + return dateFormatter.string(from: self) + } + + /// Day name from date. + /// + /// Date().dayName(ofStyle: .oneLetter) -> "T" + /// Date().dayName(ofStyle: .threeLetters) -> "Thu" + /// Date().dayName(ofStyle: .full) -> "Thursday" + /// + /// - Parameter Style: style of day name (default is DayNameStyle.full). + /// - Returns: day name string (example: W, Wed, Wednesday). + func dayName(ofStyle style: DayNameStyle = .full) -> String { + // http://www.codingexplorer.com/swiftly-getting-human-readable-date-nsdateformatter/ + let dateFormatter = DateFormatter() + var format: String { + switch style { + case .oneLetter: + return "EEEEE" + case .threeLetters: + return "EEE" + case .full: + return "EEEE" + } + } + dateFormatter.setLocalizedDateFormatFromTemplate(format) + return dateFormatter.string(from: self) + } + + /// Month name from date. + /// + /// Date().monthName(ofStyle: .oneLetter) -> "J" + /// Date().monthName(ofStyle: .threeLetters) -> "Jan" + /// Date().monthName(ofStyle: .full) -> "January" + /// + /// - Parameter Style: style of month name (default is MonthNameStyle.full). + /// - Returns: month name string (example: D, Dec, December). + func monthName(ofStyle style: MonthNameStyle = .full) -> String { + // http://www.codingexplorer.com/swiftly-getting-human-readable-date-nsdateformatter/ + let dateFormatter = DateFormatter() + var format: String { + switch style { + case .oneLetter: + return "MMMMM" + case .threeLetters: + return "MMM" + case .full: + return "MMMM" + } + } + dateFormatter.setLocalizedDateFormatFromTemplate(format) + return dateFormatter.string(from: self) + } + + /// get number of seconds between two date + /// + /// - Parameter date: date to compare self to. + /// - Returns: number of seconds between self and given date. + func secondsSince(_ date: Date) -> Double { + return timeIntervalSince(date) + } + + /// get number of minutes between two date + /// + /// - Parameter date: date to compare self to. + /// - Returns: number of minutes between self and given date. + func minutesSince(_ date: Date) -> Double { + return timeIntervalSince(date) / 60 + } + + /// get number of hours between two date + /// + /// - Parameter date: date to compare self to. + /// - Returns: number of hours between self and given date. + func hoursSince(_ date: Date) -> Double { + return timeIntervalSince(date) / 3600 + } + + /// get number of days between two date + /// + /// - Parameter date: date to compare self to. + /// - Returns: number of days between self and given date. + func daysSince(_ date: Date) -> Double { + return timeIntervalSince(date) / (3600 * 24) + } + + /// check if a date is between two other dates. + /// + /// - Parameters: + /// - startDate: start date to compare self to. + /// - endDate: endDate date to compare self to. + /// - includeBounds: true if the start and end date should be included (default is false). + /// - Returns: true if the date is between the two given dates. + func isBetween(_ startDate: Date, _ endDate: Date, includeBounds: Bool = false) -> Bool { + if includeBounds { + return startDate.compare(self).rawValue * compare(endDate).rawValue >= 0 + } + return startDate.compare(self).rawValue * compare(endDate).rawValue > 0 + } + + /// check if a date is a number of date components of another date. + /// + /// - Parameters: + /// - value: number of times component is used in creating range. + /// - component: Calendar.Component to use. + /// - date: Date to compare self to. + /// - Returns: true if the date is within a number of components of another date. + func isWithin(_ value: UInt, _ component: Calendar.Component, of date: Date) -> Bool { + let components = calendar.dateComponents([component], from: self, to: date) + guard let componentValue = components.value(for: component) else { return false } + return abs(componentValue) <= value + } + + /// Returns a random date within the specified range. + /// + /// - Parameter range: The range in which to create a random date. `range` must not be empty. + /// - Returns: A random date within the bounds of `range`. + static func random(in range: Range) -> Date { + return Date(timeIntervalSinceReferenceDate: + TimeInterval + .random(in: range.lowerBound.timeIntervalSinceReferenceDate..) -> Date { + return Date(timeIntervalSinceReferenceDate: + TimeInterval + .random(in: range.lowerBound.timeIntervalSinceReferenceDate...range.upperBound + .timeIntervalSinceReferenceDate)) + } + + /// Returns a random date within the specified range, using the given generator as a source for + /// randomness. + /// + /// - Parameters: + /// - range: The range in which to create a random date. `range` must not be empty. + /// - generator: The random number generator to use when creating the new random date. + /// - Returns: A random date within the bounds of `range`. + static func random(in range: Range, using generator: inout T) -> Date where T: RandomNumberGenerator { + return Date(timeIntervalSinceReferenceDate: + TimeInterval.random( + in: range.lowerBound.timeIntervalSinceReferenceDate..(in range: ClosedRange, using generator: inout T) -> Date + where T: RandomNumberGenerator { + return Date(timeIntervalSinceReferenceDate: + TimeInterval.random( + in: range.lowerBound.timeIntervalSinceReferenceDate...range.upperBound.timeIntervalSinceReferenceDate, + using: &generator)) + } +} + +// MARK: - Initializers + +public extension Date { + /// Create a new date form calendar components. + /// + /// let date = Date(year: 2010, month: 1, day: 12) // "Jan 12, 2010, 7:45 PM" + /// + /// - Parameters: + /// - calendar: Calendar (default is current). + /// - timeZone: TimeZone (default is current). + /// - era: Era (default is current era). + /// - year: Year (default is current year). + /// - month: Month (default is current month). + /// - day: Day (default is today). + /// - hour: Hour (default is current hour). + /// - minute: Minute (default is current minute). + /// - second: Second (default is current second). + /// - nanosecond: Nanosecond (default is current nanosecond). + init?( + calendar: Calendar? = Calendar.current, + timeZone: TimeZone? = NSTimeZone.default, + era: Int? = Date().era, + year: Int? = Date().year, + month: Int? = Date().month, + day: Int? = Date().day, + hour: Int? = Date().hour, + minute: Int? = Date().minute, + second: Int? = Date().second, + nanosecond: Int? = Date().nanosecond) { + var components = DateComponents() + components.calendar = calendar + components.timeZone = timeZone + components.era = era + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + components.second = second + components.nanosecond = nanosecond + + guard let date = calendar?.date(from: components) else { return nil } + self = date + } + + /// Create date object from ISO8601 string. + /// + /// let date = Date(iso8601String: "2017-01-12T16:48:00.959Z") // "Jan 12, 2017, 7:48 PM" + /// + /// - Parameter iso8601String: ISO8601 string of format (yyyy-MM-dd'T'HH:mm:ss.SSSZ). + init?(iso8601String: String) { + // https://github.com/justinmakaila/NSDate-ISO-8601/blob/master/NSDateISO8601.swift + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone.current + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + guard let date = dateFormatter.date(from: iso8601String) else { return nil } + self = date + } + + /// Create new date object from UNIX timestamp. + /// + /// let date = Date(unixTimestamp: 1484239783.922743) // "Jan 12, 2017, 7:49 PM" + /// + /// - Parameter unixTimestamp: UNIX timestamp. + init(unixTimestamp: Double) { + self.init(timeIntervalSince1970: unixTimestamp) + } + + /// Create date object from Int literal. + /// + /// let date = Date(integerLiteral: 2017_12_25) // "2017-12-25 00:00:00 +0000" + /// - Parameter value: Int value, e.g. 20171225, or 2017_12_25 etc. + init?(integerLiteral value: Int) { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd" + guard let date = formatter.date(from: String(value)) else { return nil } + self = date + } +} + +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Decimal.swift b/Sources/LCEssentials/Extensions/LCEssentials+Decimal.swift new file mode 100644 index 0000000..136120c --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Decimal.swift @@ -0,0 +1,37 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +public extension Decimal { + mutating func round(_ scale: Int, _ roundingMode: NSDecimalNumber.RoundingMode) { + var localCopy = self + NSDecimalRound(&self, &localCopy, scale, roundingMode) + } + + func rounded(_ scale: Int, _ roundingMode: NSDecimalNumber.RoundingMode) -> Decimal { + var result = Decimal() + var localCopy = self + NSDecimalRound(&result, &localCopy, scale, roundingMode) + return result + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift b/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift new file mode 100644 index 0000000..27368a6 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Dictionary.swift @@ -0,0 +1,324 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit + +public extension Dictionary { + + /// Deep fetch or set a value from nested dictionaries. + /// + /// var dict = ["key": ["key1": ["key2": "value"]]] + /// dict[path: ["key", "key1", "key2"]] = "newValue" + /// dict[path: ["key", "key1", "key2"]] -> "newValue" + /// + /// - Note: Value fetching is iterative, while setting is recursive. + /// + /// - Complexity: O(N), _N_ being the length of the path passed in. + /// + /// - Parameter path: An array of keys to the desired value. + /// + /// - Returns: The value for the key-path passed in. `nil` if no value is found. + subscript(path path: [Key]) -> Any? { + get { + guard !path.isEmpty else { return nil } + var result: Any? = self + for key in path { + if let element = (result as? [Key: Any])?[key] { + result = element + } else { + return nil + } + } + return result + } + set { + if let first = path.first { + if path.count == 1, let new = newValue as? Value { + return self[first] = new + } + if var nested = self[first] as? [Key: Any] { + nested[path: Array(path.dropFirst())] = newValue + return self[first] = nested as? Value + } + } + } + } + + + var queryString: String { + var output: String = "" + for (key,value) in self { + output += "\(key)=\(value)&" + } + output = String(output.dropLast()) + return output + } + + var convertToJSON: String { + do { + let jsonData = try JSONSerialization.data(withJSONObject: self, options: JSONSerialization.WritingOptions.prettyPrinted) + if let jsonString = NSString(data: jsonData, encoding: String.Encoding.utf8.rawValue) as? String { + return jsonString + } + return "Data to String with UFT8 Encoded error parsing" + } catch { + return "\(error.localizedDescription)" + } + } + + /// Creates a Dictionary from a given sequence grouped by a given key path. + /// + /// - Parameters: + /// - sequence: Sequence being grouped. + /// - keypath: The key path to group by. + init(grouping sequence: S, by keyPath: KeyPath) where Value == [S.Element] { + self.init(grouping: sequence, by: { $0[keyPath: keyPath] }) + } + + //MARK: - Append Dictionary + static func += (lhs: inout [Key: Value], rhs: [Key: Value]) { + rhs.forEach { lhs[$0] = $1} + } + + /// Remove keys contained in the sequence from the dictionary + /// + /// let dict: [String: String] = ["key1": "value1", "key2": "value2", "key3": "value3"] + /// let result = dict-["key1", "key2"] + /// result.keys.contains("key3") -> true + /// result.keys.contains("key1") -> false + /// result.keys.contains("key2") -> false + /// + /// - Parameters: + /// - lhs: dictionary + /// - rhs: array with the keys to be removed. + /// - Returns: a new dictionary with keys removed. + static func - (lhs: [Key: Value], keys: S) -> [Key: Value] where S.Element == Key { + var result = lhs + result.removeAll(keys: keys) + return result + } + + /// Remove keys contained in the sequence from the dictionary + /// + /// var dict: [String: String] = ["key1": "value1", "key2": "value2", "key3": "value3"] + /// dict-=["key1", "key2"] + /// dict.keys.contains("key3") -> true + /// dict.keys.contains("key1") -> false + /// dict.keys.contains("key2") -> false + /// + /// - Parameters: + /// - lhs: dictionary + /// - rhs: array with the keys to be removed. + static func -= (lhs: inout [Key: Value], keys: S) where S.Element == Key { + lhs.removeAll(keys: keys) + } + + /// - LoverdeCo: Convert Dictonary to Object + /// + /// - returns: Object: Codable/Decodable + func toObjetct() -> T { + let jsonString = self.convertToJSON + let output: T = try! JSONDecoder.decode(jsonString) + return output + } + + /// Check if key exists in dictionary. + /// + /// let dict: [String: Any] = ["testKey": "testValue", "testArrayKey": [1, 2, 3, 4, 5]] + /// dict.has(key: "testKey") -> true + /// dict.has(key: "anotherKey") -> false + /// + /// - Parameter key: key to search for + /// - Returns: true if key exists in dictionary. + func has(key: Key) -> Bool { + return index(forKey: key) != nil + } + + /// Remove all keys contained in the keys parameter from the dictionary. + /// + /// var dict : [String: String] = ["key1" : "value1", "key2" : "value2", "key3" : "value3"] + /// dict.removeAll(keys: ["key1", "key2"]) + /// dict.keys.contains("key3") -> true + /// dict.keys.contains("key1") -> false + /// dict.keys.contains("key2") -> false + /// + /// - Parameter keys: keys to be removed + mutating func removeAll(keys: S) where S.Element == Key { + keys.forEach { removeValue(forKey: $0) } + } + + /// Remove a value for a random key from the dictionary. + @discardableResult + mutating func removeValueForRandomKey() -> Value? { + guard let randomKey = keys.randomElement() else { return nil } + return removeValue(forKey: randomKey) + } + +#if canImport(Foundation) + /// JSON Data from dictionary. + /// + /// - Parameter prettify: set true to prettify data (default is false). + /// - Returns: optional JSON Data (if applicable). + func jsonData(prettify: Bool = false) -> Data? { + guard JSONSerialization.isValidJSONObject(self) else { + return nil + } + let options = (prettify == true) ? JSONSerialization.WritingOptions.prettyPrinted : JSONSerialization + .WritingOptions() + return try? JSONSerialization.data(withJSONObject: self, options: options) + } +#endif + +#if canImport(Foundation) + /// JSON String from dictionary. + /// + /// dict.jsonString() -> "{"testKey":"testValue","testArrayKey":[1,2,3,4,5]}" + /// + /// dict.jsonString(prettify: true) + /// /* + /// returns the following string: + /// + /// "{ + /// "testKey" : "testValue", + /// "testArrayKey" : [ + /// 1, + /// 2, + /// 3, + /// 4, + /// 5 + /// ] + /// }" + /// + /// */ + /// + /// - Parameter prettify: set true to prettify string (default is false). + /// - Returns: optional JSON String (if applicable). + func jsonString(prettify: Bool = false) -> String? { + guard JSONSerialization.isValidJSONObject(self) else { return nil } + let options = (prettify == true) ? JSONSerialization.WritingOptions.prettyPrinted : JSONSerialization + .WritingOptions() + guard let jsonData = try? JSONSerialization.data(withJSONObject: self, options: options) else { return nil } + return String(data: jsonData, encoding: .utf8) + } +#endif + + /// Returns a dictionary containing the results of mapping the given closure over the sequence’s + /// elements. + /// - Parameter transform: A mapping closure. `transform` accepts an element of this sequence as its parameter and + /// returns a transformed value of the same or of a different type. + /// - Returns: A dictionary containing the transformed elements of this sequence. + func mapKeysAndValues(_ transform: ((key: Key, value: Value)) throws -> (K, V)) rethrows -> [K: V] { + return try [K: V](uniqueKeysWithValues: map(transform)) + } + + /// Returns a dictionary containing the non-`nil` results of calling the given transformation with + /// each element of this sequence. + /// - Parameter transform: A closure that accepts an element of this sequence as its argument and returns an + /// optional value. + /// - Returns: A dictionary of the non-`nil` results of calling `transform` with each element of the sequence. + /// - Complexity: *O(m + n)*, where _m_ is the length of this sequence and _n_ is the length of the result. + func compactMapKeysAndValues(_ transform: ((key: Key, value: Value)) throws -> (K, V)?) rethrows -> [K: V] { + return try [K: V](uniqueKeysWithValues: compactMap(transform)) + } + + /// Creates a new dictionary using specified keys. + /// + /// var dict = ["key1": 1, "key2": 2, "key3": 3, "key4": 4] + /// dict.pick(keys: ["key1", "key3", "key4"]) -> ["key1": 1, "key3": 3, "key4": 4] + /// dict.pick(keys: ["key2"]) -> ["key2": 2] + /// + /// - Complexity: O(K), where _K_ is the length of the keys array. + /// + /// - Parameter keys: An array of keys that will be the entries in the resulting dictionary. + /// + /// - Returns: A new dictionary that contains the specified keys only. If none of the keys exist, an empty + /// dictionary will be returned. + func pick(keys: [Key]) -> [Key: Value] { + keys.reduce(into: [Key: Value]()) { result, item in + result[item] = self[item] + } + } + + /// Merge the keys/values of two dictionaries. + /// + /// let dict: [String: String] = ["key1": "value1"] + /// let dict2: [String: String] = ["key2": "value2"] + /// let result = dict + dict2 + /// result["key1"] -> "value1" + /// result["key2"] -> "value2" + /// + /// - Parameters: + /// - lhs: dictionary. + /// - rhs: dictionary. + /// - Returns: An dictionary with keys and values from both. + static func + (lhs: [Key: Value], rhs: [Key: Value]) -> [Key: Value] { + var result = lhs + rhs.forEach { result[$0] = $1 } + return result + } +} + + +// MARK: - Methods (Value: Equatable) +public extension Dictionary where Value: Equatable { + + /// Returns an array of all keys that have the given value in dictionary. + /// + /// let dict = ["key1": "value1", "key2": "value1", "key3": "value2"] + /// dict.keys(forValue: "value1") -> ["key1", "key2"] + /// dict.keys(forValue: "value2") -> ["key3"] + /// dict.keys(forValue: "value3") -> [] + /// + /// - Parameter value: Value for which keys are to be fetched. + /// - Returns: An array containing keys that have the given value. + func keys(forValue value: Value) -> [Key] { + return keys.filter { self[$0] == value } + } + +} + +// MARK: - Methods (ExpressibleByStringLiteral) +public extension Dictionary where Key: StringProtocol { + + /// Lowercase all keys in dictionary. + /// + /// var dict = ["tEstKeY": "value"] + /// dict.lowercaseAllKeys() + /// print(dict) // prints "["testkey": "value"]" + /// + mutating func lowercaseAllKeys() { + // http://stackoverflow.com/questions/33180028/extend-dictionary-where-key-is-of-type-string + for key in keys { + if let lowercaseKey = String(describing: key).lowercased() as? Key { + self[lowercaseKey] = removeValue(forKey: key) + } + } + } +} + +public extension Dictionary where Value: Hashable { + var uniqueValues: Dictionary { + var seenValues = Set() + return self.filter { seenValues.insert($0.value).inserted } + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Double.swift b/Sources/LCEssentials/Extensions/LCEssentials+Double.swift new file mode 100644 index 0000000..9d2e57c --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Double.swift @@ -0,0 +1,77 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +#if canImport(CoreGraphics) +import CoreGraphics +#endif + +#if os(macOS) || os(iOS) +import Darwin +#elseif os(Linux) +import Glibc +#endif + +// MARK: - Properties + +public extension Double { + + var int: Int { + return Int(self) + } + + var float: Float { + return Float(self) + } + + #if canImport(CoreGraphics) + var cgFloat: CGFloat { + return CGFloat(self) + } + #endif + + var satsToBTC: Double { + return (self / bitcoinIntConvertion.double) + } + + var convertToBTC: Double { + return satsToBTC + } + + var toBTC: Double { + return satsToBTC + } +} + +// MARK: - Operators + +precedencegroup PowerPrecedence { higherThan: MultiplicationPrecedence } +infix operator **: PowerPrecedence +/// Value of exponentiation. +/// +/// - Parameters: +/// - lhs: base double. +/// - rhs: exponent double. +/// - Returns: exponentiation result (example: 4.4 ** 0.5 = 2.0976176963). +public func ** (lhs: Double, rhs: Double) -> Double { + // http://nshipster.com/swift-operators/ + return pow(lhs, rhs) +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift b/Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift new file mode 100644 index 0000000..f72313b --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+EncodableDecodable.swift @@ -0,0 +1,104 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +//MARK: - Codables convertions +public extension Encodable { + subscript(key: String) -> Any? { + return dictionary[key] + } + var dictionary: [String: Any] { + return (try? JSONSerialization.jsonObject(with: JSONEncoder().encode(self))) as? [String: Any] ?? [:] + } + var json: String { + return self.dictionary.convertToJSON + } + + var data: Data { + return self.json.data + } +} + +extension JSONDecoder { + + /// - LoverdeCo: Decode JSON Data to Object + /// + /// - Parameter data: Data + /// - returns: Object: Codable/Decodable + public static func decode(data: Data) throws -> T { + var error = NSError(domain: "", code: 0) + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .useDefaultKeys + do { + return try decoder.decode(T.self, from: data) + } catch let DecodingError.keyNotFound(key, context) { + let msg = "Missing key '\(key.stringValue)' in \(T.self) Object in JSON: \(context.debugDescription)" + error = NSError.createErrorWith(code: 0, description: msg, reasonForError: msg) + } catch let DecodingError.typeMismatch(type, context) { + let msg = "Type mismatch for type '\(type)' \(T.self) Object: \(context.debugDescription)" + error = NSError.createErrorWith(code: 0, description: msg, reasonForError: msg) + } catch let DecodingError.valueNotFound(value, context) { + let msg = "Missing value '\(value)' in \(T.self) Object in JSON: \(context.debugDescription)" + error = NSError.createErrorWith(code: 0, description: msg, reasonForError: msg) + } catch { + throw error + } + throw error + } + + /// - LoverdeCo: Decode JSON String to Object + /// + /// - Parameter json: String + /// - returns: Object: Codable/Decodable + public static func decode(_ json: String, using encoding: String.Encoding = .utf8) throws -> T { + var error = NSError() + if let jsonData = json.data(using: .utf8) { + do { + return try decode(data: jsonData) + } catch { + throw error + } + } + throw error + } + + /// - LoverdeCo: Decode JSON URL to Object + /// + /// - Parameter url: URL + /// - returns: Object: Codable/Decodable + public static func decode(fromURL url: URL) throws -> T { + return try decode(data: try! Data(contentsOf: url)) + } + + public static func decode(dictionary: Any) throws -> T { + do { + let json = try JSONSerialization.data(withJSONObject: dictionary) + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(T.self, from: json) + } catch { + printError(title: "JSONDecoder.decode", msg: error, prettyPrint: true) + throw error + } + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+FileManager.swift b/Sources/LCEssentials/Extensions/LCEssentials+FileManager.swift new file mode 100644 index 0000000..fb94fe6 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+FileManager.swift @@ -0,0 +1,128 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit + +public extension FileManager { + + func createDirectory(_ directoryName: String) -> URL? { + let fileManager = FileManager.default + if let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + let filePath = documentDirectory.appendingPathComponent(directoryName) + if !fileManager.fileExists(atPath: filePath.path) { + do { + try fileManager.createDirectory(atPath: filePath.path, withIntermediateDirectories: true, attributes: nil) + } catch { + print(error.localizedDescription) + return nil + } + } + return filePath + } else { + return nil + } + } + + func saveFileToDirectory( _ sourceURL: URL, toPathURL: URL ) -> Bool { + + do { + try FileManager.default.moveItem(at: sourceURL, to: toPathURL) + return true + } catch let error as NSError { + print("Erro ao salvar o arquivo: \(error.localizedDescription)") + return false + } + } + +#if os(iOS) || os(macOS) + func saveImageToDirectory( _ imageWithPath : String, imagem : UIImage ) -> Bool { + + let data = imagem.pngData() + + let success = (try? data!.write(to: URL(fileURLWithPath: imageWithPath), options: [])) != nil + + //let success = NSFileManager.defaultManager().createFileAtPath(imageWithPath, contents: data, attributes: nil) + + if success { + return true + } else { + NSLog("Unable to create directory") + return false + } + } +#endif + + func retrieveFile( _ directoryAndFile: String ) -> URL { + let documentsPath = URL(fileURLWithPath: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]) + let logsPath = documentsPath.appendingPathComponent(directoryAndFile) + return logsPath + } + + func removeFile( _ directoryAndFile: String ) -> Bool { + do { + try self.removeItem(atPath: directoryAndFile) + return true + } + catch let error as NSError { + print("Ooops! Something went wrong: \(error)") + return false + } + } + /// LoverdeCo: Retrieve all files names as dictionary. + /// + /// - Parameters: + /// - directoryName: Give a directory name. + /// - Returns: + /// An array of files name: [String]. + func retrieveAllFilesFromDirectory(directoryName: String) -> [String]? { + let fileMngr = FileManager.default; + let docs = fileMngr.urls(for: .documentDirectory, in: .userDomainMask)[0].path + do { + var filelist = try fileMngr.contentsOfDirectory(atPath: "\(docs)/\(directoryName)") + if filelist.contains(".DS_Store") { + filelist.remove(at: filelist.firstIndex(of: ".DS_Store")!) + } + return filelist + } catch let error { + print("Error: \(error.localizedDescription)") + return nil + } + } + func directoryExistsAtPath(_ path: String) -> Bool { + var isDirectory = ObjCBool(true) + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + return exists && isDirectory.boolValue + } + + func convertToURL(path:String)-> URL?{ + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].path + do { + _ = try FileManager.default.contentsOfDirectory(atPath: "\(docs)/\(path)") + return URL(fileURLWithPath: "\(docs)/\(path)", isDirectory: true) + } catch let error { + print("Error: \(error.localizedDescription)") + return nil + } + } + +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Float.swift b/Sources/LCEssentials/Extensions/LCEssentials+Float.swift new file mode 100644 index 0000000..cbe859d --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Float.swift @@ -0,0 +1,79 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +#if canImport(CoreGraphics) +import CoreGraphics +#endif + +#if os(macOS) || os(iOS) +import Darwin +#elseif os(Linux) +import Glibc +#endif + +public extension Float { + + var int: Int { + return Int(self) + } + + var double: Double { + return Double(self) + } + +#if canImport(CoreGraphics) + var cgFloat: CGFloat { + return CGFloat(self) + } +#endif + + var satsToBTC: Double { + return (self / bitcoinIntConvertion.float).double + } + + var convertToBTC: Double { + return satsToBTC + } + + var toBTC: Double { + return satsToBTC + } + + /// Rounds the double to decimal places value + func rounded(toPlaces places:Int) -> Float { + let divisor = pow(10.0, Float(places)) + return (self * divisor).rounded() / divisor + } +} + +precedencegroup PowerPrecedence { higherThan: MultiplicationPrecedence } +infix operator **: PowerPrecedence +/// Value of exponentiation. +/// +/// - Parameters: +/// - lhs: base float. +/// - rhs: exponent float. +/// - Returns: exponentiation result (4.4 ** 0.5 = 2.0976176963). +public func ** (lhs: Float, rhs: Float) -> Float { + // http://nshipster.com/swift-operators/ + return pow(lhs, rhs) +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Int.swift b/Sources/LCEssentials/Extensions/LCEssentials+Int.swift new file mode 100644 index 0000000..d353d5a --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Int.swift @@ -0,0 +1,254 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +#if canImport(Foundation) +import Foundation +#endif + +#if canImport(CoreGraphics) +import CoreGraphics +#endif + +#if os(macOS) || os(iOS) +import Darwin +#elseif os(Linux) +import Glibc +#endif + +var bitcoinIntConvertion: Int { + return 100_000_000 +} + +// MARK: - Properties + +public extension Int { + /// CountableRange 0.. { + return 0..= 0 ? "" : "-" + } + let abs = Swift.abs(self) + if abs == 0 { + return "0k" + } else if abs >= 0, abs < 1000 { + return "0k" + } else if abs >= 1000, abs < 1_000_000 { + return String(format: "\(sign)%ik", abs / 1000) + } + return String(format: "\(sign)%ikk", abs / 100_000) + } + + /// Array of digits of integer value. + var digits: [Int] { + let abs = Swift.abs(self) + guard self != 0 else { return [0] } + var digits = [Int]() + var number = abs + + while number != 0 { + let xNumber = number % 10 + digits.append(xNumber) + number /= 10 + } + + digits.reverse() + return digits + } + + /// Number of digits of integer value. + var digitsCount: Int { + let abs = Swift.abs(self) + guard self != 0 else { return 1 } + let number = Double(abs) + return Int(log10(number) + 1) + } +} + +// MARK: - Methods + +public extension Int { + /// check if given integer prime or not. Warning: Using big numbers can be computationally expensive! + /// - Returns: true or false depending on prime-ness. + func isPrime() -> Bool { + // To improve speed on latter loop :) + if self == 2 { return true } + + guard self > 1, self % 2 != 0 else { return false } + + // Explanation: It is enough to check numbers until + // the square root of that number. If you go up from N by one, + // other multiplier will go 1 down to get similar result + // (integer-wise operation) such way increases speed of operation + let base = Int(sqrt(Double(self))) + for int in Swift.stride(from: 3, through: base, by: 2) where self % int == 0 { + return false + } + return true + } + + /// Roman numeral string from integer (if applicable). + /// + /// 10.romanNumeral() -> "X" + /// + /// - Returns: The roman numeral string. + func romanNumeral() -> String? { + // https://gist.github.com/kumo/a8e1cb1f4b7cff1548c7 + guard self > 0 else { // there is no roman numeral for 0 or negative numbers + return nil + } + let romanValues = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"] + let arabicValues = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1] + + var romanValue = "" + var startingValue = self + + for (index, romanChar) in romanValues.enumerated() { + let arabicValue = arabicValues[index] + let div = startingValue / arabicValue + for _ in 0..
Int { + return number == 0 ? self : Int(round(Double(self) / Double(number))) * number + } +} + +// MARK: - Operators + +precedencegroup PowerPrecedence { higherThan: MultiplicationPrecedence } +infix operator **: PowerPrecedence +/// Value of exponentiation. +/// +/// - Parameters: +/// - lhs: base integer. +/// - rhs: exponent integer. +/// - Returns: exponentiation result (example: 2 ** 3 = 8). +public func ** (lhs: Int, rhs: Int) -> Double { + // http://nshipster.com/swift-operators/ + return pow(Double(lhs), Double(rhs)) +} + +// swiftlint:disable identifier_name +prefix operator √ +/// Square root of integer. +/// +/// - Parameter int: integer value to find square root for. +/// - Returns: square root of given integer. +public prefix func √ (int: Int) -> Double { + // http://nshipster.com/swift-operators/ + return sqrt(Double(int)) +} + +// swiftlint:enable identifier_name + +// swiftlint:disable identifier_name +infix operator ± +/// Tuple of plus-minus operation. +/// +/// - Parameters: +/// - lhs: integer number. +/// - rhs: integer number. +/// - Returns: tuple of plus-minus operation (example: 2 ± 3 -> (5, -1)). +public func ± (lhs: Int, rhs: Int) -> (Int, Int) { + // http://nshipster.com/swift-operators/ + return (lhs + rhs, lhs - rhs) +} + +// swiftlint:enable identifier_name + +// swiftlint:disable identifier_name +prefix operator ± +/// Tuple of plus-minus operation. +/// +/// - Parameter int: integer number. +/// - Returns: tuple of plus-minus operation (example: ± 2 -> (2, -2)). +public prefix func ± (int: Int) -> (Int, Int) { + // http://nshipster.com/swift-operators/ + return (int, -int) +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+LosslessStringConvertible.swift b/Sources/LCEssentials/Extensions/LCEssentials+LosslessStringConvertible.swift new file mode 100644 index 0000000..0ecb590 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+LosslessStringConvertible.swift @@ -0,0 +1,29 @@ +// +// Copyright (c) 2024 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +public extension Sequence where Element == UInt8 { + var data: Data { .init(self) } + var base64Decoded: Data? { Data(base64Encoded: data) } + var string: String? { String(bytes: self, encoding: .utf8) } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+NSAttributedString.swift b/Sources/LCEssentials/Extensions/LCEssentials+NSAttributedString.swift new file mode 100644 index 0000000..fef9e03 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+NSAttributedString.swift @@ -0,0 +1,45 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +public extension NSAttributedString { + + //Example Usage + // + //let attributedString = NSAttributedString(html: "" Some html string "") + //myLabel.attributedText = attributedString + + convenience init?(html: String) { + guard let data = html.data(using: String.Encoding.utf8, allowLossyConversion: false) else { + return nil + } + let attributedOptions: [NSAttributedString.DocumentReadingOptionKey : Any] = [ + NSAttributedString.DocumentReadingOptionKey(rawValue: NSAttributedString.DocumentAttributeKey.documentType.rawValue): NSAttributedString.DocumentType.html, + NSAttributedString.DocumentReadingOptionKey(rawValue: NSAttributedString.DocumentAttributeKey.characterEncoding.rawValue): String.Encoding.utf8.rawValue + ] + guard let attributedString = try? NSMutableAttributedString(data: data, options: attributedOptions, documentAttributes: nil) else { + return nil + } + self.init(attributedString: attributedString) + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+NSError.swift b/Sources/LCEssentials/Extensions/LCEssentials+NSError.swift new file mode 100644 index 0000000..7666f27 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+NSError.swift @@ -0,0 +1,41 @@ +// +// Copyright (c) 2024 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +extension NSError { + public static func createErrorWith(code: Int, description: String, reasonForError: String) -> NSError { + let userInfo: [String : Any] = + [ + NSLocalizedDescriptionKey : NSLocalizedString("Generic Error", + value: description, + comment: "") , + NSLocalizedFailureReasonErrorKey : NSLocalizedString("Generic Error", + value: reasonForError, + comment: "") + ] + + return NSError(domain: LCEssentials.DEFAULT_ERROR_DOMAIN, + code: code, + userInfo: userInfo) + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+NSLayoutConstraints.swift b/Sources/LCEssentials/Extensions/LCEssentials+NSLayoutConstraints.swift new file mode 100644 index 0000000..a2d95be --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+NSLayoutConstraints.swift @@ -0,0 +1,74 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit + +extension NSLayoutConstraint { + + func constraintWithMultiplier(_ multiplier: CGFloat) -> NSLayoutConstraint { + guard let first = firstItem else { return NSLayoutConstraint() } + return NSLayoutConstraint(item: first, + attribute: self.firstAttribute, + relatedBy: self.relation, + toItem: self.secondItem, + attribute: self.secondAttribute, + multiplier: multiplier, + constant: self.constant) + } + + func matches(view: UIView, anchor: NSLayoutYAxisAnchor) -> Bool { + if let firstView = firstItem as? UIView, + firstView == view && firstAnchor == anchor { + return true + } + if let secondView = secondItem as? UIView, + secondView == view && secondAnchor == anchor { + return true + } + return false + } + + func matches(view: UIView, anchor: NSLayoutXAxisAnchor) -> Bool { + if let firstView = firstItem as? UIView, + firstView == view && firstAnchor == anchor { + return true + } + if let secondView = secondItem as? UIView, + secondView == view && secondAnchor == anchor { + return true + } + return false + } + + func matches(view: UIView, anchor: NSLayoutDimension) -> Bool { + if let firstView = firstItem as? UIView, + firstView == view && firstAnchor == anchor { + return true + } + if let secondView = secondItem as? UIView, + secondView == view && secondAnchor == anchor { + return true + } + return false + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+NSMutableAttributedString.swift b/Sources/LCEssentials/Extensions/LCEssentials+NSMutableAttributedString.swift new file mode 100644 index 0000000..1698573 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+NSMutableAttributedString.swift @@ -0,0 +1,191 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +#if os(iOS) || os(macOS) +import UIKit + +public extension NSMutableAttributedString { + @discardableResult func customize(_ text: String, + withFont font: UIFont, + color: UIColor? = nil, + lineSpace: CGFloat? = nil, + alignment: NSTextAlignment? = nil, + changeCurrentText: Bool = false) -> NSMutableAttributedString { + + var attrs: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: font] + if color != nil { + attrs[NSAttributedString.Key.foregroundColor] = color + } + if lineSpace != nil { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = lineSpace ?? 0 + attrs[NSAttributedString.Key.paragraphStyle] = paragraphStyle + } + + if let alignment = alignment { + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = alignment + attrs[NSAttributedString.Key.paragraphStyle] = paragraph + } + + if changeCurrentText { + self.addAttributes(attrs, range: self.mutableString.range(of: text)) + } else { + let customStr = NSMutableAttributedString(string: "\(text)", attributes: attrs) + self.append(customStr) + } + return self + } + + @discardableResult func underline(_ text: String, + withFont font: UIFont, + color: UIColor? = nil, + changeCurrentText: Bool = false) -> NSMutableAttributedString { + + var attrs: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue as AnyObject, + NSAttributedString.Key.font: font + ] + + if color != nil { + attrs[NSAttributedString.Key.foregroundColor] = color + } + + if changeCurrentText { + self.addAttributes(attrs, range: self.mutableString.range(of: text)) + } else { + let customStr = NSMutableAttributedString(string: "\(text)", attributes: attrs) + self.append(customStr) + } + return self + } + + @discardableResult + func strikethrough(_ text: String, changeCurrentText: Bool = false) -> Self { + let attrs: [NSAttributedString.Key: Any] = [ + .strikethroughStyle: NSUnderlineStyle.single.rawValue, + ] + if changeCurrentText { + self.addAttributes(attrs, range: self.mutableString.range(of: text)) + } else { + let customStr = NSMutableAttributedString(string: "\(text)", attributes: attrs) + self.append(customStr) + } + + return self + } + + @discardableResult func linkTouch(_ text: String, + url: String, + withFont font: UIFont, + color: UIColor = UIColor.blue, + changeCurrentText: Bool = false) -> NSMutableAttributedString { + + let linkTerms: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key.link: NSURL(string: url) ?? NSURL(), + NSAttributedString.Key.foregroundColor: color, + NSAttributedString.Key.underlineColor: color, + NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue, + NSAttributedString.Key.font: font + ] + + if changeCurrentText { + self.addAttributes(linkTerms, range: self.mutableString.range(of: text)) + } else { + let customStr = NSMutableAttributedString(string: "\(text)", attributes: linkTerms) + self.append(customStr) + } + return self + } + + @discardableResult func supperscript(_ text: String, + withFont font: UIFont, + color: UIColor? = nil, + offset: CGFloat, + changeCurrentText: Bool = false) -> NSMutableAttributedString { + + var attrs: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key.baselineOffset: offset, + NSAttributedString.Key.font: font + ] + + if color != nil { + attrs[NSAttributedString.Key.foregroundColor] = color + } + + if changeCurrentText { + self.addAttributes(attrs, range: self.mutableString.range(of: text)) + } else { + let customStr = NSMutableAttributedString(string: "\(text)", attributes: attrs) + self.append(customStr) + } + return self + } + + @discardableResult func appendImageToText(_ image: UIImage? = nil) -> NSMutableAttributedString { + let imageAttach = NSTextAttachment() + imageAttach.image = image + + let imgStr = NSAttributedString(attachment: imageAttach) + + append(imgStr) + + return self + } + + func attributtedString() -> NSAttributedString { + let range = self.string.range(of: self.string) ?? Range(uncheckedBounds: (self.string.startIndex, upper: self.string.endIndex)) + let nsRange = self.string.nsRange(from: range) ?? NSRange() + return self.attributedSubstring(from: nsRange) + } + + @discardableResult func normal(_ text: String) -> NSMutableAttributedString { + let normal = NSAttributedString(string: text) + self.append(normal) + return self + } + + func canSetAsLink(textToFind: String, linkURL: String) -> Bool { + let foundRange = self.mutableString.range(of: textToFind) + if foundRange.location != NSNotFound { + self.addAttribute(NSAttributedString.Key.link, value: linkURL, range: foundRange) + return true + } + return false + } + + func height(withConstrainedWidth width: CGFloat) -> CGFloat { + let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude) + let boundingBox = boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, context: nil) + + return ceil(boundingBox.height) + } + + func width(withConstrainedHeight height: CGFloat) -> CGFloat { + let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height) + let boundingBox = boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, context: nil) + + return ceil(boundingBox.width) + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+NSString.swift b/Sources/LCEssentials/Extensions/LCEssentials+NSString.swift new file mode 100644 index 0000000..d7a4312 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+NSString.swift @@ -0,0 +1,45 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +public extension NSString { + + var string: String? { + return String(describing: self) + } + + func randomAlphaNumericString(_ length: Int = 8) -> String { + + let allowedChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + let allowedCharsCount = UInt32(allowedChars.count) + var randomString = "" + + for _ in (0.. "bar" + /// + /// let bar: String? = "bar" + /// print(bar.unwrapped(or: "foo")) -> "bar" + /// + /// - Parameter defaultValue: default value to return if self is nil. + /// - Returns: self if not nil or default value if nil. + func unwrapped(or defaultValue: Wrapped) -> Wrapped { + // http://www.russbishop.net/improving-optionals + return self ?? defaultValue + } + + /// Gets the wrapped value of an optional. If the optional is `nil`, throw a custom error. + /// + /// let foo: String? = nil + /// try print(foo.unwrapped(or: MyError.notFound)) -> error: MyError.notFound + /// + /// let bar: String? = "bar" + /// try print(bar.unwrapped(or: MyError.notFound)) -> "bar" + /// + /// - Parameter error: The error to throw if the optional is `nil`. + /// - Returns: The value wrapped by the optional. + /// - Throws: The error passed in. + func unwrapped(or error: Error) throws -> Wrapped { + guard let wrapped = self else { throw error } + return wrapped + } + + /// Runs a block to Wrapped if not nil + /// + /// let foo: String? = nil + /// foo.run { unwrappedFoo in + /// // block will never run sice foo is nill + /// print(unwrappedFoo) + /// } + /// + /// let bar: String? = "bar" + /// bar.run { unwrappedBar in + /// // block will run sice bar is not nill + /// print(unwrappedBar) -> "bar" + /// } + /// + /// - Parameter block: a block to run if self is not nil. + func run(_ block: (Wrapped) -> Void) { + // http://www.russbishop.net/improving-optionals + _ = map(block) + } + + /// Assign an optional value to a variable only if the value is not nil. + /// + /// let someParameter: String? = nil + /// let parameters = [String: Any]() // Some parameters to be attached to a GET request + /// parameters[someKey] ??= someParameter // It won't be added to the parameters dict + /// + /// - Parameters: + /// - lhs: Any? + /// - rhs: Any? + static func ??= (lhs: inout Optional, rhs: Optional) { + guard let rhs = rhs else { return } + lhs = rhs + } + + /// Assign an optional value to a variable only if the variable is nil. + /// + /// var someText: String? = nil + /// let newText = "Foo" + /// let defaultText = "Bar" + /// someText ?= newText // someText is now "Foo" because it was nil before + /// someText ?= defaultText // someText doesn't change its value because it's not nil + /// + /// - Parameters: + /// - lhs: Any? + /// - rhs: Any? + static func ?= (lhs: inout Optional, rhs: @autoclosure () -> Optional) { + if lhs == nil { + lhs = rhs() + } + } +} + +// MARK: - Methods (Collection) + +public extension Optional where Wrapped: Collection { + /// Check if optional is nil or empty collection. + var isNilOrEmpty: Bool { + return self?.isEmpty ?? true + } + + /// Returns the collection only if it is not nil and not empty. + var nonEmpty: Wrapped? { + return (self?.isEmpty ?? true) ? nil : self + } +} + +// MARK: - Methods (RawRepresentable, RawValue: Equatable) + +public extension Optional where Wrapped: RawRepresentable, Wrapped.RawValue: Equatable { + // swiftlint:disable missing_swifterswift_prefix + + /// Returns a Boolean value indicating whether two values are equal. + /// + /// Equality is the inverse of inequality. For any values `a` and `b`, + /// `a == b` implies that `a != b` is `false`. + /// + /// - Parameters: + /// - lhs: A value to compare. + /// - rhs: Another value to compare. + @inlinable static func == (lhs: Optional, rhs: Wrapped.RawValue?) -> Bool { + return lhs?.rawValue == rhs + } + + /// Returns a Boolean value indicating whether two values are equal. + /// + /// Equality is the inverse of inequality. For any values `a` and `b`, + /// `a == b` implies that `a != b` is `false`. + /// + /// - Parameters: + /// - lhs: A value to compare. + /// - rhs: Another value to compare. + @inlinable static func == (lhs: Wrapped.RawValue?, rhs: Optional) -> Bool { + return lhs == rhs?.rawValue + } + + /// Returns a Boolean value indicating whether two values are not equal. + /// + /// Inequality is the inverse of equality. For any values `a` and `b`, + /// `a != b` implies that `a == b` is `false`. + /// + /// - Parameters: + /// - lhs: A value to compare. + /// - rhs: Another value to compare. + @inlinable static func != (lhs: Optional, rhs: Wrapped.RawValue?) -> Bool { + return lhs?.rawValue != rhs + } + + /// Returns a Boolean value indicating whether two values are not equal. + /// + /// Inequality is the inverse of equality. For any values `a` and `b`, + /// `a != b` implies that `a == b` is `false`. + /// + /// - Parameters: + /// - lhs: A value to compare. + /// - rhs: Another value to compare. + @inlinable static func != (lhs: Wrapped.RawValue?, rhs: Optional) -> Bool { + return lhs != rhs?.rawValue + } + + // swiftlint:enable missing_swifterswift_prefix +} + +// MARK: - Operators + +infix operator ??=: AssignmentPrecedence +infix operator ?=: AssignmentPrecedence diff --git a/Sources/LCEssentials/Extensions/LCEssentials+RangeReplaceableCollection.swift b/Sources/LCEssentials/Extensions/LCEssentials+RangeReplaceableCollection.swift new file mode 100644 index 0000000..95c23e9 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+RangeReplaceableCollection.swift @@ -0,0 +1,220 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +public extension RangeReplaceableCollection { + /// Creates a new collection of a given size where for each position of the collection the value will + /// be the result of a call of the given expression. + /// + /// let values = Array(expression: "Value", count: 3) + /// print(values) + /// // Prints "["Value", "Value", "Value"]" + /// + /// - Parameters: + /// - expression: The expression to execute for each position of the collection. + /// - count: The count of the collection. + init(expression: @autoclosure () throws -> Element, count: Int) rethrows { + self.init() + // swiftlint:disable:next empty_count + if count > 0 { + reserveCapacity(count) + while self.count < count { + try append(expression()) + } + } + } +} + +// MARK: - Methods + +public extension RangeReplaceableCollection { + ///  SwifterSwift: Returns a new rotated collection by the given places. + /// + /// [1, 2, 3, 4].rotated(by: 1) -> [4,1,2,3] + /// [1, 2, 3, 4].rotated(by: 3) -> [2,3,4,1] + /// [1, 2, 3, 4].rotated(by: -1) -> [2,3,4,1] + /// + /// - Parameter places: Number of places that the array be rotated. If the value is positive the end becomes the + /// start, if it negative it's that start become the end. + /// - Returns: The new rotated collection. + func rotated(by places: Int) -> Self { + // Inspired by: https://ruby-doc.org/core-2.2.0/Array.html#method-i-rotate + var copy = self + return copy.rotate(by: places) + } + + ///  SwifterSwift: Rotate the collection by the given places. + /// + /// [1, 2, 3, 4].rotate(by: 1) -> [4,1,2,3] + /// [1, 2, 3, 4].rotate(by: 3) -> [2,3,4,1] + /// [1, 2, 3, 4].rotated(by: -1) -> [2,3,4,1] + /// + /// - Parameter places: The number of places that the array should be rotated. If the value is positive the end + /// becomes the start, if it negative it's that start become the end. + /// - Returns: self after rotating. + @discardableResult + mutating func rotate(by places: Int) -> Self { + guard places != 0 else { return self } + let placesToMove = places % count + if placesToMove > 0 { + let range = index(endIndex, offsetBy: -placesToMove)... + let slice = self[range] + removeSubrange(range) + insert(contentsOf: slice, at: startIndex) + } else { + let range = startIndex.. [1, 2, 3, 4, 2, 5] + /// ["h", "e", "l", "l", "o"].removeFirst { $0 == "e" } -> ["h", "l", "l", "o"] + /// + /// - Parameter predicate: A closure that takes an element as its argument and returns a Boolean value that + /// indicates whether the passed element represents a match. + /// - Returns: The first element for which predicate returns true, after removing it. If no elements in the + /// collection satisfy the given predicate, returns `nil`. + @discardableResult + mutating func removeFirst(where predicate: (Element) throws -> Bool) rethrows -> Element? { + guard let index = try firstIndex(where: predicate) else { return nil } + return remove(at: index) + } + + /// Remove a random value from the collection. + @discardableResult + mutating func removeRandomElement() -> Element? { + guard let randomIndex = indices.randomElement() else { return nil } + return remove(at: randomIndex) + } + + /// Keep elements of Array while condition is true. + /// + /// [0, 2, 4, 7].keep(while: { $0 % 2 == 0 }) -> [0, 2, 4] + /// + /// - Parameter condition: condition to evaluate each element against. + /// - Returns: self after applying provided condition. + /// - Throws: provided condition exception. + @discardableResult + mutating func keep(while condition: (Element) throws -> Bool) rethrows -> Self { + if let idx = try firstIndex(where: { try !condition($0) }) { + removeSubrange(idx...) + } + return self + } + + /// Take element of Array while condition is true. + /// + /// [0, 2, 4, 7, 6, 8].take( where: {$0 % 2 == 0}) -> [0, 2, 4] + /// + /// - Parameter condition: condition to evaluate each element against. + /// - Returns: All elements up until condition evaluates to false. + func take(while condition: (Element) throws -> Bool) rethrows -> Self { + return try Self(prefix(while: condition)) + } + + /// Skip elements of Array while condition is true. + /// + /// [0, 2, 4, 7, 6, 8].skip( where: {$0 % 2 == 0}) -> [6, 8] + /// + /// - Parameter condition: condition to evaluate each element against. + /// - Returns: All elements after the condition evaluates to false. + func skip(while condition: (Element) throws -> Bool) rethrows -> Self { + guard let idx = try firstIndex(where: { try !condition($0) }) else { return Self() } + return Self(self[idx...]) + } + + /// Remove all duplicate elements using KeyPath to compare. + /// + /// - Parameter path: Key path to compare, the value must be Equatable. + mutating func removeDuplicates(keyPath path: KeyPath) { + var items = [Element]() + removeAll { element -> Bool in + guard items.contains(where: { $0[keyPath: path] == element[keyPath: path] }) else { + items.append(element) + return false + } + return true + } + } + + /// Remove all duplicate elements using KeyPath to compare. + /// + /// - Parameter path: Key path to compare, the value must be Hashable. + mutating func removeDuplicates(keyPath path: KeyPath) { + var set = Set() + removeAll { !set.insert($0[keyPath: path]).inserted } + } + + /// Accesses the element at the specified position. + /// + /// - Parameter offset: The offset position of the element to access. `offset` must be a valid index offset of the + /// collection that is not equal to the `endIndex` property. + subscript(offset: Int) -> Element { + get { + return self[index(startIndex, offsetBy: offset)] + } + set { + let offsetIndex = index(startIndex, offsetBy: offset) + replaceSubrange(offsetIndex..(range: R) -> SubSequence where R: RangeExpression, R.Bound == Int { + get { + let indexRange = range.relative(to: 0..(contentsOf newElements: S?) where Element == S.Element, S: Sequence { + guard let newElements = newElements else { return } + append(contentsOf: newElements) + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+Sequence.swift b/Sources/LCEssentials/Extensions/LCEssentials+Sequence.swift new file mode 100644 index 0000000..f7e4e11 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+Sequence.swift @@ -0,0 +1,330 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +public extension Sequence { + /// Check if all elements in collection match a condition. + /// + /// [2, 2, 4].all(matching: {$0 % 2 == 0}) -> true + /// [1,2, 2, 4].all(matching: {$0 % 2 == 0}) -> false + /// + /// - Parameter condition: condition to evaluate each element against. + /// - Returns: true when all elements in the array match the specified condition. + func all(matching condition: (Element) throws -> Bool) rethrows -> Bool { + return try !contains { try !condition($0) } + } + + /// Check if no elements in collection match a condition. + /// + /// [2, 2, 4].none(matching: {$0 % 2 == 0}) -> false + /// [1, 3, 5, 7].none(matching: {$0 % 2 == 0}) -> true + /// + /// - Parameter condition: condition to evaluate each element against. + /// - Returns: true when no elements in the array match the specified condition. + func none(matching condition: (Element) throws -> Bool) rethrows -> Bool { + return try !contains { try condition($0) } + } + + /// Check if any element in collection match a condition. + /// + /// [2, 2, 4].any(matching: {$0 % 2 == 0}) -> false + /// [1, 3, 5, 7].any(matching: {$0 % 2 == 0}) -> true + /// + /// - Parameter condition: condition to evaluate each element against. + /// - Returns: true when no elements in the array match the specified condition. + func any(matching condition: (Element) throws -> Bool) rethrows -> Bool { + return try contains { try condition($0) } + } + + /// Filter elements based on a rejection condition. + /// + /// [2, 2, 4, 7].reject(where: {$0 % 2 == 0}) -> [7] + /// + /// - Parameter condition: to evaluate the exclusion of an element from the array. + /// - Returns: the array with rejected values filtered from it. + func reject(where condition: (Element) throws -> Bool) rethrows -> [Element] { + return try filter { return try !condition($0) } + } + + /// Get element count based on condition. + /// + /// [2, 2, 4, 7].count(where: {$0 % 2 == 0}) -> 3 + /// + /// - Parameter condition: condition to evaluate each element against. + /// - Returns: number of times the condition evaluated to true. + func count(where condition: (Element) throws -> Bool) rethrows -> Int { + var count = 0 + for element in self where try condition(element) { + count += 1 + } + return count + } + + /// Iterate over a collection in reverse order. (right to left) + /// + /// [0, 2, 4, 7].forEachReversed({ print($0)}) -> // Order of print: 7,4,2,0 + /// + /// - Parameter body: a closure that takes an element of the array as a parameter. + func forEachReversed(_ body: (Element) throws -> Void) rethrows { + try reversed().forEach(body) + } + + /// Calls the given closure with each element where condition is true. + /// + /// [0, 2, 4, 7].forEach(where: {$0 % 2 == 0}, body: { print($0)}) -> // print: 0, 2, 4 + /// + /// - Parameters: + /// - condition: condition to evaluate each element against. + /// - body: a closure that takes an element of the array as a parameter. + func forEach(where condition: (Element) throws -> Bool, body: (Element) throws -> Void) rethrows { + try lazy.filter(condition).forEach(body) + } + + /// Reduces an array while returning each interim combination. + /// + /// [1, 2, 3].accumulate(initial: 0, next: +) -> [1, 3, 6] + /// + /// - Parameters: + /// - initial: initial value. + /// - next: closure that combines the accumulating value and next element of the array. + /// - Returns: an array of the final accumulated value and each interim combination. + func accumulate(initial: U, next: (U, Element) throws -> U) rethrows -> [U] { + var runningTotal = initial + return try map { element in + runningTotal = try next(runningTotal, element) + return runningTotal + } + } + + /// Filtered and map in a single operation. + /// + /// [1,2,3,4,5].filtered({ $0 % 2 == 0 }, map: { $0.string }) -> ["2", "4"] + /// + /// - Parameters: + /// - isIncluded: condition of inclusion to evaluate each element against. + /// - transform: transform element function to evaluate every element. + /// - Returns: Return an filtered and mapped array. + func filtered(_ isIncluded: (Element) throws -> Bool, map transform: (Element) throws -> T) rethrows -> [T] { + return try lazy.filter(isIncluded).map(transform) + } + + /// Get the only element based on a condition. + /// + /// [].single(where: {_ in true}) -> nil + /// [4].single(where: {_ in true}) -> 4 + /// [1, 4, 7].single(where: {$0 % 2 == 0}) -> 4 + /// [2, 2, 4, 7].single(where: {$0 % 2 == 0}) -> nil + /// + /// - Parameter condition: condition to evaluate each element against. + /// - Returns: The only element in the array matching the specified condition. If there are more matching elements, + /// nil is returned. (optional) + func single(where condition: (Element) throws -> Bool) rethrows -> Element? { + var singleElement: Element? + for element in self where try condition(element) { + guard singleElement == nil else { + singleElement = nil + break + } + singleElement = element + } + return singleElement + } + + /// Remove duplicate elements based on condition. + /// + /// [1, 2, 1, 3, 2].withoutDuplicates { $0 } -> [1, 2, 3] + /// [(1, 4), (2, 2), (1, 3), (3, 2), (2, 1)].withoutDuplicates { $0.0 } -> [(1, 4), (2, 2), (3, 2)] + /// + /// - Parameter transform: A closure that should return the value to be evaluated for repeating elements. + /// - Returns: Sequence without repeating elements + /// - Complexity: O(*n*), where *n* is the length of the sequence. + func withoutDuplicates(transform: (Element) throws -> T) rethrows -> [Element] { + var set = Set() + return try filter { try set.insert(transform($0)).inserted } + } + + ///  SwifterSwift: Separates all items into 2 lists based on a given predicate. The first list contains all items + /// for which the specified condition evaluates to true. The second list contains those that don't. + /// + /// let (even, odd) = [0, 1, 2, 3, 4, 5].divided { $0 % 2 == 0 } + /// let (minors, adults) = people.divided { $0.age < 18 } + /// + /// - Parameter condition: condition to evaluate each element against. + /// - Returns: A tuple of matched and non-matched items + func divided(by condition: (Element) throws -> Bool) rethrows -> (matching: [Element], nonMatching: [Element]) { + // Inspired by: http://ruby-doc.org/core-2.5.0/Enumerable.html#method-i-partition + var matching = [Element]() + var nonMatching = [Element]() + + for element in self { + // swiftlint:disable:next void_function_in_ternary + try condition(element) ? matching.append(element) : nonMatching.append(element) + } + return (matching, nonMatching) + } + + /// Return a sorted array based on a key path and a compare function. + /// + /// - Parameter keyPath: Key path to sort by. + /// - Parameter compare: Comparison function that will determine the ordering. + /// - Returns: The sorted array. + func sorted(by keyPath: KeyPath, with compare: (T, T) -> Bool) -> [Element] { + return sorted { compare($0[keyPath: keyPath], $1[keyPath: keyPath]) } + } + + /// Return a sorted array based on a key path. + /// + /// - Parameter keyPath: Key path to sort by. The key path type must be Comparable. + /// - Returns: The sorted array. + func sorted(by keyPath: KeyPath) -> [Element] { + return sorted { $0[keyPath: keyPath] < $1[keyPath: keyPath] } + } + + /// Returns a sorted sequence based on two key paths. The second one will be used in case the values + /// of the first one match. + /// + /// - Parameters: + /// - keyPath1: Key path to sort by. Must be Comparable. + /// - keyPath2: Key path to sort by in case the values of `keyPath1` match. Must be Comparable. + func sorted(by keyPath1: KeyPath, + and keyPath2: KeyPath) -> [Element] { + return sorted { + if $0[keyPath: keyPath1] != $1[keyPath: keyPath1] { + return $0[keyPath: keyPath1] < $1[keyPath: keyPath1] + } + return $0[keyPath: keyPath2] < $1[keyPath: keyPath2] + } + } + + /// Returns a sorted sequence based on three key paths. Whenever the values of one key path match, the + /// next one will be used. + /// + /// - Parameters: + /// - keyPath1: Key path to sort by. Must be Comparable. + /// - keyPath2: Key path to sort by in case the values of `keyPath1` match. Must be Comparable. + /// - keyPath3: Key path to sort by in case the values of `keyPath1` and `keyPath2` match. Must be Comparable. + func sorted(by keyPath1: KeyPath, + and keyPath2: KeyPath, + and keyPath3: KeyPath) -> [Element] { + return sorted { + if $0[keyPath: keyPath1] != $1[keyPath: keyPath1] { + return $0[keyPath: keyPath1] < $1[keyPath: keyPath1] + } + if $0[keyPath: keyPath2] != $1[keyPath: keyPath2] { + return $0[keyPath: keyPath2] < $1[keyPath: keyPath2] + } + return $0[keyPath: keyPath3] < $1[keyPath: keyPath3] + } + } + + /// Sum of a `AdditiveArithmetic` property of each `Element` in a `Sequence`. + /// + /// ["James", "Wade", "Bryant"].sum(for: \.count) -> 15 + /// + /// - Parameter keyPath: Key path of the `AdditiveArithmetic` property. + /// - Returns: The sum of the `AdditiveArithmetic` properties at `keyPath`. + func sum(for keyPath: KeyPath) -> T { + // Inspired by: https://swiftbysundell.com/articles/reducers-in-swift/ + return reduce(.zero) { $0 + $1[keyPath: keyPath] } + } + + /// Returns the first element of the sequence with having property by given key path equals to given + /// `value`. + /// + /// - Parameters: + /// - keyPath: The `KeyPath` of property for `Element` to compare. + /// - value: The value to compare with `Element` property. + /// - Returns: The first element of the collection that has property by given key path equals to given `value` or + /// `nil` if there is no such element. + func first(where keyPath: KeyPath, equals value: T) -> Element? { + return first { $0[keyPath: keyPath] == value } + } +} + +public extension Sequence where Element: Equatable { + /// Check if array contains an array of elements. + /// + /// [1, 2, 3, 4, 5].contains([1, 2]) -> true + /// [1.2, 2.3, 4.5, 3.4, 4.5].contains([2, 6]) -> false + /// ["h", "e", "l", "l", "o"].contains(["l", "o"]) -> true + /// + /// - Parameter elements: array of elements to check. + /// - Returns: true if array contains all given items. + /// - Complexity: _O(m·n)_, where _m_ is the length of `elements` and _n_ is the length of this sequence. + func contains(_ elements: [Element]) -> Bool { + return elements.allSatisfy { contains($0) } + } +} + +public extension Sequence where Element: Hashable { + /// Check if array contains an array of elements. + /// + /// [1, 2, 3, 4, 5].contains([1, 2]) -> true + /// [1.2, 2.3, 4.5, 3.4, 4.5].contains([2, 6]) -> false + /// ["h", "e", "l", "l", "o"].contains(["l", "o"]) -> true + /// + /// - Parameter elements: array of elements to check. + /// - Returns: true if array contains all given items. + /// - Complexity: _O(m + n)_, where _m_ is the length of `elements` and _n_ is the length of this sequence. + func contains(_ elements: [Element]) -> Bool { + let set = Set(self) + return elements.allSatisfy { set.contains($0) } + } + + /// Check whether a sequence contains duplicates. + /// + /// - Returns: true if the receiver contains duplicates. + func containsDuplicates() -> Bool { + var set = Set() + return contains { !set.insert($0).inserted } + } + + /// Getting the duplicated elements in a sequence. + /// + /// [1, 1, 2, 2, 3, 3, 3, 4, 5].duplicates().sorted() -> [1, 2, 3]) + /// ["h", "e", "l", "l", "o"].duplicates().sorted() -> ["l"]) + /// + /// - Returns: An array of duplicated elements. + /// + func duplicates() -> [Element] { + var set = Set() + var duplicates = Set() + forEach { + if !set.insert($0).inserted { + duplicates.insert($0) + } + } + return Array(duplicates) + } +} + +// MARK: - Methods (AdditiveArithmetic) + +public extension Sequence where Element: AdditiveArithmetic { + /// Sum of all elements in array. + /// + /// [1, 2, 3, 4, 5].sum() -> 15 + /// + /// - Returns: sum of the array's elements. + func sum() -> Element { + return reduce(.zero, +) + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+SignedNumeric.swift b/Sources/LCEssentials/Extensions/LCEssentials+SignedNumeric.swift new file mode 100644 index 0000000..5f3410f --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+SignedNumeric.swift @@ -0,0 +1,75 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +#if canImport(Foundation) +import Foundation +#endif + +// MARK: - Properties + +public extension SignedNumeric { + /// String. + var string: String { + return String(describing: self) + } + + #if canImport(Foundation) + /// String with number and current locale currency. + var asLocaleCurrency: String? { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = Locale.current + // swiftlint:disable:next force_cast + return formatter.string(from: self as! NSNumber) + } + + func asCurrency(locale: Locale = Locale.init(identifier: "pt_BR")) -> String? { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = locale + // swiftlint:disable:next force_cast + return formatter.string(from: self as! NSNumber) + } + #endif +} + +// MARK: - Methods + +public extension SignedNumeric { + #if canImport(Foundation) + /// Spelled out representation of a number. + /// + /// print((12.32).spelledOutString()) // prints "twelve point three two" + /// + /// - Parameter locale: Locale, default is .current. + /// - Returns: String representation of number spelled in specified locale language. E.g. input 92, output in "en": + /// "ninety-two". + func spelledOutString(locale: Locale = .current) -> String? { + let formatter = NumberFormatter() + formatter.locale = locale + formatter.numberStyle = .spellOut + + guard let number = self as? NSNumber else { return nil } + return formatter.string(from: number) + } + #endif +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+String.swift b/Sources/LCEssentials/Extensions/LCEssentials+String.swift new file mode 100644 index 0000000..ead8b3a --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+String.swift @@ -0,0 +1,977 @@ +// +// Copyright (c) 2018 SwifterSwift. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit +#if canImport(CommonCrypto) +import CommonCrypto +#endif + +public extension String { + + // MARK: - Variables + + private var convertHtmlToNSAttributedString: NSAttributedString? { + guard let data = data(using: .utf8) else { + return nil + } + + do { + return try NSAttributedString(data: data, + options: [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ], documentAttributes: nil) + } catch { + print(error.localizedDescription) + return nil + } + } + + var convertToHTML: NSAttributedString? { + return convertHtmlToAttributedStringWithCSS(font: nil, + csscolor: "", + lineheight: 0, + csstextalign: "") + } + + /// Check if string is a valid URL. + /// + /// "https://google.com".isValidUrl -> true + /// + var isValidUrl: Bool { + return URL(string: self) != nil + } + + #if canImport(Foundation) + /// Check if string is a valid https URL. + /// + /// "https://google.com".isValidHttpsUrl -> true + /// + var isValidHttpsUrl: Bool { + guard let url = URL(string: self) else { return false } + return url.scheme == "https" + } + #endif + + #if canImport(Foundation) + /// Check if string is a valid http URL. + /// + /// "http://google.com".isValidHttpUrl -> true + /// + var isValidHttpUrl: Bool { + guard let url = URL(string: self) else { return false } + return url.scheme == "http" + } + #endif + + /// Readable string from a URL string. + /// + /// "it's%20easy%20to%20decode%20strings".urlDecoded -> "it's easy to decode strings" + /// + var urlDecoded: String { + return removingPercentEncoding ?? self + } + + /// SwifterSwift.: URL escaped string. + /// + /// "it's easy to encode strings".urlEncoded -> "it's%20easy%20to%20encode%20strings" + /// + var urlEncoded: String { + return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)! + } + /// String without spaces and new lines. + /// + /// " \n Swifter \n Swift ".withoutSpacesAndNewLines -> "SwifterSwift" + /// + var withoutSpacesAndNewLines: String { + return replacingOccurrences(of: " ", with: "").replacingOccurrences(of: "\n", with: "") + } + + var isEmail: Bool { + let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,20}" + let emailTest = NSPredicate(format: "SELF MATCHES %@", emailRegEx) + return emailTest.evaluate(with: self) + } + + var isCPF: Bool { + let cpf = self.onlyNumbers + guard cpf.count == 11 else { return false } + + let i1 = cpf.index(cpf.startIndex, offsetBy: 9) + let i2 = cpf.index(cpf.startIndex, offsetBy: 10) + let i3 = cpf.index(cpf.startIndex, offsetBy: 11) + let d1 = Int(cpf[i1..]+>", with: "", options: .regularExpression, range: nil) + } + + var removeEmoji: String { + return self.components(separatedBy: CharacterSet.symbols).joined() + } + + var alphanumeric: String { + return self.components(separatedBy: CharacterSet.alphanumerics.inverted).joined() + } + + /// Check if string contains only letters. + /// + /// "abc".isAlphabetic -> true + /// "123abc".isAlphabetic -> false + /// + var isAlphabetic: Bool { + let hasLetters = rangeOfCharacter(from: .letters, options: .numeric, range: nil) != nil + let hasNumbers = rangeOfCharacter(from: .decimalDigits, options: .literal, range: nil) != nil + return hasLetters && !hasNumbers + } + + /// Check if string contains at least one letter and one number. + /// + /// // useful for passwords + /// "123abc".isAlphaNumeric -> true + /// "abc".isAlphaNumeric -> false + /// + var isAlphaNumeric: Bool { + let hasLetters = rangeOfCharacter(from: .letters, options: .numeric, range: nil) != nil + let hasNumbers = rangeOfCharacter(from: .decimalDigits, options: .literal, range: nil) != nil + let comps = components(separatedBy: .alphanumerics) + return comps.joined(separator: "").count == 0 && hasLetters && hasNumbers + } + + var alphanumericWithWhiteSpace: String { + return self.components(separatedBy: CharacterSet.alphanumerics.union(.whitespaces).inverted).joined() + } + + var lettersWithWhiteSpace: String { + return self.components(separatedBy: CharacterSet.letters.union(.whitespaces).inverted).joined() + } + + var letters: String { + return self.components(separatedBy: CharacterSet.letters.inverted).joined() + } + + var numbers: String { + return components(separatedBy: CharacterSet.decimalDigits.inverted).joined() + } + + var data: Data { + return Data(self.utf8) + } + + /// Retorna a URL encontrada na string, caso exista. Se não, retorna nulo + /// + /// let string = "Meu www.algumsite.com.br tem tudo que voce precisa." + /// print(string.url) + /// //Optional(www.algumsite.com.br) + var url: String? { + do { + let detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + let matches = detector.matches(in: self, options: [], range: NSRange(location: 0, length: self.utf16.count)) + for match in matches { + return (self as NSString).substring(with: match.range) + } + } catch { + return nil + } + return nil + } + + /// Retorna true caso contenha tag HTML na String + /// + /// let string = "Meu site tem tudo que voce precisa." + /// print(string.isHTML) + /// //true + var isHTML: Bool { + return self.range(of: "<[^>]+>", options: .regularExpression, range: nil, locale: nil) != nil + } + + var onlyNumbers: String { + guard !isEmpty else { return "" } + return replacingOccurrences(of: "\\D", + with: "", + options: .regularExpression, + range: startIndex..) -> Int { + var number = 1 + let digit = 11 - slice.reversed().reduce(into: 0) { + number += 1 + $0 += $1 * number + if number == 9 { number = 1 } + } % 11 + return digit % 10 + } + let dv1 = digitCalculator(numbers.prefix(12)) + let dv2 = digitCalculator(numbers.prefix(13)) + return dv1 == numbers[12] && dv2 == numbers[13] + } + + var currentTimeZone : String { + let date = NSDate(); + let formatter = DateFormatter(); + formatter.dateFormat = "ZZZ"; + let defaultTimeZoneStr = formatter.string(from: date as Date); + + return defaultTimeZoneStr; + } + + var first: String { + return String(prefix(1)) + } + var last: String { + return String(suffix(1)) + } + var uppercaseFirst: String { + return first.uppercased() + String(dropFirst()) + } + + var toURL: NSURL? { + return NSURL(string: self) + } + + /// Integer value from string (if applicable). + /// + /// "101".int -> 101 + /// + var int: Int? { + return Int(self) + } + + var float: Float? { + return Float(self) + } + + var double: Double? { + return Double(self) + } + + var btcToSats: Int { + let decamal = Decimal(string: self) ?? Decimal() + var scaledResult = decamal * Decimal(bitcoinIntConvertion) + return NSDecimalNumber(decimal: scaledResult).intValue + } + + var bitcoinToSatoshis: Int { + btcToSats + } + + /// NSString from a string. + var nsString: NSString { + return NSString(string: self) + } + + /// The full `NSRange` of the string. + var fullNSRange: NSRange { NSRange(startIndex.. String { + var strOutput = self + for (key, Value) in withDict { + strOutput = strOutput.replacingOccurrences(of: "{\(key)}", with: "\(Value)") + } + return strOutput + } + + /// Lorem ipsum string of given length. + /// + /// - Parameter length: number of characters to limit lorem ipsum to (default is 445 - full lorem ipsum). + /// - Returns: Lorem ipsum dolor sit amet... string. + static func loremIpsum(ofLength length: Int = 445) -> String { + guard length > 0 else { return "" } + + // https://www.lipsum.com/ + let loremIpsum = """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + """ + if loremIpsum.count > length { + return String(loremIpsum[loremIpsum.startIndex.. ["Swift", "is", "amazing"] + /// + /// - Returns: The words contained in a string. + func words() -> [String] { + // https://stackoverflow.com/questions/42822838 + let characterSet = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters) + let comps = components(separatedBy: characterSet) + return comps.filter { !$0.isEmpty } + } + /// Count of words in a string. + /// + /// "Swift is amazing".wordsCount() -> 3 + /// + /// - Returns: The count of words contained in a string. + func wordCount() -> Int { + // https://stackoverflow.com/questions/42822838 + let characterSet = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters) + let comps = components(separatedBy: characterSet) + let words = comps.filter { !$0.isEmpty } + return words.count + } + /// Check if string contains one or more instance of substring. + /// + /// "Hello World!".contain("O") -> false + /// "Hello World!".contain("o", caseSensitive: false) -> true + /// + /// - Parameters: + /// - string: substring to search for. + /// - caseSensitive: set true for case sensitive search (default is true). + /// - Returns: true if string contains one or more instance of substring. + func contains(_ string: String, caseSensitive: Bool = true) -> Bool { + if !caseSensitive { + return range(of: string, options: .caseInsensitive) != nil + } + return range(of: string) != nil + } +#endif + + /// Reverse string. + @discardableResult + mutating func reverse() -> String { + let chars: [Character] = reversed() + self = String(chars) + return self + } + + /// Returns a string by padding to fit the length parameter size with another string in the start. + /// + /// "hue".paddingStart(10) -> " hue" + /// "hue".paddingStart(10, with: "br") -> "brbrbrbhue" + /// + /// - Parameter length: The target length to pad. + /// - Parameter string: Pad string. Default is " ". + /// - Returns: The string with the padding on the start. + func paddingStart(_ length: Int, with string: String = " ") -> String { + guard count < length else { return self } + + let padLength = length - count + if padLength < string.count { + return string[string.startIndex.. "hue " + /// "hue".paddingEnd(10, with: "br") -> "huebrbrbrb" + /// + /// - Parameter length: The target length to pad. + /// - Parameter string: Pad string. Default is " ". + /// - Returns: The string with the padding on the end. + func paddingEnd(_ length: Int, with string: String = " ") -> String { + guard count < length else { return self } + + let padLength = length - count + if padLength < string.count { + return self + string[string.startIndex..) -> NSRange? { + let utf16view = self.utf16 + if let from = range.lowerBound.samePosition(in: utf16view), let to = range.upperBound.samePosition(in: utf16view) { + return NSMakeRange(utf16view.distance(from: utf16view.startIndex, to: from), utf16view.distance(from: from, to: to)) + } + return nil + } + + mutating func insertAtIndexEnd(string: String, ind: Int) { + self.insert(contentsOf: string, at: self.index(self.endIndex, offsetBy: ind) ) + } + + mutating func insertAtIndexStart(string: String, ind: Int) { + self.insert(contentsOf: string, at: self.index(self.endIndex, offsetBy: ind) ) + } + /// Convert URL string to readable string. + /// + /// var str = "it's%20easy%20to%20decode%20strings" + /// str.urlDecode() + /// print(str) // prints "it's easy to decode strings" + /// + @discardableResult + mutating func urlDecode() -> String { + if let decoded = removingPercentEncoding { + self = decoded + } + return self + } + /// Escape string. + /// + /// var str = "it's easy to encode strings" + /// str.urlEncode() + /// print(str) // prints "it's%20easy%20to%20encode%20strings" + /// + @discardableResult + mutating func urlEncode() -> String { + if let encoded = addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) { + self = encoded + } + return self + } + + /// Verifica se o texto equivale a verdadeiro ou falso. + func validateBolean(comparingBoolean:Bool = true) ->Bool{ + + if(comparingBoolean) + { + if (self.uppercased() == "TRUE") {return true} + if (self.uppercased() == "YES") {return true} + if (self.uppercased() == "ON") {return true} + if (self.uppercased() == "ONLINE") {return true} + if (self.uppercased() == "ENABLE") {return true} + if (self.uppercased() == "ACTIVATED") {return true} + if (self.uppercased() == "ONE") {return true} + // + if (self.uppercased() == "VERDADEIRO") {return true} + if (self.uppercased() == "SIM") {return true} + if (self.uppercased() == "LIGADO") {return true} + if (self.uppercased() == "ATIVO") {return true} + if (self.uppercased() == "ATIVADO") {return true} + if (self.uppercased() == "HABILITADO") {return true} + if (self.uppercased() == "UM") {return true} + // + if (self.uppercased() == "1") {return true} + if (self.uppercased() == "T") {return true} + if (self.uppercased() == "Y") {return true} + if (self.uppercased() == "S") {return true} + } + else + { + if (self.uppercased() == "FALSE") {return true} + if (self.uppercased() == "NO") {return true} + if (self.uppercased() == "OFF") {return true} + if (self.uppercased() == "OFFLINE") {return true} + if (self.uppercased() == "DISABLED") {return true} + if (self.uppercased() == "DEACTIVATED") {return true} + if (self.uppercased() == "ZERO") {return true} + // + if (self.uppercased() == "FALSO") {return true} + if (self.uppercased() == "NÃO") {return true} + if (self.uppercased() == "NAO") {return true} + if (self.uppercased() == "DESLIGADO") {return true} + if (self.uppercased() == "DESATIVADO") {return true} + if (self.uppercased() == "DESABILITADO") {return true} + // + if (self.uppercased() == "0") {return true} + if (self.uppercased() == "F") {return true} + if (self.uppercased() == "N") {return true} + } + + return false; + } + + + func randomString(length: Int) -> String { + + let letters : NSString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + let len = UInt32(letters.length) + + var randomString = "" + + for _ in 0 ..< length { + let rand = arc4random_uniform(len) + var nextChar = letters.character(at: Int(rand)) + randomString += NSString(characters: &nextChar, length: 1) as String + } + + return randomString + } + + func stringFromTimeInterval(_ interval:TimeInterval) -> NSString { + + let ti = NSInteger(interval) + + let ms = Int((interval.truncatingRemainder(dividingBy: 1)) * 1000) + + let seconds = ti % 60 + let minutes = (ti / 60) % 60 + let hours = (ti / 3600) + + return NSString(format: "%0.2d:%0.2d:%0.2d.%0.3d",hours,minutes,seconds,ms) + } + + /// LoverdeCo: String to Date object. + /// + /// - Parameters: + /// - withCurrFormatt: Give a input formatt as it comes in String. + /// - Returns: Date object. + func date(withCurrFormatt: String = "yyyy-MM-dd HH:mm:ss", + localeIdentifier: String = "pt-BR", + timeZone: TimeZone? = TimeZone.current) -> Date? { + + let updatedString:String = self.replacingOccurrences(of: " 0000", with: " +0000") + let dateFormatter:DateFormatter = DateFormatter.init() + let calendar:Calendar = Calendar.init(identifier: Calendar.Identifier.gregorian) + let enUSPOSIXLocale:Locale = Locale.init(identifier: localeIdentifier) + // + dateFormatter.timeZone = timeZone + // + dateFormatter.calendar = calendar + dateFormatter.locale = enUSPOSIXLocale + dateFormatter.dateFormat = withCurrFormatt + // + return dateFormatter.date(from: updatedString) + } + + /// LoverdeCo: String to Date object. + /// + /// - Parameters: + /// - withCurrFormatt: Give a input formatt as it comes in String. + /// - newFormatt: Give a new formatt you want in String + /// - Returns: + /// Date object. + func date(withCurrFormatt: String = "yyyy-MM-dd HH:mm:ss", + newFormatt: String = "yyyy-MM-dd HH:mm:ss", + localeIdentifier: String = "pt-BR", + timeZone: TimeZone? = TimeZone.current) -> Date? { + + let date = self.date(withCurrFormatt: withCurrFormatt, localeIdentifier: localeIdentifier, timeZone: timeZone) + let strDate = date?.string(withFormat: newFormatt) + return strDate?.date(withCurrFormatt: newFormatt, localeIdentifier: localeIdentifier, timeZone: timeZone) + } + + #if os(iOS) || os(macOS) + func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat { + let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude) + let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil) + + return ceil(boundingBox.height) + } + + func width(withConstraintedHeight height: CGFloat, font: UIFont) -> CGFloat { + let constraintRect = CGSize(width: .greatestFiniteMagnitude, height: height) + let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil) + + return ceil(boundingBox.width) + } + #endif + + func exponentize(str: String) -> String { + + let supers = [ + "1": "\u{00B9}", + "2": "\u{00B2}", + "3": "\u{00B3}", + "4": "\u{2074}", + "5": "\u{2075}", + "6": "\u{2076}", + "7": "\u{2077}", + "8": "\u{2078}", + "9": "\u{2079}"] + + var newStr = "" + var isExp = false + for (_, char) in str.enumerated() { + if char == "^" { + isExp = true + } else { + if isExp { + let key = String(char) + if supers.keys.contains(key) { + newStr.append(Character(supers[key]!)) + } else { + isExp = false + newStr.append(char) + } + } else { + newStr.append(char) + } + } + } + return newStr + } + + /// Truncate string (cut it to a given number of characters). + /// + /// var str = "This is a very long sentence" + /// str.truncate(toLength: 14) + /// print(str) // prints "This is a very..." + /// + /// - Parameters: + /// - toLength: maximum number of characters before cutting. + /// - trailing: string to add at the end of truncated string (default is "..."). + @discardableResult + mutating func truncate(toLength length: Int, trailing: String? = "...") -> String { + guard length > 0 else { return self } + if count > length { + self = self[startIndex.. "This is a very..." + /// "Short sentence".truncated(toLength: 14) -> "Short sentence" + /// + /// - Parameters: + /// - toLength: maximum number of characters before cutting. + /// - trailing: string to add at the end of truncated string. + /// - Returns: truncated string (this is an extr...). + func truncated(toLength length: Int, trailing: String? = "...") -> String { + guard 1.., with replacementString: String) -> String { + let start = index(startIndex, offsetBy: range.lowerBound) + let end = index(start, offsetBy: range.count) + return self.replacingCharacters(in: start ..< end, with: replacementString) + } + + func findAndReplace(from this: Target, to that: Replacement) -> String + where Target : StringProtocol, Replacement : StringProtocol { + return self.replacingOccurrences(of: this, with: that) + } + + func replace(from: String, to: String) -> String { + return self.findAndReplace(from: from, to: to) + } + + func replacingLastOccurrenceOfString(_ searchString: String, with replacementString: String, caseInsensitive: Bool = true) -> String { + let options: String.CompareOptions + if caseInsensitive { + options = [.backwards, .caseInsensitive] + } else { + options = [.backwards] + } + + if let range = self.range(of: searchString, options: options, range: nil, locale: nil) { + return self.replacingCharacters(in: range, with: replacementString) + } + return self + } + + /// Converte String para HTML com CSS. + /// + /// - Parameters: + /// - font: Caso necessite uma fonte diferente ou que seja considerado o CSS nessa conversão. + /// - csscolor: Cor da font em formato ASCII. + /// - lineheight: Quantas linhas o texto pode conter. Digite 0 (zero) caso queira dinâmico. + /// - csstextalign: Alinhamento do texto em formato CSS. + /// - customCSS: Adiciona CSS customizado (Opcional). + /// - Returns: + /// Retorna um NSAttributedString convertido. + func convertHtmlToAttributedStringWithCSS(font: UIFont?, + csscolor: String, + lineheight: Int, + csstextalign: String, + customCSS: String? = nil) -> NSAttributedString? { + guard let font = font else { return convertHtmlToNSAttributedString } + + let modifiedString = """ + \(self) + """; + + guard let data = modifiedString.data(using: .utf8) else { + return nil + } + + do { + return try NSAttributedString(data: data, + options: [ + .documentType: NSAttributedString.DocumentType.html, + .characterEncoding: String.Encoding.utf8.rawValue + ], documentAttributes: nil) + } catch { + print(error.localizedDescription) + return nil + } + } + + /// Float value from string (if applicable). + /// + /// - Parameter locale: Locale (default is Locale.current) + /// - Returns: Optional Float value from given string. + func float(locale: Locale = .current) -> Float? { + let formatter = NumberFormatter() + formatter.locale = locale + formatter.allowsFloats = true + return formatter.number(from: self)?.floatValue + } + /// Double value from string (if applicable). + /// + /// - Parameter locale: Locale (default is Locale.current) + /// - Returns: Optional Double value from given string. + func double(locale: Locale = .current) -> Double? { + let formatter = NumberFormatter() + formatter.locale = locale + formatter.allowsFloats = true + return formatter.number(from: self)?.doubleValue + } + /// Returns a localized string, with an optional comment for translators. + /// + /// "Hello world".localized -> Hallo Welt + /// + func localized(comment: String = "") -> String { + return NSLocalizedString(self, comment: comment) + } + + /// First character of string (if applicable). + /// + /// "Hello".firstCharacterAsString -> Optional("H") + /// "".firstCharacterAsString -> nil + /// + var firstCharacterAsString: String? { + guard let first = first else { return nil } + return String(first) + } + + /// Last character of string (if applicable). + /// + /// "Hello".lastCharacterAsString -> Optional("o") + /// "".lastCharacterAsString -> nil + /// + var lastCharacterAsString: String? { + guard let last = last else { return nil } + return String(last) + } + /// Transforms the string into a slug string. + /// + /// "Swift is amazing".toSlug() -> "swift-is-amazing" + /// + /// - Returns: The string in slug format. + func toSlug() -> String { + let lowercased = self.lowercased() + let latinized = lowercased.folding(options: .diacriticInsensitive, locale: Locale.current) + let withDashes = latinized.replacingOccurrences(of: " ", with: "-") + + let alphanumerics = NSCharacterSet.alphanumerics + var filtered = withDashes.filter { + guard String($0) != "-" else { return true } + guard String($0) != "&" else { return true } + return String($0).rangeOfCharacter(from: alphanumerics) != nil + } + + while filtered.lastCharacterAsString == "-" { + filtered = String(filtered.dropLast()) + } + + while filtered.firstCharacterAsString == "-" { + filtered = String(filtered.dropFirst()) + } + + return filtered.replacingOccurrences(of: "--", with: "-") + } + /// Removes spaces and new lines in beginning and end of string. + /// + /// var str = " \n Hello World \n\n\n" + /// str.trim() + /// print(str) // prints "Hello World" + /// + @discardableResult + mutating func trim() -> String { + self = trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + return self + } + + /// Loverde Co.: Add mask to a text - Very simple to use + /// + /// - Parameter toText: String you want to maks + /// - Parameter mask: Using mask like sharp ##-#####(###) + func applyMask(toText: String, mask: String) -> String { + + let toTextNSString = toText as NSString + let maskNSString = mask as NSString + + var onOriginal: Int = 0 + var onFilter: Int = 0 + var onOutput: Int = 0 + var outputString = [Character](repeating: "\0", count: maskNSString.length) + var done:Bool = false + + while (onFilter < maskNSString.length && !done) { + + let filterChar: Character = Character(UnicodeScalar(maskNSString.character(at: onFilter)) ?? UnicodeScalar.init(0)) + let originalChar: Character = onOriginal >= toTextNSString.length ? "\0" : + Character(UnicodeScalar(toTextNSString.character(at: onOriginal))!) + + switch filterChar { + case "#": + + if (originalChar == "\0") { + // We have no more input numbers for the filter. We're done. + done = true + break + } + + if (CharacterSet.init(charactersIn: "0123456789").contains(UnicodeScalar(originalChar.unicodeScalarCodePoint())!)) { + outputString[onOutput] = originalChar; + onOriginal += 1 + onFilter += 1 + onOutput += 1 + }else{ + onOriginal += 1 + } + + default: + // Any other character will automatically be inserted for the user as they type (spaces, - etc..) or deleted as they delete if there are more numbers to come. + outputString[onOutput] = filterChar; + onOutput += 1 + onFilter += 1 + if(originalChar == filterChar) { + onOriginal += 1 + } + } + } + + if (onOutput < outputString.count){ + outputString[onOutput] = "\0" // Cap the output string + } + + return String(outputString).replacingOccurrences(of: "\0", with: "") + } + + func stringByAddingPercentEncodingForRFC3986() -> String { + let allowedQueryParamAndKey = CharacterSet(charactersIn: ";/?:@&=+$, ").inverted + return addingPercentEncoding(withAllowedCharacters: allowedQueryParamAndKey)! + } + + func replaceAll(of pattern: String, + with replacement: String, + options: NSRegularExpression.Options = []) -> String{ + do{ + let regex = try NSRegularExpression(pattern: pattern, options: []) + let range = NSRange(0.. UInt32 { + let characterString = String(self) + let scalars = characterString.unicodeScalars + + return scalars[scalars.startIndex].value + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+StringProtocol.swift b/Sources/LCEssentials/Extensions/LCEssentials+StringProtocol.swift new file mode 100644 index 0000000..4a450be --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+StringProtocol.swift @@ -0,0 +1,30 @@ +// +// Copyright (c) 2024 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +extension StringProtocol { + var data: Data { Data(utf8) } + var base64Encoded: Data { data.base64EncodedData() } + var base64Decoded: Data? { Data(base64Encoded: string) } + var string: String { String(self) } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UIApplication.swift b/Sources/LCEssentials/Extensions/LCEssentials+UIApplication.swift new file mode 100644 index 0000000..b66d80f --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UIApplication.swift @@ -0,0 +1,107 @@ +// +// Copyright (c) 2022 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +#if canImport(UIKit) && os(iOS) || os(macOS) +import UIKit + +public extension UIApplication { + /// Application running environment. + /// + /// - debug: Application is running in debug mode. + /// - testFlight: Application is installed from Test Flight. + /// - appStore: Application is installed from the App Store. + enum Environment { + /// Application is running in debug mode. + case debug + /// Application is installed from Test Flight. + case testFlight + /// Application is installed from the App Store. + case appStore + } + + /// Current inferred app environment. + static var inferredEnvironment: Environment { + #if DEBUG + return .debug + + #elseif targetEnvironment(simulator) + return .debug + + #else + if Bundle.main.path(forResource: "embedded", ofType: "mobileprovision") != nil { + return .testFlight + } + + guard let appStoreReceiptUrl = Bundle.main.appStoreReceiptURL else { + return .debug + } + + if appStoreReceiptUrl.lastPathComponent.lowercased() == "sandboxreceipt" { + return .testFlight + } + + if appStoreReceiptUrl.path.lowercased().contains("simulator") { + return .debug + } + + return .appStore + #endif + } + + /// Application name (if applicable). + static var displayName: String? { + return Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + } + + /// App current build number (if applicable). + static var buildNumber: String? { + return Bundle.main.object(forInfoDictionaryKey: kCFBundleVersionKey as String) as? String + } + + /// App's current version number (if applicable). + static var version: String? { + return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + } +} + +public extension UIApplication { + + static func openURL(urlStr: String) { + guard let url = URL(string: urlStr) else { return } + if UIApplication.shared.canOpenURL(url) { + if #available(iOS 10.0, *) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } else { + UIApplication.shared.openURL(url) + } + } else { + // App is not installed + guard let storeApp = URL(string: urlStr) else { return } + if #available(iOS 10.0, *) { + UIApplication.shared.open(storeApp, options: [:], completionHandler: nil) + } else { + UIApplication.shared.openURL(storeApp) + } + } + } +} + +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UIButton.swift b/Sources/LCEssentials/Extensions/LCEssentials+UIButton.swift new file mode 100644 index 0000000..84db7d9 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UIButton.swift @@ -0,0 +1,191 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit + +#if os(iOS) || os(macOS) +// MARK: - Properties +public extension UIButton { + + /// Image of disabled state for button; also inspectable from Storyboard. + var imageForDisabled: UIImage? { + get { + return image(for: .disabled) + } + set { + setImage(newValue, for: .disabled) + } + } + + /// Image of highlighted state for button; also inspectable from Storyboard. + var imageForHighlighted: UIImage? { + get { + return image(for: .highlighted) + } + set { + setImage(newValue, for: .highlighted) + } + } + + /// Image of normal state for button; also inspectable from Storyboard. + var imageForNormal: UIImage? { + get { + return image(for: .normal) + } + set { + setImage(newValue, for: .normal) + } + } + + /// Image of selected state for button; also inspectable from Storyboard. + var imageForSelected: UIImage? { + get { + return image(for: .selected) + } + set { + setImage(newValue, for: .selected) + } + } + + /// Title color of disabled state for button; also inspectable from Storyboard. + var titleColorForDisabled: UIColor? { + get { + return titleColor(for: .disabled) + } + set { + setTitleColor(newValue, for: .disabled) + } + } + + /// Title color of highlighted state for button; also inspectable from Storyboard. + var titleColorForHighlighted: UIColor? { + get { + return titleColor(for: .highlighted) + } + set { + setTitleColor(newValue, for: .highlighted) + } + } + + /// Title color of normal state for button; also inspectable from Storyboard. + var titleColorForNormal: UIColor? { + get { + return titleColor(for: .normal) + } + set { + setTitleColor(newValue, for: .normal) + } + } + + /// Title color of selected state for button; also inspectable from Storyboard. + var titleColorForSelected: UIColor? { + get { + return titleColor(for: .selected) + } + set { + setTitleColor(newValue, for: .selected) + } + } + + /// Title of disabled state for button; also inspectable from Storyboard. + var titleForDisabled: String? { + get { + return title(for: .disabled) + } + set { + setTitle(newValue, for: .disabled) + } + } + + /// Title of highlighted state for button; also inspectable from Storyboard. + var titleForHighlighted: String? { + get { + return title(for: .highlighted) + } + set { + setTitle(newValue, for: .highlighted) + } + } + + /// Title of normal state for button; also inspectable from Storyboard. + var titleForNormal: String? { + get { + return title(for: .normal) + } + set { + setTitle(newValue, for: .normal) + } + } + + /// Title of selected state for button; also inspectable from Storyboard. + var titleForSelected: String? { + get { + return title(for: .selected) + } + set { + setTitle(newValue, for: .selected) + } + } + +} + +// MARK: - Methods +public extension UIButton { + + private var states: [UIControl.State] { + return [.normal, .selected, .highlighted, .disabled] + } + + /// Set image for all states. + /// + /// - Parameter image: UIImage. + func setImageForAllStates(_ image: UIImage) { + states.forEach { setImage(image, for: $0) } + } + + /// Set title color for all states. + /// + /// - Parameter color: UIColor. + func setTitleColorForAllStates(_ color: UIColor) { + states.forEach { setTitleColor(color, for: $0) } + } + + /// Set title for all states. + /// + /// - Parameter title: title string. + func setTitleForAllStates(_ title: String) { + states.forEach { setTitle(title, for: $0) } + } + + /// Center align title text and image on UIButton + /// + /// - Parameter spacing: spacing between UIButton title text and UIButton Image. + func centerTextAndImage(spacing: CGFloat) { + let insetAmount = spacing / 2 + imageEdgeInsets = UIEdgeInsets(top: 0, left: -insetAmount, bottom: 0, right: insetAmount) + titleEdgeInsets = UIEdgeInsets(top: 0, left: insetAmount, bottom: 0, right: -insetAmount) + contentEdgeInsets = UIEdgeInsets(top: 0, left: insetAmount, bottom: 0, right: insetAmount) + } + +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UICollectionView.swift b/Sources/LCEssentials/Extensions/LCEssentials+UICollectionView.swift new file mode 100644 index 0000000..8e6eed5 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UICollectionView.swift @@ -0,0 +1,259 @@ +// +// Copyright (c) 2019 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +#if os(iOS) || os(macOS) +import UIKit + +public extension UICollectionView { + + static var identifier: String { + return "id"+String(describing: self) + } + + /// Index path of last item in collectionView. + var indexPathForLastItem: IndexPath? { + return indexPathForLastItem(inSection: lastSection) + } + + /// Index of last section in collectionView. + var lastSection: Int { + return numberOfSections > 0 ? numberOfSections - 1 : 0 + } + + func setupCollectionView(flowLayout: UICollectionViewFlowLayout = UICollectionViewFlowLayout(), + spacings: CGFloat = 0, + direction: UICollectionView.ScrollDirection = .horizontal, + edgesInset: UIEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), + allowMulpleSelection: Bool = false, + automaticSize: CGSize? = nil) { + + let layout: UICollectionViewFlowLayout = flowLayout + layout.sectionInset = edgesInset + layout.minimumInteritemSpacing = spacings + layout.minimumLineSpacing = spacings + layout.scrollDirection = direction + if let automaticSize = automaticSize { + layout.estimatedItemSize = automaticSize + } + self.contentInsetAdjustmentBehavior = .always + self.collectionViewLayout = layout + self.allowsMultipleSelection = allowMulpleSelection + } + + /// Reload data with a completion handler. + /// + /// - Parameter completion: completion handler to run after reloadData finishes. + func reloadData(_ completion: @escaping () -> Void) { + UIView.animate(withDuration: 0, animations: { + self.reloadData() + }, completion: { _ in + completion() + }) + } + + /// Number of all items in all sections of collectionView. + /// + /// - Returns: The count of all rows in the collectionView. + func numberOfItems() -> Int { + var section = 0 + var itemsCount = 0 + while section < numberOfSections { + itemsCount += numberOfItems(inSection: section) + section += 1 + } + return itemsCount + } + + /// IndexPath for last item in section. + /// + /// - Parameter section: section to get last item in. + /// - Returns: optional last indexPath for last item in section (if applicable). + func indexPathForLastItem(inSection section: Int) -> IndexPath? { + guard section >= 0 else { + return nil + } + guard section < numberOfSections else { + return nil + } + guard numberOfItems(inSection: section) > 0 else { + return IndexPath(item: 0, section: section) + } + return IndexPath(item: numberOfItems(inSection: section) - 1, section: section) + } + + /// Safely scroll to possibly invalid IndexPath. + /// + /// - Parameters: + /// - indexPath: Target IndexPath to scroll to. + /// - scrollPosition: Scroll position. + /// - animated: Whether to animate or not. + func safeScrollToItem(at indexPath: IndexPath, at scrollPosition: UICollectionView.ScrollPosition, animated: Bool) { + guard indexPath.item >= 0, + indexPath.section >= 0, + indexPath.section < numberOfSections, + indexPath.item < numberOfItems(inSection: indexPath.section) else { + return + } + scrollToItem(at: indexPath, at: scrollPosition, animated: animated) + } + + /// Check whether IndexPath is valid within the CollectionView. + /// + /// - Parameter indexPath: An IndexPath to check. + /// - Returns: Boolean value for valid or invalid IndexPath. + func isValidIndexPath(_ indexPath: IndexPath) -> Bool { + return indexPath.section >= 0 && + indexPath.item >= 0 && + indexPath.section < numberOfSections && + indexPath.item < numberOfItems(inSection: indexPath.section) + } +} + +public enum CollectionViewFlowLayoutSpacingMode { + case fixed(spacing: CGFloat) + case overlap(visibleOffset: CGFloat) +} +/// CollectionViewFlowLayout create a centralized content with pagination. +/// +open class CollectionViewFlowLayout: UICollectionViewFlowLayout { + + private struct LayoutState { + var size: CGSize + var direction: UICollectionView.ScrollDirection + func isEqual(_ otherState: LayoutState) -> Bool { + return self.size.equalTo(otherState.size) && self.direction == otherState.direction + } + } + + private var state = LayoutState(size: CGSize.zero, direction: .horizontal) + + open var sideItemScale: CGFloat = 0.6 + open var sideItemAlpha: CGFloat = 0.6 + open var sideItemShift: CGFloat = 0.0 + + open var spacingMode = CollectionViewFlowLayoutSpacingMode.fixed(spacing: 40) + + override open func prepare() { + super.prepare() + let currentState = LayoutState(size: self.collectionView?.bounds.size ?? CGSize.zero, + direction: self.scrollDirection) + + if !self.state.isEqual(currentState) { + self.setupCollectionView() + self.updateLayout() + self.state = currentState + } + } + + private func setupCollectionView() { + guard let collectionView = self.collectionView else { return } + if collectionView.decelerationRate != UIScrollView.DecelerationRate.fast { + collectionView.decelerationRate = UIScrollView.DecelerationRate.fast + } + } + + private func updateLayout() { + guard let collectionView = self.collectionView else { return } + + let collectionSize = collectionView.bounds.size + let isHorizontal = (self.scrollDirection == .horizontal) + + let yInset = (collectionSize.height - self.itemSize.height) / 2 + let xInset = (collectionSize.width - self.itemSize.width) / 2 + self.sectionInset = UIEdgeInsets.init(top: yInset, left: xInset, bottom: yInset, right: xInset) + + let side = isHorizontal ? self.itemSize.width : self.itemSize.height + let scaledItemOffset = (side - side*self.sideItemScale) / 2 + switch self.spacingMode { + case .fixed(let spacing): + self.minimumLineSpacing = spacing - scaledItemOffset + case .overlap(let visibleOffset): + let fullSizeSideItemOverlap = visibleOffset + scaledItemOffset + let inset = isHorizontal ? xInset : yInset + self.minimumLineSpacing = inset - fullSizeSideItemOverlap + } + } + + override open func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { + return true + } + + override open func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + guard let superAttributes = super.layoutAttributesForElements(in: rect), + let attributes = NSArray(array: superAttributes, copyItems: true) as? [UICollectionViewLayoutAttributes] + else { return nil } + return attributes.map({ self.transformLayoutAttributes($0) }) + } + + private func transformLayoutAttributes(_ attributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutAttributes { + guard let collectionView = self.collectionView else { return attributes } + let isHorizontal = (self.scrollDirection == .horizontal) + + let collectionCenter = isHorizontal ? collectionView.frame.size.width/2 : collectionView.frame.size.height/2 + let offset = isHorizontal ? collectionView.contentOffset.x : collectionView.contentOffset.y + let normalizedCenter = (isHorizontal ? attributes.center.x : attributes.center.y) - offset + + let maxDistance = (isHorizontal ? self.itemSize.width : self.itemSize.height) + self.minimumLineSpacing + let distance = min(abs(collectionCenter - normalizedCenter), maxDistance) + let ratio = (maxDistance - distance)/maxDistance + + let alpha = ratio * (1 - self.sideItemAlpha) + self.sideItemAlpha + let scale = ratio * (1 - self.sideItemScale) + self.sideItemScale + let shift = (1 - ratio) * self.sideItemShift + attributes.alpha = alpha + attributes.transform3D = CATransform3DScale(CATransform3DIdentity, scale, scale, 1) + attributes.zIndex = Int(alpha * 10) + + if isHorizontal { + attributes.center.y += shift + } else { + attributes.center.x += shift + } + + return attributes + } + + override open func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, + withScrollingVelocity velocity: CGPoint) -> CGPoint { + guard let collectionView = collectionView, !collectionView.isPagingEnabled, + let layoutAttributes = self.layoutAttributesForElements(in: collectionView.bounds) + else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset) } + + let isHorizontal = (self.scrollDirection == .horizontal) + + let midSide = (isHorizontal ? collectionView.bounds.size.width : collectionView.bounds.size.height) / 2 + let proposedContentOffsetCenterOrigin = (isHorizontal ? proposedContentOffset.x : proposedContentOffset.y) + midSide + + var targetContentOffset: CGPoint + if isHorizontal { + let closest = layoutAttributes.sorted {abs($0.center.x-proposedContentOffsetCenterOrigin) < abs($1.center.x-proposedContentOffsetCenterOrigin)}.first ?? UICollectionViewLayoutAttributes() + targetContentOffset = CGPoint(x: floor(closest.center.x - midSide), y: proposedContentOffset.y) + } else { + let closest = layoutAttributes.sorted {abs($0.center.y-proposedContentOffsetCenterOrigin) < abs($1.center.y-proposedContentOffsetCenterOrigin)}.first ?? UICollectionViewLayoutAttributes() + targetContentOffset = CGPoint(x: proposedContentOffset.x, y: floor(closest.center.y - midSide)) + } + + return targetContentOffset + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UIColor.swift b/Sources/LCEssentials/Extensions/LCEssentials+UIColor.swift new file mode 100644 index 0000000..a1b4c75 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UIColor.swift @@ -0,0 +1,106 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit + +#if os(iOS) || os(macOS) +public extension UIColor { + + var redValue: CGFloat{ return CIColor(color: self).red } + var greenValue: CGFloat{ return CIColor(color: self).green } + var blueValue: CGFloat{ return CIColor(color: self).blue } + var alphaValue: CGFloat{ return CIColor(color: self).alpha } + + convenience init(hex: String) { + var red: CGFloat = 0.0 + var green: CGFloat = 0.0 + var blue: CGFloat = 0.0 + var alpha: CGFloat = 1.0 + var hex: String = hex + + if hex.hasPrefix("#") { + hex = String(hex.dropFirst()) + } + + let scanner = Scanner(string: hex) + var hexValue: CUnsignedLongLong = 0 + if scanner.scanHexInt64(&hexValue) { + switch (hex.count) { + case 3: + red = CGFloat((hexValue & 0xF00) >> 8) / 15.0 + green = CGFloat((hexValue & 0x0F0) >> 4) / 15.0 + blue = CGFloat(hexValue & 0x00F) / 15.0 + case 4: + red = CGFloat((hexValue & 0xF000) >> 12) / 15.0 + green = CGFloat((hexValue & 0x0F00) >> 8) / 15.0 + blue = CGFloat((hexValue & 0x00F0) >> 4) / 15.0 + alpha = CGFloat(hexValue & 0x000F) / 15.0 + case 6: + red = CGFloat((hexValue & 0xFF0000) >> 16) / 255.0 + green = CGFloat((hexValue & 0x00FF00) >> 8) / 255.0 + blue = CGFloat(hexValue & 0x0000FF) / 255.0 + case 8: + red = CGFloat((hexValue & 0xFF000000) >> 24) / 255.0 + green = CGFloat((hexValue & 0x00FF0000) >> 16) / 255.0 + blue = CGFloat((hexValue & 0x0000FF00) >> 8) / 255.0 + alpha = CGFloat(hexValue & 0x000000FF) / 255.0 + default: + print("Invalid RGB string, number of characters after '#' should be either 3, 4, 6 or 8", terminator: "") + } + } else { + print("Scan hex error") + } + self.init(red:red, green:green, blue:blue, alpha:alpha) + } + var hexString: String? { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + let multiplier = CGFloat(255.999999) + + guard self.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { + return nil + } + + if alpha == 1.0 { + return String( + format: "#%02lX%02lX%02lX", + Int(red * multiplier), + Int(green * multiplier), + Int(blue * multiplier) + ) + } + else { + return String( + format: "#%02lX%02lX%02lX%02lX", + Int(red * multiplier), + Int(green * multiplier), + Int(blue * multiplier), + Int(alpha * multiplier) + ) + } + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UIDevice.swift b/Sources/LCEssentials/Extensions/LCEssentials+UIDevice.swift new file mode 100644 index 0000000..93ebf04 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UIDevice.swift @@ -0,0 +1,124 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit + +#if os(iOS) || os(macOS) +public extension UIDevice { + + static var topNotch: CGFloat { + if #available(iOS 11.0, *) { + return LCEssentials.keyWindow?.safeAreaInsets.top ?? 0 + } + return 0 + } + + static var bottomNotch: CGFloat { + if #available(iOS 11.0, *) { + return LCEssentials.keyWindow?.safeAreaInsets.bottom ?? 0 + } + return 0 + } + + static var hasNotch: Bool { + return (self.topNotch > 0 && self.bottomNotch > 0) + } + + var modelName: String { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + var identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8 , value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + #if swift(>=4.1) + #if targetEnvironment(simulator) + //#if (arch(i386) || arch(x86_64)) && os(iOS) + // this neat trick is found at http://kelan.io/2015/easier-getenv-in-swift/ + if let dir = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] { + identifier = dir + } + #endif + #endif + + #if os(iOS) + switch identifier { + case "iPod5,1": return "iPod touch (5th generation)" + case "iPod7,1": return "iPod touch (6th generation)" + case "iPod9,1": return "iPod touch (7th generation)" + case "iPhone3,1", "iPhone3,2", "iPhone3,3": return "iPhone 4" + case "iPhone4,1": return "iPhone 4s" + case "iPhone5,1", "iPhone5,2": return "iPhone 5" + case "iPhone5,3", "iPhone5,4": return "iPhone 5c" + case "iPhone6,1", "iPhone6,2": return "iPhone 5s" + case "iPhone7,2": return "iPhone 6" + case "iPhone7,1": return "iPhone 6 Plus" + case "iPhone8,1": return "iPhone 6s" + case "iPhone8,2": return "iPhone 6s Plus" + case "iPhone9,1", "iPhone9,3": return "iPhone 7" + case "iPhone9,2", "iPhone9,4": return "iPhone 7 Plus" + case "iPhone8,4", "iPhone12,8": return "iPhone SE" + case "iPhone10,1", "iPhone10,4": return "iPhone 8" + case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus" + case "iPhone10,3", "iPhone10,6": return "iPhone X" + case "iPhone11,2": return "iPhone XS" + case "iPhone11,4", "iPhone11,6": return "iPhone XS Max" + case "iPhone11,8": return "iPhone XR" + case "iPhone12,1": return "iPhone 11" + case "iPhone12,3": return "iPhone 11 Pro" + case "iPhone12,5": return "iPhone 11 Pro Max" + case "iPhone13,1": return "iPhone 12 mini" + case "iPhone13,2": return "iPhone 12" + case "iPhone13,3": return "iPhone 12 Pro" + case "iPhone13,4": return "iPhone 12 Pro Max" + case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4":return "iPad 2" + case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad 3" + case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad 4" + case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air" + case "iPad5,3", "iPad5,4": return "iPad Air 2" + case "iPad6,11", "iPad6,12": return "iPad 5" + case "iPad7,5", "iPad7,6": return "iPad 6" + case "iPad7,11", "iPad7,12": return "iPad 7" + case "iPad11,4", "iPad11,5": return "iPad Air (3rd generation)" + case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad Mini" + case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad Mini 2" + case "iPad4,7", "iPad4,8", "iPad4,9": return "iPad Mini 3" + case "iPad5,1", "iPad5,2": return "iPad Mini 4" + case "iPad11,1", "iPad11,2": return "iPad Mini 5" + case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)" + case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch)" + case "iPad7,1", "iPad7,2": return "iPad Pro (12.9-inch) (2nd generation)" + case "iPad7,3", "iPad7,4": return "iPad Pro (10.5-inch)" + case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4":return "iPad Pro (11-inch)" + case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8":return "iPad Pro (12.9-inch) (3rd generation)" + case "AppleTV5,3": return "Apple TV" + case "AppleTV6,2": return "Apple TV 4K" + case "AudioAccessory1,1": return "HomePod" + case "i386", "x86_64": return "Simulator \(identifier)" + default: return identifier + } + #endif + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UIImage.swift b/Sources/LCEssentials/Extensions/LCEssentials+UIImage.swift new file mode 100644 index 0000000..cad51f3 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UIImage.swift @@ -0,0 +1,186 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit + +#if os(iOS) || os(macOS) +public extension UIImage { + //Extension Required by RoundedButton to create UIImage from UIColor + func imageWithColor(color: UIColor) -> UIImage { + let rect: CGRect = CGRect(origin: .zero, size: CGSize(width: 1, height: 1)) + UIGraphicsBeginImageContextWithOptions(CGSize(width: 1, height: 1), false, 1.0) + color.setFill() + UIRectFill(rect) + let image: UIImage = UIGraphicsGetImageFromCurrentImageContext()! + UIGraphicsEndImageContext() + return image + } + /** Cria uma cópia de uma imagem fazendo sobreposição de cor.*/ + func tintImage(color:UIColor) -> UIImage { + + let newImage = self.withRenderingMode(.alwaysTemplate) + UIGraphicsBeginImageContextWithOptions(self.size, false, self.scale); + color.set() + newImage.draw(in: CGRect.init(x: 0.0, y: 0.0, width: self.size.width, height: self.size.height)) + let finalImage:UIImage? = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + // + return finalImage ?? self + } + + ///Make transparent color of image - choose a color and range color + func backgroundColorTransparent(initialColor: UIColor, finalColor: UIColor) -> UIImage? { + + let image = UIImage(data: self.jpegData(compressionQuality: 1.0)!)! + let rawImageRef: CGImage = image.cgImage! + + //let colorMasking: [CGFloat] = [222, 255, 222, 255, 222, 255] + let colorMasking: [CGFloat] = [finalColor.redValue, initialColor.redValue, finalColor.greenValue, initialColor.greenValue, finalColor.blueValue, initialColor.blueValue] + UIGraphicsBeginImageContext(image.size); + + let maskedImageRef = rawImageRef.copy(maskingColorComponents: colorMasking) + UIGraphicsGetCurrentContext()?.translateBy(x: 0.0,y: image.size.height) + UIGraphicsGetCurrentContext()?.scaleBy(x: 1.0, y: -1.0) + UIGraphicsGetCurrentContext()?.draw(maskedImageRef!, in: CGRect.init(x: 0, y: 0, width: image.size.width, height: image.size.height)) + let result = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + return result + + } + + /// Creates a circular outline image. + class func outlinedEllipse(size: CGSize, color: UIColor, lineWidth: CGFloat = 1.0) -> UIImage? { + + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + guard let context = UIGraphicsGetCurrentContext() else { + return nil + } + + context.setStrokeColor(color.cgColor) + context.setLineWidth(lineWidth) + // Inset the rect to account for the fact that strokes are + // centred on the bounds of the shape. + let rect = CGRect(origin: .zero, size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5) + context.addEllipse(in: rect) + context.strokePath() + + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image + } + + func resizeImage(newWidth: CGFloat) -> UIImage { + + let scale = newWidth / self.size.width + let newHeight = self.size.height * scale + UIGraphicsBeginImageContext(CGSize(width: newWidth, height: newHeight)) + self.draw(in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight)) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage! + } + + /// Verifica se a imagem instanciada é uma animação. + /// - Returns: O retorno será 'true' para imagens animadas e 'false' para imagens normais. + func isAnimated() -> Bool { + if ((self.images?.count ?? 0) > 1) { + return true + } + return false + } + + + /// Cria o `thumbnail` da imagem. + /// - Parameter maxPixelSize: Máxima dimensão que o thumb deve ter. + /// - Returns: Retorna uma nova instância cópia do objeto imagem original. + func createThumbnail(_ maxPixelSize:UInt) -> UIImage { + + if self.size.width == 0 || self.size.height == 0 || self.isAnimated() { + return self + } + + if let data = self.pngData() { + let imageSource:CGImageSource = CGImageSourceCreateWithData(data as CFData, nil)! + // + var options: [NSString:Any] = Dictionary() + options[kCGImageSourceThumbnailMaxPixelSize] = maxPixelSize + options[kCGImageSourceCreateThumbnailFromImageAlways] = true + // + if let scaledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) { + let finalImage = UIImage.init(cgImage: scaledImage) + return finalImage + } + } + + return self + } + + /// Aplica uma máscara na imagem base, gerando uma nova imagem 'vazada'. A máscara deve conter canal alpha, que definirá a visibilidade final da imagem resultante. + func maskWithAlphaImage(maskImage:UIImage) -> UIImage { + + if self.cgImage == nil || maskImage.cgImage == nil || self.size.width == 0 || self.size.height == 0 || self.isAnimated() { + return self + } + + let filterName = "CIBlendWithAlphaMask" + + let inputImage = CIImage.init(cgImage: self.cgImage!) + let inputMaskImage = CIImage.init(cgImage: maskImage.cgImage!) + + let context:CIContext = CIContext.init() + + if let ciFilter:CIFilter = CIFilter.init(name: filterName) { + ciFilter.setValue(inputImage, forKey: kCIInputImageKey) + ciFilter.setValue(inputMaskImage, forKey: kCIInputMaskImageKey) + // + if let outputImage:CIImage = ciFilter.outputImage { + let cgimg:CGImage = context.createCGImage(outputImage, from: outputImage.extent)! + let newImage:UIImage = UIImage.init(cgImage: cgimg, scale: self.scale, orientation: self.imageOrientation) + return newImage + } + } + + return self + } + + /// Create a new image from a base 64 string. + /// + /// - Parameters: + /// - base64String: a base-64 `String`, representing the image + /// - scale: The scale factor to assume when interpreting the image data created from the base-64 string. Applying a scale factor of 1.0 results in an image whose size matches the pixel-based dimensions of the image. Applying a different scale factor changes the size of the image as reported by the `size` property. + convenience init?(base64String: String, scale: CGFloat = 1.0) { + guard let data = Data(base64Encoded: base64String) else { return nil } + self.init(data: data, scale: scale) + } + + @MainActor + convenience init(view: UIView) { + UIGraphicsBeginImageContext(view.frame.size) + view.layer.render(in:UIGraphicsGetCurrentContext()!) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + self.init(cgImage: image!.cgImage!) + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UIImageView.swift b/Sources/LCEssentials/Extensions/LCEssentials+UIImageView.swift new file mode 100644 index 0000000..5f88ee1 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UIImageView.swift @@ -0,0 +1,69 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit + +#if os(iOS) || os(macOS) +@MainActor +public extension UIImageView { + + func changeColorOfImage( _ color: UIColor, image: UIImage? ) -> UIImageView { + + let origImage = image + let tintedImage = origImage?.withRenderingMode(UIImage.RenderingMode.alwaysTemplate) + self.image = tintedImage + self.tintColor = color + return self + } + + var encodeToBase64: String? { + let jpegCompressionQuality: CGFloat = 0.6 + if let base64String = self.image?.jpegData(compressionQuality: jpegCompressionQuality) { + let strBase64 = base64String.base64EncodedString(options: .endLineWithLineFeed) + return strBase64 + } + return nil + } + + func addAspectRatioConstraint() { + removeAspectRatioConstraint() + if let image = self.image, image.size.height > 0 { + let aspectRatio = image.size.width / image.size.height + let constraint = NSLayoutConstraint(item: self, attribute: .width, + relatedBy: .equal, + toItem: self, attribute: .height, + multiplier: aspectRatio, constant: 0.0) + addConstraint(constraint) + } + } + + + func removeAspectRatioConstraint() { + for constraint in self.constraints + where (constraint.firstItem as? UIImageView) == self + && (constraint.secondItem as? UIImageView) == self { + removeConstraint(constraint) + } + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UILabel.swift b/Sources/LCEssentials/Extensions/LCEssentials+UILabel.swift new file mode 100644 index 0000000..4615549 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UILabel.swift @@ -0,0 +1,48 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit + +#if os(iOS) || os(macOS) +public extension UILabel { + + func lineNumbers() -> Int{ + let textSize = CGSize(width: self.frame.size.width, height: CGFloat(Float.infinity)) + let rHeight = lroundf(Float(self.sizeThatFits(textSize).height)) + let charSize = lroundf(Float(self.font.lineHeight)) + let lineCount = rHeight/charSize + return lineCount + } + + var getEstimatedHeight: CGFloat { + let label = UILabel(frame: CGRect(x: 0, y: 0, width: frame.width, height: CGFloat.greatestFiniteMagnitude)) + label.numberOfLines = 0 + label.lineBreakMode = NSLineBreakMode.byWordWrapping + label.font = font + label.text = text + label.attributedText = attributedText + label.sizeToFit() + return label.frame.height + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UINavigationController.swift b/Sources/LCEssentials/Extensions/LCEssentials+UINavigationController.swift new file mode 100644 index 0000000..35a7ae4 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UINavigationController.swift @@ -0,0 +1,93 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +#if os(iOS) || os(macOS) +import UIKit +import QuartzCore + +public extension UINavigationController { + + /// Pop ViewController with completion handler. + /// + /// - Parameters: + /// - animated: Set this value to true to animate the transition (default is true). + /// - completion: optional completion handler (default is nil). + func popViewController(animated: Bool = true, _ completion: (() -> Void)? = nil) { + // https://github.com/cotkjaer/UserInterface/blob/master/UserInterface/UIViewController.swift + CATransaction.begin() + CATransaction.setCompletionBlock(completion) + popViewController(animated: animated) + CATransaction.commit() + } + + /// Pop To a ViewController with completion handler. + /// + /// - Parameters: + /// - animated: Set this value to true to animate the transition (default is true). + /// - completion: optional completion handler (default is nil). + func popToViewController(_ viewController: UIViewController, animated: Bool = true, _ completion: (() -> Void)? = nil) { + // https://github.com/cotkjaer/UserInterface/blob/master/UserInterface/UIViewController.swift + CATransaction.begin() + CATransaction.setCompletionBlock(completion) + popToViewController(viewController, animated: animated) + CATransaction.commit() + } + + /// Push ViewController with completion handler. + /// + /// - Parameters: + /// - viewController: viewController to push. + /// - completion: optional completion handler (default is nil). + func pushViewController(_ viewController: UIViewController, animated: Bool = true, completion: (() -> Void)? = nil) { + // https://github.com/cotkjaer/UserInterface/blob/master/UserInterface/UIViewController.swift + CATransaction.begin() + CATransaction.setCompletionBlock(completion) + pushViewController(viewController, animated: animated) + CATransaction.commit() + } + +#if !os(tvOS) + /// Pushes a view controller while hiding or showing the bottom bar. + /// + /// - Parameters: + /// - viewController: The view controller to push. + /// - hidesBottomBar: If `true`, hides the bottom bar (e.g. tab bar). + /// - animated: Specify `true` to animate the transition. + func pushViewController(_ viewController: UIViewController, hidesBottomBar: Bool = false, animated: Bool = true) { + viewController.hidesBottomBarWhenPushed = hidesBottomBar + pushViewController(viewController, animated: animated) + } +#endif + + /// Make navigation controller's navigation bar transparent. + /// + /// - Parameter tint: tint color (default is .white). + func makeTransparent(withTint tint: UIColor = .white) { + navigationBar.setBackgroundImage(UIImage(), for: .default) + navigationBar.shadowImage = UIImage() + navigationBar.isTranslucent = true + navigationBar.tintColor = tint + navigationBar.titleTextAttributes = [.foregroundColor: tint] + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UIResponder.swift b/Sources/LCEssentials/Extensions/LCEssentials+UIResponder.swift new file mode 100644 index 0000000..387c9e5 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UIResponder.swift @@ -0,0 +1,46 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +#if os(iOS) || os(macOS) +import UIKit + +public extension UIResponder { + + var getParentViewController: UIViewController? { + if next is UIViewController { + return next as? UIViewController + } else { + if next != nil { + return next?.getParentViewController + } + else { return nil } + } + } +} +//HOW TO USE +//let vc = UIViewController() +//let view = UIView() +//vc.view.addSubview(view) +//view.getParentViewController //provide reference to vc + +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UIScrollView.swift b/Sources/LCEssentials/Extensions/LCEssentials+UIScrollView.swift new file mode 100644 index 0000000..1bfa710 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UIScrollView.swift @@ -0,0 +1,137 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +#if canImport(UIKit) && !os(watchOS) +import UIKit + +// MARK: - Methods +public extension UIScrollView { + + enum orientation { + case horizontal, vertical + } + + /// Takes a snapshot of an entire ScrollView + /// + /// AnySubclassOfUIScroolView().snapshot + /// UITableView().snapshot + /// + /// - Returns: Snapshot as UIimage for rendered ScrollView + var snapshot: UIImage? { + // Original Source: https://gist.github.com/thestoics/1204051 + UIGraphicsBeginImageContextWithOptions(contentSize, false, 0) + defer { + UIGraphicsEndImageContext() + } + guard let context = UIGraphicsGetCurrentContext() else { return nil } + let previousFrame = frame + frame = CGRect(origin: frame.origin, size: contentSize) + layer.render(in: context) + frame = previousFrame + return UIGraphicsGetImageFromCurrentImageContext() + } + + /// The currently visible region of the scroll view. + var visibleRect: CGRect { + let contentWidth = contentSize.width - contentOffset.x + let contentHeight = contentSize.height - contentOffset.y + return CGRect(origin: contentOffset, + size: CGSize(width: min(min(bounds.size.width, contentSize.width), contentWidth), + height: min(min(bounds.size.height, contentSize.height), contentHeight))) + } + + var offsetInPage: CGFloat { + let page = contentOffset.y / frame.size.height + return page - floor(page) + } +} + +public extension UIScrollView { + /// Scroll up one page of the scroll view. + /// If `isPagingEnabled` is `true`, the previous page location is used. + /// - Parameter animated: `true` to animate the transition at a constant velocity to the new offset, `false` to make + /// the transition immediate. + func scrollUp(animated: Bool = true) { + let minY = -contentInset.top + var y = max(minY, contentOffset.y - bounds.height) + #if !os(tvOS) + if isPagingEnabled, + bounds.height != 0 { + let page = max(0, ((y + contentInset.top) / bounds.height).rounded(.down)) + y = max(minY, page * bounds.height - contentInset.top) + } + #endif + setContentOffset(CGPoint(x: contentOffset.x, y: y), animated: animated) + } + + /// Scroll left one page of the scroll view. + /// If `isPagingEnabled` is `true`, the previous page location is used. + /// - Parameter animated: `true` to animate the transition at a constant velocity to the new offset, `false` to make + /// the transition immediate. + func scrollLeft(animated: Bool = true) { + let minX = -contentInset.left + var x = max(minX, contentOffset.x - bounds.width) + #if !os(tvOS) + if isPagingEnabled, + bounds.width != 0 { + let page = ((x + contentInset.left) / bounds.width).rounded(.down) + x = max(minX, page * bounds.width - contentInset.left) + } + #endif + setContentOffset(CGPoint(x: x, y: contentOffset.y), animated: animated) + } + + /// Scroll down one page of the scroll view. + /// If `isPagingEnabled` is `true`, the next page location is used. + /// - Parameter animated: `true` to animate the transition at a constant velocity to the new offset, `false` to make + /// the transition immediate. + func scrollDown(animated: Bool = true) { + let maxY = max(0, contentSize.height - bounds.height) + contentInset.bottom + var y = min(maxY, contentOffset.y + bounds.height) + #if !os(tvOS) + if isPagingEnabled, + bounds.height != 0 { + let page = ((y + contentInset.top) / bounds.height).rounded(.down) + y = min(maxY, page * bounds.height - contentInset.top) + } + #endif + setContentOffset(CGPoint(x: contentOffset.x, y: y), animated: animated) + } + + /// Scroll right one page of the scroll view. + /// If `isPagingEnabled` is `true`, the next page location is used. + /// - Parameter animated: `true` to animate the transition at a constant velocity to the new offset, `false` to make + /// the transition immediate. + func scrollRight(animated: Bool = true) { + let maxX = max(0, contentSize.width - bounds.width) + contentInset.right + var x = min(maxX, contentOffset.x + bounds.width) + #if !os(tvOS) + if isPagingEnabled, + bounds.width != 0 { + let page = ((x + contentInset.left) / bounds.width).rounded(.down) + x = min(maxX, page * bounds.width - contentInset.left) + } + #endif + setContentOffset(CGPoint(x: x, y: contentOffset.y), animated: animated) + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UIStackView.swift b/Sources/LCEssentials/Extensions/LCEssentials+UIStackView.swift new file mode 100644 index 0000000..237052b --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UIStackView.swift @@ -0,0 +1,99 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit +#if os(iOS) + +// MARK: - Initializers +@available(iOS 9.0, *) +public extension UIStackView { + + convenience init(arrangedSubviews: [UIView]? = nil, + axis: NSLayoutConstraint.Axis = .vertical, + spacing: CGFloat = 0.0, + alignment: UIStackView.Alignment = .fill, + distribution: UIStackView.Distribution = .fill, + layoutMargins: UIEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0), + isMarginsRelative: Bool = true) { + + if let arrangedSubviews = arrangedSubviews { + self.init(arrangedSubviews: arrangedSubviews) + } else { + self.init() + } + self.axis = axis + self.spacing = spacing + self.alignment = alignment + self.distribution = distribution + self.layoutMargins = layoutMargins + self.isLayoutMarginsRelativeArrangement = isMarginsRelative + } + + func addArrangedSubviews(_ views: [UIView], translateAutoresizing: Bool = false) { + views.forEach { subview in + addArrangedSubview(subview) + subview.translatesAutoresizingMaskIntoConstraints = translateAutoresizing + } + } + + func removeAllArrangedSubviews(deactivateConstraints: Bool = true) { + arrangedSubviews.forEach { + removeSubview(view: $0, deactivateConstraints: deactivateConstraints) + } + } + + /// Remove specific view from it + /// + /// - Parameter view: view to be removed. + func removeSubview(view: UIView, deactivateConstraints: Bool = true) { + removeArrangedSubview(view) + if deactivateConstraints { + NSLayoutConstraint.deactivate(view.constraints) + } + view.removeFromSuperview() + } + + private func addSpace(height: CGFloat? = nil, width: CGFloat? = nil, backgroundColor: UIColor = .clear) { + let spaceView = UIView() + spaceView.backgroundColor = backgroundColor + spaceView.translatesAutoresizingMaskIntoConstraints = false + if let height = height { + spaceView.setHeight(size: height) + } + if let width = width { + spaceView.setWidth(size: width) + } + addArrangedSubview(spaceView) + } + + func addSpace(_ size: CGFloat, backgroundColor: UIColor = .clear) { + switch self.axis { + case .vertical: + addSpace(height: size, backgroundColor: backgroundColor) + case .horizontal: + addSpace(width: size, backgroundColor: backgroundColor) + default: break + } + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UITabBarController.swift b/Sources/LCEssentials/Extensions/LCEssentials+UITabBarController.swift new file mode 100644 index 0000000..2cfe5ff --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UITabBarController.swift @@ -0,0 +1,173 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +#if os(iOS) || os(macOS) +import UIKit + +public class CustomTabBadge: UILabel { + + public init(font: UIFont = UIFont(name: "Helvetica-Light", size: 11) ?? UIFont()) { + super.init(frame: .zero) + + self.font = font + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +public extension UITabBarController { + + func setBadges(badgeValues: [Int], font: UIFont = UIFont(name: "Helvetica-Light", size: 11) ?? UIFont()) { + + for view in self.tabBar.subviews { + if view is CustomTabBadge { + view.removeFromSuperview() + } + } + + for index in 0...badgeValues.count-1 { + if badgeValues[index] != 0 { + addBadge(index: index, + value: badgeValues[index], + color: .red, + font: font) + } + } + } + + func addBadge(index: Int, value: Int, color: UIColor, font: UIFont) { + let badgeView = CustomTabBadge(font: font) + + badgeView.clipsToBounds = true + badgeView.textColor = UIColor.white + badgeView.textAlignment = .center + badgeView.text = String(value) + badgeView.backgroundColor = color + badgeView.tag = index + tabBar.addSubview(badgeView) + + self.positionBadges() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + self.positionBadges() + } + + // Positioning + func positionBadges() { + + var tabbarButtons = self.tabBar.subviews.filter { (view: UIView) -> Bool in + return view.isUserInteractionEnabled // only UITabBarButton are userInteractionEnabled + } + + tabbarButtons = tabbarButtons.sorted(by: { $0.frame.origin.x < $1.frame.origin.x }) + + for view in self.tabBar.subviews { + if view is CustomTabBadge { + if let badgeView = view as? CustomTabBadge { + self.positionBadge(badgeView: badgeView, items: tabbarButtons, index: badgeView.tag) + } + } + } + } + + func positionBadge(badgeView: UIView, items: [UIView], index: Int) { + + let itemView = items[index] + let center = itemView.center + + let xOffset: CGFloat = 10 + let yOffset: CGFloat = -14 + badgeView.frame.size = CGSize(width: 17, height: 17) + badgeView.center = CGPoint(x: center.x + xOffset, y: center.y + yOffset) + badgeView.layer.cornerRadius = badgeView.bounds.width/2 + tabBar.bringSubviewToFront(badgeView) + } + + func setSelectedView(atIndex: Int, withAnimation: Bool = false, completion:(()->())?){ + if let navController = self.viewControllers![atIndex] as? UINavigationController { + navController.popToRootViewController(animated: false) + } + if withAnimation { + animateToTab(toIndex: atIndex) + }else{ + self.selectedIndex = atIndex + } + completion?() + } + + func setSelectedView(withNoPop atIndex: Int, withAnimation: Bool = false, completion:(()->())?) { + //self.viewControllers?[atIndex].viewWillAppear(true) + if withAnimation { + animateToTab(toIndex: atIndex) + }else{ + self.selectedIndex = atIndex + } + completion?() + } + + func changeViewControllerToItem(withViewController: UIViewController, Item: Int){ + guard let navController = self.viewControllers?[Item] as? UINavigationController else{ return } + navController.setViewControllers([withViewController], animated: true) + self.setSelectedView(atIndex: Item, completion: nil) + } + + func animateToTab(toIndex: Int) { + let tabViewControllers = viewControllers! + let fromView = selectedViewController!.view + let toView = tabViewControllers[toIndex].view + let fromIndex = self.selectedIndex //tabViewControllers.index(of: selectedViewController!) + + guard fromIndex != toIndex else {return} + + // Add the toView to the tab bar view + fromView?.superview!.addSubview(toView!) + + // Position toView off screen (to the left/right of fromView) + let screenWidth = UIScreen.main.bounds.size.width; + let scrollRight = toIndex > fromIndex; + let offset = (scrollRight ? screenWidth : -screenWidth) + toView?.center = CGPoint(x: (fromView?.center.x)! + offset, y: (toView?.center.y)!) + + // Disable interaction during animation + view.isUserInteractionEnabled = false + + UIView.animate(withDuration: 0.5, delay: 0.0, usingSpringWithDamping: 1, initialSpringVelocity: 0, options: UIView.AnimationOptions.curveEaseOut, animations: { + + // Slide the views by -offset + fromView?.center = CGPoint(x: (fromView?.center.x)! - offset, y: (fromView?.center.y)!); + toView?.center = CGPoint(x: (toView?.center.x)! - offset, y: (toView?.center.y)!); + + }, completion: { finished in + + // Remove the old view from the tabbar view. + fromView?.removeFromSuperview() + self.selectedIndex = toIndex + self.view.isUserInteractionEnabled = true + }) + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UITableView.swift b/Sources/LCEssentials/Extensions/LCEssentials+UITableView.swift new file mode 100644 index 0000000..6997458 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UITableView.swift @@ -0,0 +1,151 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import UIKit + +#if os(iOS) || os(macOS) +//MARK: - UITableView Animation Cell +public typealias UITableViewCellAnimation = (UITableViewCell, IndexPath, UITableView) -> Void + +public class UITableViewAnimator { + private let animation: UITableViewCellAnimation + + public init(animation: @escaping UITableViewCellAnimation) { + self.animation = animation + } + + public func animate(cell: UITableViewCell, at indexPath: IndexPath, in tableView: UITableView) { + animation(cell, indexPath, tableView) + } +} + +public extension UITableView { + + /// Reload data with a completion handler. + /// + /// - Parameter completion: completion handler to run after reloadData finishes. + func reloadData(_ completion: @escaping () -> Void) { + UIView.animate(withDuration: 0, animations: { + self.reloadData() + }, completion: { _ in + completion() + }) + } + + /// Loverde Co.: Animate cell to up with fade - See file example to know how to use + /// + func makeMoveUpWithFadeAnimation(rowHeight: CGFloat, duration: TimeInterval, delayFactor: TimeInterval) -> UITableViewCellAnimation { + return { cell, indexPath, _ in + cell.transform = CGAffineTransform(translationX: 0, y: rowHeight * 1.4) + cell.alpha = 0 + UIView.animate( + withDuration: duration, + delay: delayFactor * Double(indexPath.row), + options: [.curveEaseInOut], + animations: { + cell.transform = CGAffineTransform(translationX: 0, y: 0) + cell.alpha = 1 + }) + } + } + + /// Dequeue reusable UITableViewCell using class name + /// + /// - Parameter name: UITableViewCell type + /// - Returns: UITableViewCell object with associated class name. + func dequeueReusableCell(withClass name: T.Type) -> T { + guard let cell = dequeueReusableCell(withIdentifier: String(describing: name)) as? T else { + fatalError("Couldn't find UITableViewCell for \(String(describing: name)), make sure the cell is registered with table view") + } + return cell + } + + /// Dequeue reusable UITableViewCell using class name for indexPath + /// + /// - Parameters: + /// - name: UITableViewCell type. + /// - indexPath: location of cell in tableView. + /// - Returns: UITableViewCell object with associated class name. + func dequeueReusableCell(withClass name: T.Type, for indexPath: IndexPath) -> T { + guard let cell = dequeueReusableCell(withIdentifier: String(describing: name), for: indexPath) as? T else { + fatalError("Couldn't find UITableViewCell for \(String(describing: name)), make sure the cell is registered with table view") + } + return cell + } + + /// Dequeue reusable UITableViewHeaderFooterView using class name + /// + /// - Parameter name: UITableViewHeaderFooterView type + /// - Returns: UITableViewHeaderFooterView object with associated class name. + func dequeueReusableHeaderFooterView(withClass name: T.Type) -> T { + guard let headerFooterView = dequeueReusableHeaderFooterView(withIdentifier: String(describing: name)) as? T else { + fatalError("Couldn't find UITableViewHeaderFooterView for \(String(describing: name)), make sure the view is registered with table view") + } + return headerFooterView + } + + func dequeueCell(indexPath: IndexPath) -> T { + guard let cell = self.dequeueReusableCell(withIdentifier: T.identifier, for: indexPath) as? T else { + fatalError("Couldn't find UITableViewCell for \(String(describing: T.className)), make sure the view is registered with table view") + } + return cell + } + + /// Check whether IndexPath is valid within the tableView + /// + /// - Parameter indexPath: An IndexPath to check + /// - Returns: Boolean value for valid or invalid IndexPath + func isValidIndexPath(_ indexPath: IndexPath) -> Bool { + return indexPath.section >= 0 && + indexPath.row >= 0 && + indexPath.section < numberOfSections && + indexPath.row < numberOfRows(inSection: indexPath.section) + } + + /// Safely scroll to possibly invalid IndexPath + /// + /// - Parameters: + /// - indexPath: Target IndexPath to scroll to + /// - scrollPosition: Scroll position + /// - animated: Whether to animate or not + func safeScrollToRow(at indexPath: IndexPath, at scrollPosition: UITableView.ScrollPosition, animated: Bool) { + guard indexPath.section < numberOfSections else { return } + guard indexPath.row < numberOfRows(inSection: indexPath.section) else { return } + scrollToRow(at: indexPath, at: scrollPosition, animated: animated) + } +} + +extension UITableViewCell { + + public static var identifier: String { + return "id"+String(describing: self) + } + + public func prepareDisclosureIndicator() { + for case let button as UIButton in subviews { + let image = button.backgroundImage(for: .normal)?.withRenderingMode(.alwaysTemplate) + button.setBackgroundImage(image, for: .normal) + } + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UITapGestureRecognizer.swift b/Sources/LCEssentials/Extensions/LCEssentials+UITapGestureRecognizer.swift new file mode 100644 index 0000000..4900df4 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UITapGestureRecognizer.swift @@ -0,0 +1,60 @@ +// +// Copyright (c) 2022 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +#if canImport(UIKit) && os(iOS) || os(macOS) +import UIKit + +public extension UITapGestureRecognizer { + + func didTapAttributedTextInLabel(label: UILabel, textToTouch: String) -> Bool { + let targetRange = NSString().range(of: textToTouch) + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: CGSize.zero) + let textStorage = NSTextStorage(attributedString: label.attributedText!) + + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + + textContainer.lineFragmentPadding = 0.0 + textContainer.lineBreakMode = label.lineBreakMode + textContainer.maximumNumberOfLines = label.numberOfLines + let labelSize = label.bounds.size + textContainer.size = labelSize + + let locationOfTouchInLabel = self.location(in: label) + let textBoundingBox = layoutManager.usedRect(for: textContainer) + + let textContainerOffset = CGPoint(x: (labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x, + y: (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y) + + let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - textContainerOffset.x, + y: locationOfTouchInLabel.y - textContainerOffset.y) + + let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, + in: textContainer, + fractionOfDistanceBetweenInsertionPoints: nil) + + return NSLocationInRange(indexOfCharacter, targetRange) + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UITextField.swift b/Sources/LCEssentials/Extensions/LCEssentials+UITextField.swift new file mode 100644 index 0000000..ce08c4f --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UITextField.swift @@ -0,0 +1,63 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +#if os(iOS) || os(macOS) +import UIKit + +public extension UITextField { + + var placeholderColor: UIColor { + get { + return attributedPlaceholder?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? UIColor ?? .clear; + } + set { + guard let attributedPlaceholder = attributedPlaceholder else { return; } + let attributes: [NSAttributedString.Key: UIColor] = [.foregroundColor: newValue]; + self.attributedPlaceholder = NSAttributedString(string: attributedPlaceholder.string, attributes: attributes); + } + } + + /// Add padding to the left of the textfield rect. + /// + /// - Parameter padding: amount of padding to apply to the left of the textfield rect. + func addPaddingLeft(_ padding: CGFloat) { + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: padding, height: frame.height)) + leftView = paddingView + leftViewMode = .always + } + + /// Add padding to the left of the textfield rect. + /// + /// - Parameters: + /// - image: left image + /// - padding: amount of padding between icon and the left of textfield + func addPaddingLeftIcon(_ image: UIImage, padding: CGFloat) { + let imageView = UIImageView(image: image) + imageView.contentMode = .center + leftView = imageView + leftView?.frame.size = CGSize(width: image.size.width + padding, height: image.size.height) + leftViewMode = .always + } + +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UIView.swift b/Sources/LCEssentials/Extensions/LCEssentials+UIView.swift new file mode 100644 index 0000000..e55e228 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UIView.swift @@ -0,0 +1,714 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +#if os(iOS) || os(macOS) +import UIKit + +typealias GradientPoints = (startPoint: CGPoint, endPoint: CGPoint) + +public enum GradientOrientation { + case topRightBottomLeft + case topLeftBottomRight + case horizontal + case vertical + + var startPoint: CGPoint { + return points.startPoint + } + + var endPoint: CGPoint { + return points.endPoint + } + + var points: GradientPoints { + switch self { + case .topRightBottomLeft: + return (CGPoint(x: 0.0, y: 1.0), CGPoint(x: 1.0, y: 0.0)) + case .topLeftBottomRight: + return (CGPoint(x: 0.0, y: 0.0), CGPoint(x: 1, y: 1)) + case .horizontal: + return (CGPoint(x: 0.0, y: 0.5), CGPoint(x: 1.0, y: 0.5)) + case .vertical: + return (CGPoint(x: 0.0, y: 0.0), CGPoint(x: 0.0, y: 1.0)) + } + } +} + +public enum EnumBorderSide { + case top, bottom, left, right +} + +@MainActor fileprivate weak var kvo_viewReference: UIView? + +public extension UIView { + + enum AnchorType { + case all + case top + case topToBottom + case bottom + case bottomToTop + case leading + case trailing + case heigth + case width + case centerX + case centerY + case leadingToTrailing + case leadingToTrailingGreaterThanOrEqualTo + case trailingToLeading + case trailingToLeadingGreaterThanOrEqualTo + case topGreaterThanOrEqualTo + case bottomGreaterThanOrEqualTo + case bottomLessThanOrEqualTo + case topToTopGreaterThanOrEqualTo + case left + case right + } + + static var className: String { + return String(describing: self) + } + + var viewReference: UIView? { + get { + kvo_viewReference + } + set { + kvo_viewReference = newValue + } + } + + /// First width constraint for this view. + var widthConstraint: NSLayoutConstraint? { + findConstraint(attribute: .width, for: self) + } + + /// First height constraint for this view. + var heightConstraint: NSLayoutConstraint? { + findConstraint(attribute: .height, for: self) + } + + /// First leading constraint for this view. + var leadingConstraint: NSLayoutConstraint? { + findConstraint(attribute: .leading, for: self) + } + + /// First trailing constraint for this view. + var trailingConstraint: NSLayoutConstraint? { + findConstraint(attribute: .trailing, for: self) + } + + /// First top constraint for this view. + var topConstraint: NSLayoutConstraint? { + findConstraint(attribute: .top, for: self) + } + + /// First bottom constraint for this view. + var bottomConstraint: NSLayoutConstraint? { + findConstraint(attribute: .bottom, for: self) + } + + var centerYConstraints: NSLayoutConstraint? { + findConstraint(attribute: .centerY, for: self) + } + + var centerXConstraints: NSLayoutConstraint? { + findConstraint(attribute: .centerX, for: self) + } + + var globalPoint: CGPoint? { + return self.superview?.convert(self.frame.origin, to: nil) + } + + var globalFrame: CGRect? { + return self.superview?.convert(self.frame, to: nil) + } + + /// Loverde Co: Border color of view + var borderColor: UIColor? { + get { + guard let color = layer.borderColor else { return nil } + return UIColor(cgColor: color) + } + set { + guard let color = newValue else { + layer.borderColor = nil + return + } + // Fix React-Native conflict issue + guard String(describing: type(of: color)) != "__NSCFType" else { return } + layer.borderColor = color.cgColor + } + } + + /// Loverde Co: Border width of view; + var borderWidth: CGFloat { + get { + return layer.borderWidth + } + set { + layer.borderWidth = newValue + } + } + + /// Loverde Co: Corner radius of view; + var cornerRadius: CGFloat { + get { + return layer.cornerRadius + } + set { + layer.masksToBounds = true + layer.cornerRadius = newValue + } + } + + /// Loverde Co: Check if view is in RTL format. + var isRightToLeft: Bool { + if #available(iOS 10.0, *, tvOS 10.0, *) { + return effectiveUserInterfaceLayoutDirection == .rightToLeft + } else { + return false + } + } + + /// Loverde Co: Take screenshot of view (if applicable). + var screenshot: UIImage? { + UIGraphicsBeginImageContextWithOptions(layer.frame.size, false, 0) + defer { + UIGraphicsEndImageContext() + } + guard let context = UIGraphicsGetCurrentContext() else { return nil } + layer.render(in: context) + return UIGraphicsGetImageFromCurrentImageContext() + } + + /// Get view's parent view controller + var parentViewController: UIViewController? { + weak var parentResponder: UIResponder? = self + while parentResponder != nil { + parentResponder = parentResponder!.next + if let viewController = parentResponder as? UIViewController { + return viewController + } + } + return nil + } + + /// Loverde Co: Get view's absolute position + var absolutePosition: CGRect { + if #available(iOS 15, *), let window = UIApplication.shared.connectedScenes.compactMap({ ($0 as? UIWindowScene)?.keyWindow }).last { + return self.convert(self.bounds, to: window) + } else if #available(iOS 13, *), let window = UIApplication.shared.windows.filter({ $0.isKeyWindow }).first { + return self.convert(self.bounds, to: window) + } else if let window = UIApplication.shared.keyWindow { + return self.convert(self.bounds, to: window) + } + return .zero + } + + /// Loverde Co: transform UIView to UIImage + func asImage() -> UIImage { + if #available(iOS 10.0, *) { + let renderer = UIGraphicsImageRenderer(bounds: bounds) + return renderer.image { rendererContext in + layer.render(in: rendererContext.cgContext) + } + } else { + UIGraphicsBeginImageContext(self.frame.size) + self.layer.render(in:UIGraphicsGetCurrentContext()!) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return UIImage(cgImage: image!.cgImage!) + } + } + + func addSubview(_ subview: UIView, translatesAutoresizingMaskIntoConstraints: Bool = false) { + addSubview(subview) + subview.translatesAutoresizingMaskIntoConstraints = translatesAutoresizingMaskIntoConstraints + } + + func addSubviews(_ subviews: [UIView], translatesAutoresizingMaskIntoConstraints: Bool = false) { + for subview in subviews { + self.addSubview(subview, translatesAutoresizingMaskIntoConstraints: translatesAutoresizingMaskIntoConstraints) + } + } + + /// Returns all the subviews of a given type recursively in the + /// view hierarchy rooted on the view it its called. + /// + /// - Parameter ofType: Class of the view to search. + /// - Returns: All subviews with a specified type. + func subviews(ofType _: T.Type) -> [T] { + var views = [T]() + for subview in subviews { + if let view = subview as? T { + views.append(view) + } else if !subview.subviews.isEmpty { + views.append(contentsOf: subview.subviews(ofType: T.self)) + } + } + return views + } + + func findAView(_ ofType: T.Type) -> T? { + if let finded = subviews.first(where: { $0 is T }) as? T { + return finded + } else { + for view in subviews { + return view.findAView(ofType) + } + } + return nil + } + + @discardableResult + func setHeight(size: CGFloat) -> Self { + heightAnchor.constraint(equalToConstant: size).isActive = true + return self + } + + @discardableResult + func setHeight(min: CGFloat) -> Self { + heightAnchor.constraint(greaterThanOrEqualToConstant: min).isActive = true + return self + } + + @discardableResult + func setWidth(size: CGFloat) -> Self { + widthAnchor.constraint(equalToConstant: size).isActive = true + return self + } + + @discardableResult + func setWidth(min: CGFloat) -> Self { + widthAnchor.constraint(greaterThanOrEqualToConstant: min).isActive = true + return self + } + + /// Search constraints until we find one for the given view + /// and attribute. This will enumerate ancestors since constraints are + /// always added to the common ancestor. + /// + /// - Parameter attribute: the attribute to find. + /// - Parameter at: the view to find. + /// - Returns: matching constraint. + func findConstraint(attribute: NSLayoutConstraint.Attribute, for view: UIView) -> NSLayoutConstraint? { + let constraint = constraints.first { + ($0.firstAttribute == attribute && $0.firstItem as? UIView == view) || + ($0.secondAttribute == attribute && $0.secondItem as? UIView == view) + } + return constraint ?? superview?.findConstraint(attribute: attribute, for: view) + } + + func constraints(on anchor: NSLayoutYAxisAnchor) -> [NSLayoutConstraint] { + guard let superview = superview else { return [] } + return superview.constraints.filtered(view: self, anchor: anchor) + } + + func constraints(on anchor: NSLayoutXAxisAnchor) -> [NSLayoutConstraint] { + guard let superview = superview else { return [] } + return superview.constraints.filtered(view: self, anchor: anchor) + } + + func constraints(on anchor: NSLayoutDimension) -> [NSLayoutConstraint] { + guard let superview = superview else { return [] } + return constraints.filtered(view: self, anchor: anchor) + superview.constraints.filtered(view: self, anchor: anchor) + } + + func drawCircle(inCoord x: CGFloat, + y: CGFloat, + with radius: CGFloat, + strokeColor: UIColor = UIColor.red, + fillColor: UIColor = UIColor.gray, + isEmpty: Bool = false) -> [String:Any] { + + let dotPath = UIBezierPath(ovalIn: CGRect(x: x, y: y, width: radius, height: radius)) + let layer = CAShapeLayer() + if !isEmpty { + layer.path = dotPath.cgPath + layer.strokeColor = strokeColor.cgColor + layer.fillColor = fillColor.cgColor + self.layer.addSublayer(layer) + } + return ["path": dotPath,"layer": layer] + } + + func applyShadow(color: UIColor, offSet:CGSize, + radius: CGFloat, + opacity: Float, + shouldRasterize: Bool = true, + rasterizationScaleTo: CGFloat = UIScreen.main.scale){ + + self.layer.masksToBounds = false + self.layer.shadowColor = color.cgColor + self.layer.shadowOffset = offSet + self.layer.shadowRadius = radius + self.layer.shadowOpacity = opacity + self.layer.shouldRasterize = shouldRasterize + self.layer.rasterizationScale = rasterizationScaleTo + } + + func removeShadow(){ + self.layer.shadowColor = UIColor.clear.cgColor + self.layer.shadowOffset = CGSize.zero + self.layer.shadowRadius = 0.0 + self.layer.shadowOpacity = 0.0 + } + + /// Insert a blur in a view. Must insert on init of it + /// + /// - Parameter style: Blur styles available for blur effect objects. + /// - Parameter alpha: The view’s alpha value. + func insertBlurView(style: UIBlurEffect.Style, + color: UIColor = .black, + alpha: CGFloat = 0.9) { + self.backgroundColor = color + + let blurEffect = UIBlurEffect(style: style) + let blurEffectView = UIVisualEffectView(effect: blurEffect) + blurEffectView.frame = self.bounds + blurEffectView.alpha = alpha + + blurEffectView.autoresizingMask = [ + .flexibleWidth, .flexibleHeight + ] + + self.insertSubview(blurEffectView, at: 0) + } + + /** + Fade in a view with a duration + + - parameter duration: custom animation duration + */ + func fadeIn(withDuration duration: TimeInterval = 1.0, withDelay delay: TimeInterval = 0, completionHandler:@escaping (Bool) -> ()) { + UIView.animate(withDuration: duration, delay: delay, options: [], animations: { + self.alpha = 1.0 + }) { (finished) in + completionHandler(true) + } + } + + /** + Fade out a view with a duration + + - parameter duration: custom animation duration + */ + func fadeOut(withDuration duration: TimeInterval = 1.0, withDelay delay: TimeInterval = 0, completionHandler:@escaping (Bool) -> ()) { + UIView.animate(withDuration: duration, delay: delay, options: [], animations: { + self.alpha = 0.0 + }) { (finished) in + completionHandler(true) + } + } + + /** + Set x Position + + :param: x CGFloat + */ + func setX(x: CGFloat) { + var frame:CGRect = self.frame + frame.origin.x = x + self.frame = frame + } + /** + Set y Position + + :param: y CGFloat + */ + func setY(y: CGFloat) { + var frame:CGRect = self.frame + frame.origin.y = y + self.frame = frame + } + /** + Set Width + + :param: width CGFloat + */ + func setFrameWidth(width: CGFloat) { + var frame:CGRect = self.frame + frame.size.width = width + self.frame = frame + } + /** + Set Height + + :param: height CGFloat + */ + func setFrameHeight(height: CGFloat) { + var frame:CGRect = self.frame + frame.size.height = height + self.frame = frame + } + + func setWidth(_ toView: UIView? = nil, constant: CGFloat, _ multiplier: CGFloat = 0) -> Self { + if let toView = toView { + widthAnchor.constraint(equalTo: toView.widthAnchor, multiplier: multiplier, constant: constant).isActive = true + } else if multiplier == 0 { + widthAnchor.constraint(equalToConstant: constant).isActive = true + } + return self + } + + func setHeight(_ toView: UIView? = nil, constant: CGFloat, _ multiplier: CGFloat = 0) -> Self { + if let toView = toView { + heightAnchor.constraint(equalTo: toView.heightAnchor, multiplier: multiplier, constant: constant).isActive = true + } else if multiplier == 0 { + heightAnchor.constraint(equalToConstant: constant).isActive = true + } + return self + } + + // - LoverdeCo: Add radius to view + // + // + func setRadius(top: Bool, bottom: Bool, radius: CGFloat = 8){ + var maskCorns: CACornerMask = [] + var path: UIBezierPath! + if top && bottom { + maskCorns = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner] + path = UIBezierPath(roundedRect: (self.bounds), + byRoundingCorners: [.topRight, .topLeft, .bottomLeft, .bottomRight], + cornerRadii: CGSize(width: radius, height: radius)) + }else if top && !bottom { + maskCorns = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + path = UIBezierPath(roundedRect: (self.bounds), + byRoundingCorners: [.topRight, .topLeft], + cornerRadii: CGSize(width: radius, height: radius)) + }else if bottom && !top { + maskCorns = [.layerMaxXMaxYCorner, .layerMinXMaxYCorner] + path = UIBezierPath(roundedRect: (self.bounds), + byRoundingCorners: [.bottomLeft, .bottomRight], + cornerRadii: CGSize(width: radius, height: radius)) + + } + if #available(iOS 11.0, *) { + self.clipsToBounds = true + self.layer.cornerRadius = radius + self.layer.maskedCorners = maskCorns + } else { + self.clipsToBounds = true + let maskLayer = CAShapeLayer() + + maskLayer.path = path.cgPath + self.layer.mask = maskLayer + } + } + + /// Apply constraint to a View more easily. + /// + /// ```swift + /// MyView.setConstraintsTo(parentView: AnotherView, anchorType: AnchorType.leading, value: 10.0) + /// MyView.setConstraintsTo(anchorType: AnchorType.trailing, + /// value: -10.0) + /// .setConstraintsTo(parentView: SuperMegaView, + /// anchorType: AnchorType.bottomToTop, + /// value: -12.0) + /// ``` + /// + /// - Parameters: + /// - parentView: The View you whant to constraint to. + /// - anchorType: AnchorType you whant to apply" + /// - value: CGFloat value for that constraints to + /// - safeArea: Bool in case you whant top and bottom to be applied to Safe Area Layout Guide. Default is FALSE + /// - Returns: + /// All active constraints you apply. + /// + @discardableResult + func setConstraintsTo(parentView: UIView, anchorType: AnchorType, value: CGFloat, safeArea: Bool = false) -> Self { + self.viewReference = parentView + switch anchorType { + case .all: + let topAnchor: NSLayoutConstraint + if #available(iOS 11.0, *) { + topAnchor = self.topAnchor.constraint(equalTo: (safeArea ? parentView.safeAreaLayoutGuide.topAnchor : parentView.topAnchor), + constant: value) + topAnchor.identifier = "topAnchor" + topAnchor.isActive = true + } else { + topAnchor = self.topAnchor.constraint(equalTo: parentView.topAnchor, + constant: value) + topAnchor.identifier = "topAnchor" + topAnchor.isActive = true + } + var negativeValue = value + switch value { + case _ where value < 0: + break + case 0: + break + case _ where value > 0: + negativeValue = -negativeValue + default: + break + } + self.leadingAnchor.constraint(equalTo: parentView.leadingAnchor, + constant: value).isActive = true + self.trailingAnchor.constraint(equalTo: parentView.trailingAnchor, + constant: negativeValue).isActive = true + self.bottomAnchor.constraint(equalTo: parentView.bottomAnchor, + constant: negativeValue).isActive = true + case .top: + if #available(iOS 11.0, *) { + self.topAnchor.constraint(equalTo: (safeArea ? parentView.safeAreaLayoutGuide.topAnchor : parentView.topAnchor), + constant: value).isActive = true + } else { + self.topAnchor.constraint(equalTo: parentView.topAnchor, + constant: value).isActive = true + } + + case .topToBottom: + self.topAnchor.constraint(equalTo: parentView.bottomAnchor, + constant: value).isActive = true + + case .bottom: + self.bottomAnchor.constraint(equalTo: parentView.bottomAnchor, + constant: value).isActive = true + + case .bottomToTop: + self.bottomAnchor.constraint(equalTo: parentView.topAnchor, + constant: value).isActive = true + + case .leading: + self.leadingAnchor.constraint(equalTo: parentView.leadingAnchor, + constant: value).isActive = true + + case .leadingToTrailing: + self.leadingAnchor.constraint(equalTo: parentView.trailingAnchor, + constant: value).isActive = true + + case .leadingToTrailingGreaterThanOrEqualTo: + self.leadingAnchor.constraint(greaterThanOrEqualTo: parentView.trailingAnchor, + constant: value).isActive = true + + case .trailing: + self.trailingAnchor.constraint(equalTo: parentView.trailingAnchor, + constant: value).isActive = true + + case .trailingToLeading: + self.trailingAnchor.constraint(equalTo: parentView.leadingAnchor, + constant: value).isActive = true + + case .trailingToLeadingGreaterThanOrEqualTo: + self.trailingAnchor.constraint(greaterThanOrEqualTo: parentView.leadingAnchor, + constant: value).isActive = true + + case .heigth: + self.heightAnchor.constraint(equalTo: parentView.heightAnchor, + constant: value).isActive = true + + case .width: + self.widthAnchor.constraint(equalTo: parentView.widthAnchor, + constant: value).isActive = true + + case .centerX: + self.centerXAnchor.constraint(equalTo: parentView.centerXAnchor, + constant: value).isActive = true + + case .centerY: + self.centerYAnchor.constraint(equalTo: parentView.centerYAnchor, + constant: value).isActive = true + + case .topGreaterThanOrEqualTo: + self.topAnchor.constraint(greaterThanOrEqualTo: parentView.bottomAnchor, + constant: value).isActive = true + + case .topToTopGreaterThanOrEqualTo: + if #available(iOS 11.0, *) { + self.topAnchor.constraint(greaterThanOrEqualTo: (safeArea ? parentView.safeAreaLayoutGuide.topAnchor : parentView.topAnchor), + constant: value).isActive = true + } else { + self.topAnchor.constraint(greaterThanOrEqualTo: parentView.topAnchor, + constant: value).isActive = true + } + + case .bottomGreaterThanOrEqualTo: + self.bottomAnchor.constraint(greaterThanOrEqualTo: parentView.bottomAnchor, + constant: value).isActive = true + + case .bottomLessThanOrEqualTo: + self.bottomAnchor.constraint(lessThanOrEqualTo: parentView.bottomAnchor, + constant: value).isActive = true + + case .left: + self.leftAnchor.constraint(equalTo: parentView.leftAnchor, + constant: value).isActive = true + + case .right: + self.rightAnchor.constraint(equalTo: parentView.rightAnchor, + constant: value).isActive = true + } + return self + } + + @discardableResult + func setConstraintsTo(_ parentView: UIView, + _ anchorType: AnchorType, + _ value: CGFloat, + _ safeArea: Bool = false) -> Self { + + return self.setConstraintsTo(parentView: parentView, + anchorType: anchorType, + value: value, + safeArea: safeArea) + } + + @discardableResult + func setConstraintsTo(anchorType: AnchorType, value: CGFloat, safeArea: Bool = false) -> UIView { + guard let currentView = self.viewReference + else { fatalError("Ops! Faltou o parentView. Utilize setConstraintsTo(parentView... primeiro.") } + self.setConstraintsTo(parentView: currentView, anchorType: anchorType, value: value, safeArea: safeArea) + return self + } + + @discardableResult + func setConstraints(_ anchorType: AnchorType, _ value: CGFloat, _ safeArea: Bool = false) -> UIView { + return self.setConstraintsTo(anchorType: anchorType, + value: value, + safeArea: safeArea) + } + + func setConstraints(_ toScrollView: UIScrollView, direction: UICollectionView.ScrollDirection = .vertical) { + if self is UIScrollView { fatalError("You cannot apply this to self ScrollView.") } + self.setConstraintsTo(toScrollView, .top, 0) + .setConstraints(.leading, 0) + .setConstraints(.trailing, 0) + .setConstraints(.bottom, 0) + + let widthConstraint = widthAnchor.constraint(equalTo: toScrollView.widthAnchor) + if direction == .horizontal { + widthConstraint.priority = UILayoutPriority(250.0) + } + widthConstraint.isActive = true + + let heigthConstraint = heightAnchor.constraint(equalTo: toScrollView.heightAnchor) + if direction == .vertical { + heigthConstraint.priority = UILayoutPriority(250.0) + } + heigthConstraint.isActive = true + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UIViewController.swift b/Sources/LCEssentials/Extensions/LCEssentials+UIViewController.swift new file mode 100644 index 0000000..7f95e87 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UIViewController.swift @@ -0,0 +1,172 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +#if os(iOS) || os(macOS) +import UIKit +import QuartzCore + +public enum ToastPosition { + case top + case down +} + +public extension UIViewController { + + var isVisible: Bool { + // http://stackoverflow.com/questions/2777438/how-to-tell-if-uiviewcontrollers-view-is-visible + return isViewLoaded && view.window != nil + } + + var isLoaded: Bool { + return isVisible + } + + var isModal: Bool { + if let index = navigationController?.viewControllers.firstIndex(of: self), index > 0 { + return false + } else if presentingViewController != nil { + return true + } else if navigationController?.presentingViewController?.presentedViewController == navigationController { + return true + } else if tabBarController?.presentingViewController is UITabBarController { + return true + } else { + return false + } + } + + @objc func dismissSystemKeyboard(_ sender: UITapGestureRecognizer) { + if sender.state == .ended { + self.view.endEditing(true) + } + sender.cancelsTouchesInView = false + } + + static var identifier: String { + return "id"+className + } + static var segueID: String { + return "idSegue"+className + } + static var className: String { + return String(describing: self) + } + + static func instantiate(storyBoard: String, identifier: String? = nil, bundle: Bundle? = Bundle(for: T.self)) -> T { + let storyboard = UIStoryboard(name: storyBoard, bundle: bundle) + var identf = T.identifier + if let id = identifier { + identf = id + } + let controller = storyboard.instantiateViewController(withIdentifier: identf) as! T + return controller + } + + static func instatiate(nibName: String, bundle: Bundle? = nil) -> T { + let controller = T(nibName: nibName, bundle: bundle) + controller.awakeFromNib() + controller.view.updateConstraints() + controller.view.layoutIfNeeded() + return controller + } + + func present(viewControllerToPresent: UIViewController, completion: @escaping ()->()) { + CATransaction.begin() + self.present(viewControllerToPresent, animated: true) { + CATransaction.setCompletionBlock(completion) + } + CATransaction.commit() + } + + func closeController(jumpToController: UIViewController? = nil, completion: @escaping ()->()){ + if self.isModal { + self.dismiss(animated: true) { + completion() + } + return + } + guard let jump = jumpToController else { + self.navigationController?.popViewController(animated: true, { + completion() + }) + return + } + CATransaction.begin() + CATransaction.setCompletionBlock(completion) + self.navigationController?.popToViewController(jump, animated: true) + CATransaction.commit() + + } + + /// - Parameters: + /// - name: notification name. + /// - selector: selector to run with notified. + func addNotificationObserver(name: Notification.Name, selector: Selector) { + NotificationCenter.default.addObserver(self, selector: selector, name: name, object: nil) + } + + /// + /// - Parameter name: notification name. + func removeNotificationObserver(name: Notification.Name) { + NotificationCenter.default.removeObserver(self, name: name, object: nil) + } + + /// Unassign as listener from all notifications. + func removeNotificationsObserver() { + NotificationCenter.default.removeObserver(self) + } + + func show(toastWith message: String, + font: UIFont = UIFont.systemFont(ofSize: 12), + toastPosition: ToastPosition, + backgroundColor: UIColor = .black, + textColor: UIColor = .white, + duration: TimeInterval = 3.0) { + + let yPostition = toastPosition == .top ? (UIDevice.hasNotch ? 44 : 24) : self.view.frame.size.height - 44 - 16//margin + + let frame = CGRect(x: 30, + y: yPostition, + width: UIScreen.main.bounds.width - 60, + height: 44) + + let toast = UILabel(frame: frame) + toast.backgroundColor = backgroundColor.withAlphaComponent(0.7) + toast.textColor = textColor + toast.textAlignment = .center; + toast.font = font + toast.text = message + toast.alpha = 1.0 + toast.layer.cornerRadius = 10; + toast.clipsToBounds = true + self.view.addSubview(toast) + + UIView.animate(withDuration: duration, delay: 4, options: .curveEaseInOut, animations: { + toast.alpha = 0.0 + }, completion: {(isCompleted) in + toast.removeFromSuperview() + }) + } +} +#endif diff --git a/Sources/LCEssentials/Extensions/LCEssentials+URL.swift b/Sources/LCEssentials/Extensions/LCEssentials+URL.swift new file mode 100644 index 0000000..231bf41 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+URL.swift @@ -0,0 +1,36 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +public extension URL { + var params: [String: String] { + get { + let urlComponents = URLComponents(url: self, resolvingAgainstBaseURL: false) + var items = [String: String]() + for item in urlComponents?.queryItems ?? [] { + items[item.name] = item.value ?? "" + } + return items + } + } +} diff --git a/Sources/LCEssentials/Extensions/LCEssentials+UserDefaults.swift b/Sources/LCEssentials/Extensions/LCEssentials+UserDefaults.swift new file mode 100644 index 0000000..ab44722 --- /dev/null +++ b/Sources/LCEssentials/Extensions/LCEssentials+UserDefaults.swift @@ -0,0 +1,93 @@ +// +// Copyright (c) 2018 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +@MainActor +public extension UserDefaults { + + enum UserDefaultsKeys: String { + case isLoggedIn + case isFirstTimeOnApp + } + + var isLoggedIn: Bool { + set { + set(newValue, forKey: UserDefaultsKeys.isLoggedIn.rawValue) + synchronize() + } + get { bool(forKey: UserDefaultsKeys.isLoggedIn.rawValue) } + } + + var isFirstTimeOnApp: Bool { + set{ + set(newValue, forKey: UserDefaultsKeys.isFirstTimeOnApp.rawValue) + synchronize() + } + get{ bool(forKey: UserDefaultsKeys.isFirstTimeOnApp.rawValue) } + } + + /// Retrieves a Codable object from UserDefaults. + /// + /// - Parameters: + /// - type: Class that conforms to the Codable protocol. + /// - key: Identifier of the object. + /// - decoder: Custom JSONDecoder instance. Defaults to `JSONDecoder()`. + /// - Returns: Codable object for key (if exists). + func object(_ type: T.Type, with key: String, usingDecoder decoder: JSONDecoder = JSONDecoder()) -> T? { + guard let data = value(forKey: key) as? Data else { return nil } + return try? decoder.decode(type.self, from: data) + } + + /// Allows storing of Codable objects to UserDefaults. + /// + /// - Parameters: + /// - object: Codable object to store. + /// - key: Identifier of the object. + /// - encoder: Custom JSONEncoder instance. Defaults to `JSONEncoder()`. + func set(object: T, forKey key: String, usingEncoder encoder: JSONEncoder = JSONEncoder()) { + let data = try? encoder.encode(object) + set(data, forKey: key) + synchronize() + } + + func removeSavedObject(forKey: String) -> Bool { + if object(forKey: forKey) is String { + DispatchQueue.main.async { + self.removeObject(forKey: forKey) + self.synchronize() + } + return true + } + return false + } + + func removeAllSaved() { + let domain = Bundle.main.bundleIdentifier ?? "" + UserDefaults.standard.removePersistentDomain(forName: domain) + UserDefaults.standard.synchronize() + } + + func showEverything() -> [String: Any] { + return UserDefaults.standard.dictionaryRepresentation() + } +} diff --git a/Sources/LCEssentials/Extensions/RIPEMD160+Extension.swift b/Sources/LCEssentials/Extensions/RIPEMD160+Extension.swift new file mode 100644 index 0000000..ec0e845 --- /dev/null +++ b/Sources/LCEssentials/Extensions/RIPEMD160+Extension.swift @@ -0,0 +1,393 @@ +// +// Copyright (c) 2024 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation + +public struct RIPEMD_160 { + + private var MDbuf: (UInt32, UInt32, UInt32, UInt32, UInt32) + private var buffer: Data + private var count: Int64 // Total # of bytes processed. + + private init() { + MDbuf = (0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0) + buffer = Data() + count = 0 + } + + private mutating func compress(_ X: UnsafePointer) { + + // *** Helper functions (originally macros in rmd160.h) *** + + /* ROL(x, n) cyclically rotates x over n bits to the left */ + /* x must be of an unsigned 32 bits type and 0 <= n < 32. */ + func ROL(_ x: UInt32, _ n: UInt32) -> UInt32 { + return (x << n) | ( x >> (32 - n)) + } + + /* the five basic functions F(), G() and H() */ + + func F(_ x: UInt32, _ y: UInt32, _ z: UInt32) -> UInt32 { + return x ^ y ^ z + } + + func G(_ x: UInt32, _ y: UInt32, _ z: UInt32) -> UInt32 { + return (x & y) | (~x & z) + } + + func H(_ x: UInt32, _ y: UInt32, _ z: UInt32) -> UInt32 { + return (x | ~y) ^ z + } + + func I(_ x: UInt32, _ y: UInt32, _ z: UInt32) -> UInt32 { + return (x & z) | (y & ~z) + } + + func J(_ x: UInt32, _ y: UInt32, _ z: UInt32) -> UInt32 { + return x ^ (y | ~z) + } + + /* the ten basic operations FF() through III() */ + + func FF(_ a: inout UInt32, _ b: UInt32, _ c: inout UInt32, _ d: UInt32, _ e: UInt32, _ x: UInt32, _ s: UInt32) { + a = a &+ F(b, c, d) &+ x + a = ROL(a, s) &+ e + c = ROL(c, 10) + } + + func GG(_ a: inout UInt32, _ b: UInt32, _ c: inout UInt32, _ d: UInt32, _ e: UInt32, _ x: UInt32, _ s: UInt32) { + a = a &+ G(b, c, d) &+ x &+ 0x5a827999 + a = ROL(a, s) &+ e + c = ROL(c, 10) + } + + func HH(_ a: inout UInt32, _ b: UInt32, _ c: inout UInt32, _ d: UInt32, _ e: UInt32, _ x: UInt32, _ s: UInt32) { + a = a &+ H(b, c, d) &+ x &+ 0x6ed9eba1 + a = ROL(a, s) &+ e + c = ROL(c, 10) + } + + func II(_ a: inout UInt32, _ b: UInt32, _ c: inout UInt32, _ d: UInt32, _ e: UInt32, _ x: UInt32, _ s: UInt32) { + a = a &+ I(b, c, d) &+ x &+ 0x8f1bbcdc + a = ROL(a, s) &+ e + c = ROL(c, 10) + } + + func JJ(_ a: inout UInt32, _ b: UInt32, _ c: inout UInt32, _ d: UInt32, _ e: UInt32, _ x: UInt32, _ s: UInt32) { + a = a &+ J(b, c, d) &+ x &+ 0xa953fd4e + a = ROL(a, s) &+ e + c = ROL(c, 10) + } + + func FFF(_ a: inout UInt32, _ b: UInt32, _ c: inout UInt32, _ d: UInt32, _ e: UInt32, _ x: UInt32, _ s: UInt32) { + a = a &+ F(b, c, d) &+ x + a = ROL(a, s) &+ e + c = ROL(c, 10) + } + + func GGG(_ a: inout UInt32, _ b: UInt32, _ c: inout UInt32, _ d: UInt32, _ e: UInt32, _ x: UInt32, _ s: UInt32) { + a = a &+ G(b, c, d) &+ x &+ 0x7a6d76e9 + a = ROL(a, s) &+ e + c = ROL(c, 10) + } + + func HHH(_ a: inout UInt32, _ b: UInt32, _ c: inout UInt32, _ d: UInt32, _ e: UInt32, _ x: UInt32, _ s: UInt32) { + a = a &+ H(b, c, d) &+ x &+ 0x6d703ef3 + a = ROL(a, s) &+ e + c = ROL(c, 10) + } + + func III(_ a: inout UInt32, _ b: UInt32, _ c: inout UInt32, _ d: UInt32, _ e: UInt32, _ x: UInt32, _ s: UInt32) { + a = a &+ I(b, c, d) &+ x &+ 0x5c4dd124 + a = ROL(a, s) &+ e + c = ROL(c, 10) + } + + func JJJ(_ a: inout UInt32, _ b: UInt32, _ c: inout UInt32, _ d: UInt32, _ e: UInt32, _ x: UInt32, _ s: UInt32) { + a = a &+ J(b, c, d) &+ x &+ 0x50a28be6 + a = ROL(a, s) &+ e + c = ROL(c, 10) + } + + // *** The function starts here *** + + var (aa, bb, cc, dd, ee) = MDbuf + var (aaa, bbb, ccc, ddd, eee) = MDbuf + + /* round 1 */ + FF(&aa, bb, &cc, dd, ee, X[ 0], 11) + FF(&ee, aa, &bb, cc, dd, X[ 1], 14) + FF(&dd, ee, &aa, bb, cc, X[ 2], 15) + FF(&cc, dd, &ee, aa, bb, X[ 3], 12) + FF(&bb, cc, &dd, ee, aa, X[ 4], 5) + FF(&aa, bb, &cc, dd, ee, X[ 5], 8) + FF(&ee, aa, &bb, cc, dd, X[ 6], 7) + FF(&dd, ee, &aa, bb, cc, X[ 7], 9) + FF(&cc, dd, &ee, aa, bb, X[ 8], 11) + FF(&bb, cc, &dd, ee, aa, X[ 9], 13) + FF(&aa, bb, &cc, dd, ee, X[10], 14) + FF(&ee, aa, &bb, cc, dd, X[11], 15) + FF(&dd, ee, &aa, bb, cc, X[12], 6) + FF(&cc, dd, &ee, aa, bb, X[13], 7) + FF(&bb, cc, &dd, ee, aa, X[14], 9) + FF(&aa, bb, &cc, dd, ee, X[15], 8) + + /* round 2 */ + GG(&ee, aa, &bb, cc, dd, X[ 7], 7) + GG(&dd, ee, &aa, bb, cc, X[ 4], 6) + GG(&cc, dd, &ee, aa, bb, X[13], 8) + GG(&bb, cc, &dd, ee, aa, X[ 1], 13) + GG(&aa, bb, &cc, dd, ee, X[10], 11) + GG(&ee, aa, &bb, cc, dd, X[ 6], 9) + GG(&dd, ee, &aa, bb, cc, X[15], 7) + GG(&cc, dd, &ee, aa, bb, X[ 3], 15) + GG(&bb, cc, &dd, ee, aa, X[12], 7) + GG(&aa, bb, &cc, dd, ee, X[ 0], 12) + GG(&ee, aa, &bb, cc, dd, X[ 9], 15) + GG(&dd, ee, &aa, bb, cc, X[ 5], 9) + GG(&cc, dd, &ee, aa, bb, X[ 2], 11) + GG(&bb, cc, &dd, ee, aa, X[14], 7) + GG(&aa, bb, &cc, dd, ee, X[11], 13) + GG(&ee, aa, &bb, cc, dd, X[ 8], 12) + + /* round 3 */ + HH(&dd, ee, &aa, bb, cc, X[ 3], 11) + HH(&cc, dd, &ee, aa, bb, X[10], 13) + HH(&bb, cc, &dd, ee, aa, X[14], 6) + HH(&aa, bb, &cc, dd, ee, X[ 4], 7) + HH(&ee, aa, &bb, cc, dd, X[ 9], 14) + HH(&dd, ee, &aa, bb, cc, X[15], 9) + HH(&cc, dd, &ee, aa, bb, X[ 8], 13) + HH(&bb, cc, &dd, ee, aa, X[ 1], 15) + HH(&aa, bb, &cc, dd, ee, X[ 2], 14) + HH(&ee, aa, &bb, cc, dd, X[ 7], 8) + HH(&dd, ee, &aa, bb, cc, X[ 0], 13) + HH(&cc, dd, &ee, aa, bb, X[ 6], 6) + HH(&bb, cc, &dd, ee, aa, X[13], 5) + HH(&aa, bb, &cc, dd, ee, X[11], 12) + HH(&ee, aa, &bb, cc, dd, X[ 5], 7) + HH(&dd, ee, &aa, bb, cc, X[12], 5) + + /* round 4 */ + II(&cc, dd, &ee, aa, bb, X[ 1], 11) + II(&bb, cc, &dd, ee, aa, X[ 9], 12) + II(&aa, bb, &cc, dd, ee, X[11], 14) + II(&ee, aa, &bb, cc, dd, X[10], 15) + II(&dd, ee, &aa, bb, cc, X[ 0], 14) + II(&cc, dd, &ee, aa, bb, X[ 8], 15) + II(&bb, cc, &dd, ee, aa, X[12], 9) + II(&aa, bb, &cc, dd, ee, X[ 4], 8) + II(&ee, aa, &bb, cc, dd, X[13], 9) + II(&dd, ee, &aa, bb, cc, X[ 3], 14) + II(&cc, dd, &ee, aa, bb, X[ 7], 5) + II(&bb, cc, &dd, ee, aa, X[15], 6) + II(&aa, bb, &cc, dd, ee, X[14], 8) + II(&ee, aa, &bb, cc, dd, X[ 5], 6) + II(&dd, ee, &aa, bb, cc, X[ 6], 5) + II(&cc, dd, &ee, aa, bb, X[ 2], 12) + + /* round 5 */ + JJ(&bb, cc, &dd, ee, aa, X[ 4], 9) + JJ(&aa, bb, &cc, dd, ee, X[ 0], 15) + JJ(&ee, aa, &bb, cc, dd, X[ 5], 5) + JJ(&dd, ee, &aa, bb, cc, X[ 9], 11) + JJ(&cc, dd, &ee, aa, bb, X[ 7], 6) + JJ(&bb, cc, &dd, ee, aa, X[12], 8) + JJ(&aa, bb, &cc, dd, ee, X[ 2], 13) + JJ(&ee, aa, &bb, cc, dd, X[10], 12) + JJ(&dd, ee, &aa, bb, cc, X[14], 5) + JJ(&cc, dd, &ee, aa, bb, X[ 1], 12) + JJ(&bb, cc, &dd, ee, aa, X[ 3], 13) + JJ(&aa, bb, &cc, dd, ee, X[ 8], 14) + JJ(&ee, aa, &bb, cc, dd, X[11], 11) + JJ(&dd, ee, &aa, bb, cc, X[ 6], 8) + JJ(&cc, dd, &ee, aa, bb, X[15], 5) + JJ(&bb, cc, &dd, ee, aa, X[13], 6) + + /* parallel round 1 */ + JJJ(&aaa, bbb, &ccc, ddd, eee, X[ 5], 8) + JJJ(&eee, aaa, &bbb, ccc, ddd, X[14], 9) + JJJ(&ddd, eee, &aaa, bbb, ccc, X[ 7], 9) + JJJ(&ccc, ddd, &eee, aaa, bbb, X[ 0], 11) + JJJ(&bbb, ccc, &ddd, eee, aaa, X[ 9], 13) + JJJ(&aaa, bbb, &ccc, ddd, eee, X[ 2], 15) + JJJ(&eee, aaa, &bbb, ccc, ddd, X[11], 15) + JJJ(&ddd, eee, &aaa, bbb, ccc, X[ 4], 5) + JJJ(&ccc, ddd, &eee, aaa, bbb, X[13], 7) + JJJ(&bbb, ccc, &ddd, eee, aaa, X[ 6], 7) + JJJ(&aaa, bbb, &ccc, ddd, eee, X[15], 8) + JJJ(&eee, aaa, &bbb, ccc, ddd, X[ 8], 11) + JJJ(&ddd, eee, &aaa, bbb, ccc, X[ 1], 14) + JJJ(&ccc, ddd, &eee, aaa, bbb, X[10], 14) + JJJ(&bbb, ccc, &ddd, eee, aaa, X[ 3], 12) + JJJ(&aaa, bbb, &ccc, ddd, eee, X[12], 6) + + /* parallel round 2 */ + III(&eee, aaa, &bbb, ccc, ddd, X[ 6], 9) + III(&ddd, eee, &aaa, bbb, ccc, X[11], 13) + III(&ccc, ddd, &eee, aaa, bbb, X[ 3], 15) + III(&bbb, ccc, &ddd, eee, aaa, X[ 7], 7) + III(&aaa, bbb, &ccc, ddd, eee, X[ 0], 12) + III(&eee, aaa, &bbb, ccc, ddd, X[13], 8) + III(&ddd, eee, &aaa, bbb, ccc, X[ 5], 9) + III(&ccc, ddd, &eee, aaa, bbb, X[10], 11) + III(&bbb, ccc, &ddd, eee, aaa, X[14], 7) + III(&aaa, bbb, &ccc, ddd, eee, X[15], 7) + III(&eee, aaa, &bbb, ccc, ddd, X[ 8], 12) + III(&ddd, eee, &aaa, bbb, ccc, X[12], 7) + III(&ccc, ddd, &eee, aaa, bbb, X[ 4], 6) + III(&bbb, ccc, &ddd, eee, aaa, X[ 9], 15) + III(&aaa, bbb, &ccc, ddd, eee, X[ 1], 13) + III(&eee, aaa, &bbb, ccc, ddd, X[ 2], 11) + + /* parallel round 3 */ + HHH(&ddd, eee, &aaa, bbb, ccc, X[15], 9) + HHH(&ccc, ddd, &eee, aaa, bbb, X[ 5], 7) + HHH(&bbb, ccc, &ddd, eee, aaa, X[ 1], 15) + HHH(&aaa, bbb, &ccc, ddd, eee, X[ 3], 11) + HHH(&eee, aaa, &bbb, ccc, ddd, X[ 7], 8) + HHH(&ddd, eee, &aaa, bbb, ccc, X[14], 6) + HHH(&ccc, ddd, &eee, aaa, bbb, X[ 6], 6) + HHH(&bbb, ccc, &ddd, eee, aaa, X[ 9], 14) + HHH(&aaa, bbb, &ccc, ddd, eee, X[11], 12) + HHH(&eee, aaa, &bbb, ccc, ddd, X[ 8], 13) + HHH(&ddd, eee, &aaa, bbb, ccc, X[12], 5) + HHH(&ccc, ddd, &eee, aaa, bbb, X[ 2], 14) + HHH(&bbb, ccc, &ddd, eee, aaa, X[10], 13) + HHH(&aaa, bbb, &ccc, ddd, eee, X[ 0], 13) + HHH(&eee, aaa, &bbb, ccc, ddd, X[ 4], 7) + HHH(&ddd, eee, &aaa, bbb, ccc, X[13], 5) + + /* parallel round 4 */ + GGG(&ccc, ddd, &eee, aaa, bbb, X[ 8], 15) + GGG(&bbb, ccc, &ddd, eee, aaa, X[ 6], 5) + GGG(&aaa, bbb, &ccc, ddd, eee, X[ 4], 8) + GGG(&eee, aaa, &bbb, ccc, ddd, X[ 1], 11) + GGG(&ddd, eee, &aaa, bbb, ccc, X[ 3], 14) + GGG(&ccc, ddd, &eee, aaa, bbb, X[11], 14) + GGG(&bbb, ccc, &ddd, eee, aaa, X[15], 6) + GGG(&aaa, bbb, &ccc, ddd, eee, X[ 0], 14) + GGG(&eee, aaa, &bbb, ccc, ddd, X[ 5], 6) + GGG(&ddd, eee, &aaa, bbb, ccc, X[12], 9) + GGG(&ccc, ddd, &eee, aaa, bbb, X[ 2], 12) + GGG(&bbb, ccc, &ddd, eee, aaa, X[13], 9) + GGG(&aaa, bbb, &ccc, ddd, eee, X[ 9], 12) + GGG(&eee, aaa, &bbb, ccc, ddd, X[ 7], 5) + GGG(&ddd, eee, &aaa, bbb, ccc, X[10], 15) + GGG(&ccc, ddd, &eee, aaa, bbb, X[14], 8) + + /* parallel round 5 */ + FFF(&bbb, ccc, &ddd, eee, aaa, X[12] , 8) + FFF(&aaa, bbb, &ccc, ddd, eee, X[15] , 5) + FFF(&eee, aaa, &bbb, ccc, ddd, X[10] , 12) + FFF(&ddd, eee, &aaa, bbb, ccc, X[ 4] , 9) + FFF(&ccc, ddd, &eee, aaa, bbb, X[ 1] , 12) + FFF(&bbb, ccc, &ddd, eee, aaa, X[ 5] , 5) + FFF(&aaa, bbb, &ccc, ddd, eee, X[ 8] , 14) + FFF(&eee, aaa, &bbb, ccc, ddd, X[ 7] , 6) + FFF(&ddd, eee, &aaa, bbb, ccc, X[ 6] , 8) + FFF(&ccc, ddd, &eee, aaa, bbb, X[ 2] , 13) + FFF(&bbb, ccc, &ddd, eee, aaa, X[13] , 6) + FFF(&aaa, bbb, &ccc, ddd, eee, X[14] , 5) + FFF(&eee, aaa, &bbb, ccc, ddd, X[ 0] , 15) + FFF(&ddd, eee, &aaa, bbb, ccc, X[ 3] , 13) + FFF(&ccc, ddd, &eee, aaa, bbb, X[ 9] , 11) + FFF(&bbb, ccc, &ddd, eee, aaa, X[11] , 11) + + /* combine results */ + MDbuf = (MDbuf.1 &+ cc &+ ddd, + MDbuf.2 &+ dd &+ eee, + MDbuf.3 &+ ee &+ aaa, + MDbuf.4 &+ aa &+ bbb, + MDbuf.0 &+ bb &+ ccc) + } + + mutating private func update(data: Data) { + data.withUnsafeBytes { (pointer) -> Void in + guard var ptr = pointer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return } + var length = data.count + var X = [UInt32](repeating: 0, count: 16) + + // Process remaining bytes from last call: + if buffer.count > 0 && buffer.count + length >= 64 { + let amount = 64 - buffer.count + buffer.append(ptr, count: amount) + buffer.withUnsafeBytes { _ = memcpy(&X, $0.baseAddress, 64) } + compress(X) + ptr += amount + length -= amount + } + // Process 64 byte chunks: + while length >= 64 { + memcpy(&X, ptr, 64) + compress(X) + ptr += 64 + length -= 64 + } + // Save remaining unprocessed bytes: + buffer = Data(bytes: ptr, count: length) + } + count += Int64(data.count) + } + + mutating private func finalize() -> Data { + var X = [UInt32](repeating: 0, count: 16) + /* append the bit m_n == 1 */ + buffer.append(0x80) + buffer.withUnsafeBytes { _ = memcpy(&X, $0.baseAddress, buffer.count) } + + if (count & 63) > 55 { + /* length goes to next block */ + compress(X) + X = [UInt32](repeating: 0, count: 16) + } + + /* append length in bits */ + let lswlen = UInt32(truncatingIfNeeded: count) + let mswlen = UInt32(UInt64(count) >> 32) + X[14] = lswlen << 3 + X[15] = (lswlen >> 29) | (mswlen << 3) + compress(X) + + var data = Data(count: 20) + data.withUnsafeMutableBytes { (pointer) -> Void in + let ptr = pointer.bindMemory(to: UInt32.self) + ptr[0] = MDbuf.0 + ptr[1] = MDbuf.1 + ptr[2] = MDbuf.2 + ptr[3] = MDbuf.3 + ptr[4] = MDbuf.4 + } + + buffer = Data() + + return data + } +} + +public extension RIPEMD_160 { + static func hash(_ message: Data) -> Data { + var md = RIPEMD_160() + md.update(data: message) + return md.finalize() + } +} diff --git a/Sources/LCEssentials/HUDAlert/HUDAlertController.swift b/Sources/LCEssentials/HUDAlert/HUDAlertController.swift new file mode 100644 index 0000000..8667a8e --- /dev/null +++ b/Sources/LCEssentials/HUDAlert/HUDAlertController.swift @@ -0,0 +1,271 @@ +// +// Copyright (c) 2020 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Foundation +import UIKit + +#if os(iOS) || os(macOS) +@objc public protocol HUDAlertViewControllerDelegate { + @objc optional func didOpen(alert: HUDAlertViewController) + @objc optional func didClose(alert: HUDAlertViewController) +} + +public enum HUDAlertActionType: Int { + case cancel = 0 + case normal = 1 + case destructive = 2 + case discrete = 3 + case green = 4 +} + +public class HUDAlertAction { + + public typealias CompletionBlock = () -> Void + + var title: String = "" + var tag: Int = 0 + var type: HUDAlertActionType = .cancel + var completionBlock: CompletionBlock? = nil + + public convenience init(title: String, type: HUDAlertActionType, _ completion: (() -> Void)? = nil) { + self.init() + // + self.title = title + self.type = type + self.completionBlock = completion + } +} + +public class HUDAlertViewController: UIViewController { + + fileprivate var viewController: UIViewController + + lazy var actions: [HUDAlertAction] = [] + + let greenColor: UIColor = UIColor(hex: "609c70") + let redColor: UIColor = UIColor(hex: "a32a2e") + let blueColor: UIColor = UIColor(hex: "2a50a8") + let greyColor: UIColor = UIColor(hex: "a6a6a6") + + public weak var delegate: HUDAlertViewControllerDelegate? + public var isLoadingAlert: Bool = true + + public var customView: HUDAlertView { + return (view as? HUDAlertView) ?? HUDAlertView() + } + + public init() { + self.viewController = UIViewController() + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required public init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func loadView() { + view = HUDAlertView() + } + + public override func viewDidLoad() { + super.viewDidLoad() + if isLoaded { + printLog(title: "VIEW DIDLOAD", msg: "IS LOADED", prettyPrint: true) + } + } + + public func setTitle(_ title: String = "", font: UIFont = UIFont.systemFont(ofSize: 16.0), color: UIColor = .black) { + self.customView.titleLabel.text = title + self.customView.titleLabel.font = font + self.customView.titleLabel.textColor = color + } + + public func setDescription(_ desc: String = "", font: UIFont = .systemFont(ofSize: 14.0), color: UIColor = .black) { + self.customView.descLabel.text = desc + self.customView.descLabel.font = font + self.customView.descLabel.textColor = color + } + + public func configureAlertWith(title: String, + description: String? = nil, + viewController: UIViewController? = nil, + actionButtons: [HUDAlertAction] = []) { + + if let viewController { + self.viewController = viewController + } else if let topViewController = LCEssentials.getTopViewController(aboveBars: true) { + self.viewController = topViewController + } else { + fatalError("Ops! No UIViewController was found.") + } + + if self.isLoadingAlert { + self.customView.rotatinProgress.progress = 0.8 + self.customView.rotatinProgress.heightConstraint?.constant = 40 + }else{ + self.customView.rotatinProgress.progress = 0 + self.customView.rotatinProgress.heightConstraint?.constant = 0 + } + + self.customView.containerView.cornerRadius = 8 + self.customView.titleLabel.text = title + self.customView.descLabel.text = description + self.actions.removeAll() + self.customView.stackButtons.removeAllArrangedSubviews() + + if !actionButtons.isEmpty { + + for (index, element) in actionButtons.enumerated() { + + lazy var button: UIButton = { + $0.isOpaque = true + $0.translatesAutoresizingMaskIntoConstraints = false + $0.setTitleForAllStates(element.title) + $0.tag = index + $0.isExclusiveTouch = true + $0.isUserInteractionEnabled = true + $0.cornerRadius = 5 + $0.setHeight(size: 47.0) + $0.addTarget(self, action: #selector(self.actionButton(sender:)), for: .touchUpInside) + return $0 + }(UIButton(type: .custom)) + + switch element.type { + case .cancel: + button.borderWidth = 1 + button.borderColor = self.blueColor + button.backgroundColor = .white + button.setTitleColorForAllStates(self.blueColor) + case .destructive: + button.borderWidth = 0 + button.borderColor = nil + button.backgroundColor = self.redColor + button.setTitleColorForAllStates(.white) + case .normal: + button.borderWidth = 0 + button.borderColor = nil + button.backgroundColor = self.blueColor + button.setTitleColorForAllStates(.white) + case .discrete: + button.borderWidth = 0 + button.borderColor = nil + button.backgroundColor = self.greyColor + button.setTitleColorForAllStates(.white) + case .green: + button.borderWidth = 0 + button.borderColor = nil + button.backgroundColor = self.greenColor + button.setTitleColorForAllStates(.white) + } + + self.customView.stackButtons.isHidden = false + self.customView.stackButtons.spacing = 10 + self.customView.stackButtons.heightConstraint?.isActive = false + self.customView.stackButtons.addArrangedSubview(button) + self.actions.append(element) + } + } else { + self.actions.removeAll() + self.customView.stackButtons.removeAllArrangedSubviews() + self.customView.stackButtons.spacing = 0 + self.customView.stackButtons.heightConstraint?.isActive = true + self.customView.stackButtons.heightConstraint?.constant = 0 + self.customView.stackButtons.isHidden = true + } + } + + @objc private func actionButton(sender: UIButton) { + self.actions[exist: sender.tag]?.completionBlock?() + self.closeAlert() + } + + public func showAlert(){ + if self.presentingViewController != nil { + self.closeAlert() + LCEssentials.backgroundThread(delay: 0.2, completion: { + self.showAlert() + }) + return + } + + self.modalTransitionStyle = .crossDissolve + self.modalPresentationStyle = .overFullScreen + + self.viewController.present(self, animated: false, completion: { + self.animateIn { + self.delegate?.didOpen?(alert: self) + } + }) + } + + public func closeAlert(){ + animateOut { + self.dismiss(animated: false) { + self.delegate?.didClose?(alert: self) + } + } + } + + private func animateIn(completion: @escaping() -> Void) { + + UIView.animate(withDuration: 0.1, delay: 0.0, options: [.allowUserInteraction, .allowAnimatedContent, .curveEaseOut], animations: { + self.view.alpha = 1.0 + }) { (completed) in + self.performSpringAnimationIn(for: self.customView.containerView) { + completion() + } + } + } + + private func animateOut(completion: @escaping() -> Void) { + + self.performSpringAnimationOut(for: self.customView.containerView) { + UIView.animate(withDuration: 0.1, delay: 0.0, options: [.allowUserInteraction, .allowAnimatedContent, .curveEaseIn], animations: { + self.view.alpha = 0.0 + }) { (completed) in + completion() + } + } + } + + private func performSpringAnimationIn(for view: UIView, completion: @escaping() -> Void) { + view.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + // + UIView.animate(withDuration: 0.15, delay: 0.0, usingSpringWithDamping: 0.5, initialSpringVelocity: 0.5, options: .curveEaseIn, animations: { + view.transform = CGAffineTransform(scaleX: 1, y: 1) + view.alpha = 1.0 + }) { (completed) in + completion() + } + } + + + private func performSpringAnimationOut(for view: UIView, completion: @escaping() -> Void) { + UIView.animate(withDuration: 0.15, animations: { + view.transform = CGAffineTransform(scaleX: 0.5, y: 0.5) + view.alpha = 0.0 + }) { (completed) in + completion() + } + } +} +#endif diff --git a/Sources/LCEssentials/HUDAlert/HUDAlertView.swift b/Sources/LCEssentials/HUDAlert/HUDAlertView.swift new file mode 100644 index 0000000..a661a21 --- /dev/null +++ b/Sources/LCEssentials/HUDAlert/HUDAlertView.swift @@ -0,0 +1,158 @@ +// +// Copyright (c) 2022 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +// MARK: - Framework headers +import UIKit + +// MARK: - Protocols + + +// MARK: - Interface Headers + + +// MARK: - Local Defines / ENUMS + + +// MARK: - Class + +public final class HUDAlertView: UIView { + + // MARK: - Private properties + + // MARK: - Internal properties + + lazy var containerView: UIView = { + $0.isOpaque = true + $0.backgroundColor = .white + $0.translatesAutoresizingMaskIntoConstraints = false + return $0 + }(UIView()) + + let rotatinProgress: RotatingCircularGradientProgressBar = { + $0.layer.masksToBounds = true + $0.color = .gray + $0.gradientColor = .gray + $0.ringWidth = 2 + $0.progress = 0 + return $0 + }(RotatingCircularGradientProgressBar()) + + lazy var titleLabel: UILabel = { + $0.font = UIFont.systemFont(ofSize: 22.0) + $0.textColor = .black + $0.backgroundColor = UIColor.clear + $0.numberOfLines = 0 + $0.textAlignment = .center + $0.isOpaque = true + $0.translatesAutoresizingMaskIntoConstraints = false + return $0 + }(UILabel()) + + lazy var descLabel: UILabel = { + $0.font = UIFont.systemFont(ofSize: 16.0) + $0.textColor = .black + $0.backgroundColor = UIColor.clear + $0.numberOfLines = 0 + $0.textAlignment = .center + $0.isOpaque = true + $0.translatesAutoresizingMaskIntoConstraints = false + return $0 + }(UILabel()) + + lazy var stackButtons: UIStackView = { + $0.axis = .vertical + $0.spacing = 10.0 + $0.distribution = .fill + $0.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + $0.isLayoutMarginsRelativeArrangement = true + $0.translatesAutoresizingMaskIntoConstraints = false + return $0 + }(UIStackView()) + + // MARK: - Public properties + + + // MARK: - Initializers + + public init() { + super.init(frame: .zero) + + insertBlurView(style: .dark, color: .clear, alpha: 0.8) + + addComponentsAndConstraints() + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: - Super Class Overrides + + + // MARK: - Private methods + + fileprivate func addComponentsAndConstraints() { + + // MARK: - Add Subviews + + containerView.addSubviews([rotatinProgress, titleLabel, descLabel, stackButtons]) + + addSubviews([containerView]) + + // MARK: - Add Constraints + + rotatinProgress + .setConstraintsTo(containerView, .top, 10) + .setConstraints(.centerX, 0) + rotatinProgress.setWidth(size: 40) + rotatinProgress.setHeight(size: 40) + + titleLabel + .setConstraintsTo(rotatinProgress, .topToBottom, 8) + .setConstraintsTo(containerView, .leading, 10) + .setConstraints(.trailing, -10) + + descLabel + .setConstraintsTo(titleLabel, .topToBottom, 8) + .setConstraintsTo(containerView, .leading, 10) + .setConstraints(.trailing, -10) + + stackButtons + .setConstraintsTo(descLabel, .topToBottom, 8) + .setConstraintsTo(containerView, .leading, 10) + .setConstraints(.leading, 10) + .setConstraints(.trailing, -10) + .setConstraints(.bottom, -8) + + containerView + .setConstraintsTo(self, .topToTopGreaterThanOrEqualTo, 20, true) + .setConstraints(.leading, 40) + .setConstraints(.trailing, -40) + .setConstraints(.centerX, 0) + .setConstraints(.centerY, 0) + } + + // MARK: - Internal methods +} + +// MARK: - Extensions diff --git a/Sources/LCEssentials/HUDAlert/Storyboard/dual_loading.gif b/Sources/LCEssentials/HUDAlert/Storyboard/dual_loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..db03e3cc05d56f9bfaf00efbbe3bf10977784c3f GIT binary patch literal 70033 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%0DpR%GdLFH0EQ*aKY*;J^v)+N7d;8R7J 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 =zcbddw}+%ZTyABwn!BA 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
    yak@97nKi!8* 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 Void in + if granted == true { + if self.isAlertOpen { + self.isAlertOpen = false + self.openImagePicker() + } + } else { + guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else { + return + } + if UIApplication.shared.canOpenURL(settingsUrl) { + if #available(iOS 10.0, *) { + UIApplication.shared.open(settingsUrl, completionHandler: { (success) in + }) + } + else { + UIApplication.shared.openURL(settingsUrl) + } + } + } + }) + } +} + +//MARK: - UIImagePicker Delegate + +extension ImagePickerController: UIImagePickerControllerDelegate { + + @objc public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + self.delegate?.imagePicker(didSelect: nil) + picker.dismiss(animated: true, completion: { + self.dismiss(animated: false, completion: nil) + }) + } + + @objc public func imagePickerController(_ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[UIImagePickerController.InfoKey.editedImage] as? UIImage { + self.delegate?.imagePicker(didSelect: image) + }else if let image = info[ UIImagePickerController.InfoKey.originalImage] as? UIImage { + self.delegate?.imagePicker(didSelect: image) + } + picker.dismiss(animated: true, completion: { + self.dismiss(animated: false, completion: nil) + }) + } +} +#endif diff --git a/Sources/LCEssentials/ImageZoom/ImageZoom.swift b/Sources/LCEssentials/ImageZoom/ImageZoom.swift new file mode 100644 index 0000000..0b8a2cb --- /dev/null +++ b/Sources/LCEssentials/ImageZoom/ImageZoom.swift @@ -0,0 +1,205 @@ +// +// Copyright (c) 2023 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import UIKit + +#if os(iOS) || os(macOS) +@objc public protocol ImageZoomControllerDelegate { + @objc optional func imageZoomController(controller: ImageZoomController, didZoom image: UIImage?) + @objc optional func imageZoomController(controller: ImageZoomController, didClose image: UIImage?) +} + +public class ImageZoomController: UIViewController { + + fileprivate lazy var blackView: UIView = { + $0.isOpaque = true + $0.translatesAutoresizingMaskIntoConstraints = false + $0.backgroundColor = UIColor.black + $0.alpha = 0.8 + return $0 + }(UIView()) + + fileprivate lazy var scrollView: UIScrollView = { + $0.isOpaque = true + $0.translatesAutoresizingMaskIntoConstraints = false + $0.delegate = self + return $0 + }(UIScrollView()) + + fileprivate lazy var closeButton: UIButton = { + $0.isOpaque = true + $0.translatesAutoresizingMaskIntoConstraints = false + if #available(iOS 13.0, *) { + $0.setImage(UIImage(systemName: "xmark"), for: .normal) + $0.tintColor = UIColor.white + } + $0.addTarget(self, action: #selector(self.close), for: .touchUpInside) + return $0 + }(UIButton(type: .custom)) + + lazy var imageView: UIImageView = { + $0.isOpaque = true + $0.translatesAutoresizingMaskIntoConstraints = false + $0.isUserInteractionEnabled = true + return $0 + }(UIImageView()) + + public var minimumZoomScale: CGFloat = 1.0 + public var maximumZoomScale: CGFloat = 6.0 + public var addGestureToDismiss: Bool = true + public weak var delegate: ImageZoomControllerDelegate? + + private var minimumVelocityToHide: CGFloat = 1500 + private var minimumScreenRatioToHide: CGFloat = 0.5 + private var animationDuration: TimeInterval = 0.2 + + + public init(_ withImage: UIImage) { + super.init(nibName: nil, bundle: nil) + + self.addComponentsAndConstraints() + + self.imageView.image = withImage + self.imageView.addAspectRatioConstraint() + + self.scrollView.minimumZoomScale = self.minimumZoomScale + self.scrollView.maximumZoomScale = self.maximumZoomScale + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if addGestureToDismiss { + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:))) + view.addGestureRecognizer(panGesture) + } + } + + fileprivate func addComponentsAndConstraints() { + + scrollView.addSubview(imageView, translatesAutoresizingMaskIntoConstraints: false) + view.addSubviews([blackView, scrollView, closeButton]) + + blackView + .setConstraintsTo(view, .all, 0, true) + + scrollView + .setConstraintsTo(view, .all, 0, true) + + imageView.setHeight(min: 200) + imageView + .setConstraintsTo(scrollView, .centerX, 0) + .setConstraints(.centerY, 0) + + closeButton + .setConstraintsTo(view, .leading, 20, true) + .setConstraints(.top, 0) + .setWidth(size: 50.0) + .setHeight(size: 50.0) + } + + public func present(completion: (()->())? = nil) { + guard let viewController = LCEssentials.getTopViewController(aboveBars: true) else { + fatalError("Ops! Look like it doesnt have a ViewController") + } + self.modalTransitionStyle = .coverVertical + self.modalPresentationStyle = .overFullScreen + viewController.present(self, animated: true) { + completion?() + } + } + + public func dismiss(completion: (()->())? = nil) { + self.dismiss(animated: true) { + completion?() + } + } + + @objc private func close(){ + delegate?.imageZoomController?(controller: self, didClose: self.imageView.image) + self.dismiss() + } + + private func slideViewVerticallyTo(_ y: CGFloat) { + self.view.frame.origin = CGPoint(x: 0, y: y) + } + + @objc private func onPan(_ panGesture: UIPanGestureRecognizer) { + + switch panGesture.state { + case .began, .changed: + // If pan started or is ongoing then + // slide the view to follow the finger + let translation = panGesture.translation(in: view) + let y = max(0, translation.y) + slideViewVerticallyTo(y) + + case .ended: + // If pan ended, decide it we should close or reset the view + // based on the final position and the speed of the gesture + let translation = panGesture.translation(in: view) + let velocity = panGesture.velocity(in: view) + let closing = (translation.y > self.view.frame.size.height * minimumScreenRatioToHide) || (velocity.y > minimumVelocityToHide) + if closing { + UIView.animate(withDuration: animationDuration, animations: { + // If closing, animate to the bottom of the view + self.slideViewVerticallyTo(self.view.frame.size.height) + }, completion: { (isCompleted) in + if isCompleted { + // Dismiss the view when it dissapeared + self.dismiss(animated: false, completion: nil) + } + }) + } else { + // If not closing, reset the view to the top + UIView.animate(withDuration: animationDuration, animations: { + self.slideViewVerticallyTo(0) + }) + } + + default: + // If gesture state is undefined, reset the view to the top + UIView.animate(withDuration: animationDuration, animations: { + self.slideViewVerticallyTo(0) + }) + } + } +} + +extension ImageZoomController: UIScrollViewDelegate { + + public func scrollViewDidZoom(_ scrollView: UIScrollView) { + delegate?.imageZoomController?(controller: self, didZoom: self.imageView.image) + } + + public func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return self.imageView + } +} +#endif diff --git a/Sources/LCEssentials/Message/LCSnackBarView.swift b/Sources/LCEssentials/Message/LCSnackBarView.swift new file mode 100644 index 0000000..387ef64 --- /dev/null +++ b/Sources/LCEssentials/Message/LCSnackBarView.swift @@ -0,0 +1,439 @@ +// +// Copyright (c) 2024 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// MARK: - Framework headers +import UIKit + +// MARK: - Protocols + +@objc +public protocol LCSnackBarViewDelegate { + @objc optional func snackbar(didStartExibition: LCSnackBarView) + @objc optional func snackbar(didTouchOn snackbar: LCSnackBarView) + @objc optional func snackbar(didEndExibition: LCSnackBarView) +} + +// MARK: - Interface Headers + + +// MARK: - Local Defines / ENUMS + +public enum LCSnackBarViewType { + case `default`, rounded +} + +public enum LCSnackBarOrientation { + case top, bottom +} + +public enum LCSnackBarTimer: CGFloat { + case infinity = 0 + case minimum = 2 + case medium = 5 + case maximum = 10 +} + +// MARK: - Class +/// LCSnackBarView is a simple SnackBar that you can display notifications in app to improve your app comunication +/// +/// Usage example: +/// +///```swift +///let notification = LCSnackBarView() +///notification +/// .configure(text: "Hello World!") +/// .present() +///``` +///You can set delegate to interact with it +/// +///```swift +///let notification = LCSnackBarView(delegate: self) +///notification +/// .configure(text: "Hello World!") +/// .present() +/// +///public func snackbar(didStartExibition: LCSnackBarView){} +///public func snackbar(didTouchOn snackbar: LCSnackBarView){} +///public func snackbar(didEndExibition: LCSnackBarView){} +///``` +public final class LCSnackBarView: UIView { + + // MARK: - Private properties + + private lazy var contentView: UIView = { + $0.backgroundColor = .white + $0.translatesAutoresizingMaskIntoConstraints = false + $0.isOpaque = true + return $0 + }(UIView()) + + private lazy var descriptionLabel: UILabel = { + $0.font = .systemFont(ofSize: 12, weight: .regular) + $0.text = nil + $0.textColor = .black + $0.backgroundColor = UIColor.clear + $0.numberOfLines = 0 + $0.lineBreakMode = .byWordWrapping + $0.textAlignment = .center + $0.isOpaque = true + $0.translatesAutoresizingMaskIntoConstraints = false + return $0 + }(UILabel()) + + private var _style: LCSnackBarViewType + private var originPositionX: CGFloat = 0.0 + private var originPositionY: CGFloat = 0.0 + private var _orientation: LCSnackBarOrientation + private var _timer: LCSnackBarTimer = .minimum + private var _radius: CGFloat = 4.0 + private var spacing: CGFloat = 20.0 + + private var _width: CGFloat = .zero + private var _height: CGFloat = .zero + + private lazy var systemKeyboardVisible = false + private lazy var isOpen = false + + // MARK: - Internal properties + + + // MARK: - Public properties + + public weak var delegate: LCSnackBarViewDelegate? + + // MARK: - Initializers + + public init( + style: LCSnackBarViewType = .default, + orientation: LCSnackBarOrientation = .top, + delegate: LCSnackBarViewDelegate? = nil + ) { + + self._style = style + self._orientation = orientation + self.delegate = delegate + + super.init(frame: .zero) + + setupDefaultLayout() + addComponentsAndConstraints() + setupGestureRecognizer() + setKeyboardObserver() + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + + // MARK: - Super Class Overrides + + public override func layoutSubviews() { + super.layoutSubviews() + } + + deinit { + NotificationCenter.default.removeObserver(self, name: nil, object: nil) + } +} + +// MARK: - Extensions + +public extension LCSnackBarView { + + // MARK: - Private methods + + private func setupDefaultLayout() { + backgroundColor = .white + contentView.backgroundColor = .white + clipsToBounds = true + } + + private func setupGestureRecognizer() { + let gesture = UITapGestureRecognizer(target: self, action: #selector(onTapGestureAction)) + gesture.numberOfTapsRequired = 1 + gesture.cancelsTouchesInView = false + addGestureRecognizer(gesture) + } + + private func setKeyboardObserver() { + // Show + NotificationCenter + .default + .addObserver( + self, + selector: #selector(self.keyboardWillShow(_:)), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + + // Hidde + NotificationCenter + .default + .addObserver( + self, + selector: #selector(self.keyboardWillHide(_:)), + name: UIResponder.keyboardWillHideNotification, + object: nil + ) + } + + @objc private func keyboardWillShow(_ notification: Notification?) -> Void { + + if let info = notification?.userInfo { + + systemKeyboardVisible = true + // + let curveUserInfoKey = UIResponder.keyboardAnimationCurveUserInfoKey + let durationUserInfoKey = UIResponder.keyboardAnimationDurationUserInfoKey + let frameEndUserInfoKey = UIResponder.keyboardFrameEndUserInfoKey + // + var animationCurve: UIView.AnimationOptions = .curveEaseOut + var animationDuration: TimeInterval = 0.25 + var height:CGFloat = 0.0 + + // Getting keyboard animation. + if let curve = info[curveUserInfoKey] as? UIView.AnimationOptions { + animationCurve = curve + } + + // Getting keyboard animation duration + if let duration = info[durationUserInfoKey] as? TimeInterval { + animationDuration = duration + } + + // Getting UIKeyboardSize. + if let kbFrame = info[frameEndUserInfoKey] as? CGRect { + height = kbFrame.size.height + } + + DispatchQueue.main.async { [weak self] in + UIView.animate(withDuration: animationDuration, + delay: 0, + options: animationCurve, + animations: { + self?.frame.origin.y += height + }) + } + } + } + + @objc private func keyboardWillHide(_ notification: Notification?) -> Void { + DispatchQueue.main.async { [weak self] in + self?.systemKeyboardVisible = false + // keyboard is hidded + } + } + + private func updateStyle() { + switch _style { + case .rounded: + _width = UIScreen.main.bounds.width - spacing + cornerRadius = _radius + originPositionX = 10 + default: + _width = UIScreen.main.bounds.width + cornerRadius = 0 + originPositionX = 0 + } + } + + private func positioningView(_ view: UIView) { + view + .addSubview(self, + translatesAutoresizingMaskIntoConstraints: true) + + switch _orientation { + case .bottom: + var bottomNotch = UIDevice.bottomNotch + if _style != .rounded { + bottomNotch = (1.25 * bottomNotch) + contentView.bottomConstraint?.isActive = false + } + _height = (descriptionLabel.lineNumbers().cgFloat * descriptionLabel.font.pointSize) + spacing + bottomNotch + originPositionY = UIScreen.main.bounds.height + default: + var topNotch = UIDevice.topNotch + if _style != .rounded { + topNotch = (1.25 * topNotch) + contentView.topConstraint?.isActive = false + } + _height = (descriptionLabel.lineNumbers().cgFloat * descriptionLabel.font.pointSize) + spacing + topNotch + originPositionY = -_height + } + + frame = CGRect(x: originPositionX, + y: originPositionY, + width: _width, + height: _height) + } + + private func showSnackBar(controller: UIViewController, completion: @escaping (() -> Void)) { + if isOpen { + closeSnackBar(controller: controller) { + self.showSnackBar(controller: controller, completion: completion) + } + return + } + isHidden = true + updateStyle() + positioningView(controller.view) + let distance = CGFloat(_style == .rounded ? (_orientation == .top ? UIDevice.topNotch : UIDevice.bottomNotch) : 0) + layoutIfNeeded() + UIView.animate(withDuration: 0.6, + delay: 0.6, + options: .curveEaseInOut) { [weak self] in + self?.layoutIfNeeded() + self?.isHidden = false + if self?._orientation == .top { + self?.frame.origin.y += (self?._height ?? 0) + distance + } else { + self?.frame.origin.y -= (self?._height ?? 0) + distance + } + } completion: { finished in + self.layoutIfNeeded() + completion() + self.isOpen = true + self.delegate?.snackbar?(didStartExibition: self) + guard self._timer != .infinity else { return } + LCEssentials.delay(milliseconds: self._timer.rawValue) { + self.closeSnackBar(controller: controller, completion: {}) + } + } + } + + private func closeSnackBar(controller: UIViewController, completion: @escaping (() -> Void)) { + let distance = CGFloat(_style == .rounded ? (_orientation == .top ? UIDevice.topNotch : UIDevice.bottomNotch) : 0) + layoutIfNeeded() + UIView.animate(withDuration: 0.6, + delay: 0.6, + options: .curveEaseInOut) { [weak self] in + self?.layoutIfNeeded() + if self?._orientation == .top { + self?.frame.origin.y -= (self?._height ?? 0) + distance + } else { + self?.frame.origin.y += (self?._height ?? 0) + distance + } + } completion: { finished in + self.layoutIfNeeded() + self.delegate?.snackbar?(didEndExibition: self) + self.isOpen = false + self.removeFromSuperview() + completion() + } + } + + @objc + private func onTapGestureAction(_ : UITapGestureRecognizer) { + self.delegate?.snackbar?(didTouchOn: self) + if let controller = LCEssentials.getTopViewController(aboveBars: true), + _timer == .infinity { + self.closeSnackBar(controller: controller, completion: {}) + } + } + + private func addComponentsAndConstraints() { + + // MARK: - Add Subviews + contentView.addSubviews([descriptionLabel]) + addSubviews([contentView]) + + // MARK: - Add Constraints + + contentView + .setConstraintsTo(self, .top, 10) + .setConstraints(.leading, 10) + .setConstraints(.trailing, -10) + .setConstraints(.bottom, -10) + + descriptionLabel + .setConstraintsTo(contentView, .top, 0) + .setConstraints(.leading, 0) + .setConstraints(.trailing, 0) + .setConstraints(.bottom, 0) + } +} + +public extension LCSnackBarView { + + // MARK: - Public methods + + @discardableResult + func configure(text: String) -> Self { + descriptionLabel.text = text + return self + } + + @discardableResult + func configure(textColor: UIColor) -> Self { + descriptionLabel.textColor = textColor + return self + } + + @discardableResult + func configure(textFont: UIFont, alignment: NSTextAlignment = .center) -> Self { + descriptionLabel.font = textFont + descriptionLabel.textAlignment = alignment + return self + } + + @discardableResult + func configure(backgroundColor: UIColor) -> Self { + self.backgroundColor = backgroundColor + contentView.backgroundColor = backgroundColor + return self + } + + @discardableResult + func configure(exibition timer: LCSnackBarTimer) -> Self { + _timer = timer + return self + } + + @discardableResult + func configure(imageIconBefore icon: UIImageView, withTintColor: UIColor? = nil) -> Self { + icon.setHeight(size: 24) + icon.setWidth(size: 24) + icon.contentMode = .scaleAspectFit + descriptionLabel.leadingConstraint?.constant = (icon.widthConstraint?.constant ?? 0) + 24 + if let withTintColor { + icon.image = icon.image?.withRenderingMode(.alwaysTemplate).tintImage(color: withTintColor) + } + + contentView + .addSubviews([icon]) + + icon + .setConstraintsTo(contentView, .leading, 10) + .setConstraintsTo(descriptionLabel, .centerY, 0) + + return self + } + + func present(completion: (()->())? = nil) { + if isOpen { return } + if let controller = LCEssentials.getTopViewController(aboveBars: true) { + showSnackBar(controller: controller) { + completion?() + } + } + } +} diff --git a/Sources/LCEssentials/Repositorio/Repositorio.swift b/Sources/LCEssentials/Repositorio/Repositorio.swift new file mode 100644 index 0000000..ae0ec13 --- /dev/null +++ b/Sources/LCEssentials/Repositorio/Repositorio.swift @@ -0,0 +1,4 @@ +struct Repositorio { + var text = "Hello, World!" + private var testBin: [Int] = [] +} diff --git a/Sources/LCEssentials/SwiftUI/LCENavigationView.swift b/Sources/LCEssentials/SwiftUI/LCENavigationView.swift new file mode 100644 index 0000000..c157694 --- /dev/null +++ b/Sources/LCEssentials/SwiftUI/LCENavigationView.swift @@ -0,0 +1,274 @@ +// +// Copyright (c) 2024 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +#if canImport(SwiftUI) +import SwiftUI + +@available(iOS 15, *) +class LCENavigationState: ObservableObject { + @Published var rightButtonImage: AnyView? = nil + @Published var rightButtonText: Text = Text("") + @Published var rightButtonAction: () -> Void = {} + + @Published var leftButtonImage: AnyView? = nil + @Published var leftButtonText: Text = Text("") + @Published var leftButtonAction: () -> Void = {} + + @Published var hideNavigationBar: Bool = false + + @Published var title: (any View) = Text("") + @Published var subTitle: (any View) = Text("") +} + +@available(iOS 15, *) +public struct LCENavigationView: View { + @ObservedObject private var state: LCENavigationState + + let content: Content + + public init( + title: (any View) = Text(""), + subTitle: (any View) = Text(""), + @ViewBuilder content: () -> Content + ) { + self.content = content() + self._state = ObservedObject( + wrappedValue: LCENavigationState() + ) + self.state.title = title + self.state.subTitle = subTitle + } + + public var body: some View { + VStack { + if !state.hideNavigationBar { + NavigationBarView + } + content + } + .navigationBarHidden(true) + } + + private var NavigationBarView: some View { + HStack { + NavLeftButton + Spacer() + TitleView + Spacer() + NavRightButton + } + .font(.headline) + .padding() + .background { + Color.clear.ignoresSafeArea(edges: .top) + } + } + + private var TitleView: some View { + VStack { + AnyView(state.title) + if (try? state.subTitle.getTag() ?? "hidden" ) != "hidden" { + AnyView(state.subTitle) + } + } + } + + private var NavLeftButton: some View { + Button(action: state.leftButtonAction) { + HStack { + if let image = state.leftButtonImage { + image + } + state.leftButtonText + } + } + } + + private var NavRightButton: some View { + Button(action: state.rightButtonAction) { + HStack { + state.rightButtonText + if let image = state.rightButtonImage { + image + } + } + } + } + + public func setRightButton( + text: Text = Text(""), + image: (any View)? = nil, + action: @escaping () -> Void + ) -> LCENavigationView { + if let image { + state.rightButtonImage = AnyView(image) + } else { + state.rightButtonImage = nil + } + state.rightButtonText = text + state.rightButtonAction = action + + if let string = state.leftButtonText.string, string.isEmpty { + state.leftButtonText = text.foregroundColor(.clear) + } + if state.leftButtonImage == nil { + state.leftButtonImage = image?.foregroundColor(.clear) as? AnyView + } + + return self + } + + public func setLeftButton( + text: Text = Text(""), + image: (any View)? = nil, + action: @escaping () -> Void + ) -> LCENavigationView { + if let image { + state.leftButtonImage = AnyView(image) + } else { + state.leftButtonImage = nil + } + state.leftButtonText = text + state.leftButtonAction = action + + if let string = state.rightButtonText.string, string.isEmpty { + state.rightButtonText = text.foregroundColor(.clear) + } + if state.rightButtonImage == nil { + state.rightButtonImage = image?.foregroundColor(.clear) as? AnyView + } + + return self + } + + public func setTitle( + text: (any View) = Text(""), + subTitle: (any View)? = nil + ) -> LCENavigationView { + state.title = text + state.subTitle = subTitle ?? Text("").tag("hidden") + return self + } + + public func hideNavigationView(_ hide: Bool) -> LCENavigationView { + state.hideNavigationBar = hide + return self + } +} + +@available(iOS 15.0, *) +extension FormatStyle { + func format(any value: Any) -> FormatOutput? { + if let v = value as? FormatInput { + return format(v) + } + return nil + } +} + +@available(iOS 15.0, *) +extension LocalizedStringKey { + var resolved: String? { + let mirror = Mirror(reflecting: self) + guard let key = mirror.descendant("key") as? String else { + return nil + } + + guard let args = mirror.descendant("arguments") as? [Any] else { + return nil + } + + let values = args.map { arg -> Any? in + let mirror = Mirror(reflecting: arg) + if let value = mirror.descendant("storage", "value", ".0") { + return value + } + + guard let format = mirror.descendant("storage", "formatStyleValue", "format") as? any FormatStyle, + let input = mirror.descendant("storage", "formatStyleValue", "input") else { + return nil + } + + return format.format(any: input) + } + + let va = values.compactMap { arg -> CVarArg? in + switch arg { + case let i as Int: return i + case let i as Int64: return i + case let i as Int8: return i + case let i as Int16: return i + case let i as Int32: return i + case let u as UInt: return u + case let u as UInt64: return u + case let u as UInt8: return u + case let u as UInt16: return u + case let u as UInt32: return u + case let f as Float: return f + case let f as CGFloat: return f + case let d as Double: return d + case let o as NSObject: return o + default: return nil + } + } + + if va.count != values.count { + return nil + } + + return String.localizedStringWithFormat(key, va) + } +} + +@available(iOS 15.0, *) +extension Text { + var string: String? { + let mirror = Mirror(reflecting: self) + if let s = mirror.descendant("storage", "verbatim") as? String { + return s + } else if let attrStr = mirror.descendant("storage", "anyTextStorage", "str") as? AttributedString { + return String(attrStr.characters) + } else if let key = mirror.descendant("storage", "anyTextStorage", "key") as? LocalizedStringKey { + return key.resolved + } else if let format = mirror.descendant("storage", "anyTextStorage", "storage", "format") as? any FormatStyle, + let input = mirror.descendant("storage", "anyTextStorage", "storage", "input") { + return format.format(any: input) as? String + } else if let formatter = mirror.descendant("storage", "anyTextStorage", "formatter") as? Formatter, + let object = mirror.descendant("storage", "anyTextStorage", "object") { + return formatter.string(for: object) + } + return nil + } +} + +//@available(iOS 15.0, *) +//struct LCENavigationView_Previews: PreviewProvider { +// static var previews: some View { +// LCENavigationView { +// Text("Hello, World!") +// } +// .setTitle(text: Text("Hellow Nav Title")) +// .setLeftButton(text: "Back") { +// print("Button tapped!") +// } +// } +//} + +#endif diff --git a/Sources/LCEssentials/SwiftUI/View+Ext.swift b/Sources/LCEssentials/SwiftUI/View+Ext.swift new file mode 100644 index 0000000..9630d4c --- /dev/null +++ b/Sources/LCEssentials/SwiftUI/View+Ext.swift @@ -0,0 +1,81 @@ +// +// Copyright (c) 2025 Loverde Co. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import SwiftUI + +@available(iOS 13.0, *) +extension View { + func getTag() throws -> TagType { + // Mirror this view + let mirror = Mirror(reflecting: self) + + // Get tag modifier + guard let realTag = mirror.descendant("modifier", "value") else { + // Not found tag modifier here, this could be composite + // view. Check for modifier directly on the `body` if + // not a primitive view type. + guard Body.self != Never.self else { + throw TagError.notFound + } + return try body.getTag() + } + + // Bind memory to extract tag's value + let fakeTag = try withUnsafeBytes(of: realTag) { ptr -> FakeTag in + let binded = ptr.bindMemory(to: FakeTag.self) + guard let mapped = binded.first else { + throw TagError.other + } + return mapped + } + + // Return tag's value + return fakeTag.value + } + + func extractTag(_ closure: (() throws -> TagType) -> Void) -> Self { + closure(getTag) + return self + } +} + +enum TagError: Error, CustomStringConvertible { + case notFound + case other + + public var description: String { + switch self { + case .notFound: return "Not found" + case .other: return "Other" + } + } +} + +enum FakeTag { + case tagged(TagType) + + var value: TagType { + switch self { + case let .tagged(value): return value + } + } +} diff --git a/loverde_company_logo_full.png b/loverde_company_logo_full.png new file mode 100755 index 0000000000000000000000000000000000000000..6a3c11401bb37242f643e96efd2b08230469ebf2 GIT binary patch literal 9167 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 OHYy65^7XRTk^c*AjlJ#w literal 0 HcmV?d00001