Compare commits
1 Commits
main
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b6b45ee9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,5 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
/.build
|
/.build
|
||||||
/build
|
|
||||||
/Packages
|
/Packages
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
# `API` — networking for LCEssentials
|
|
||||||
|
|
||||||
Part of the reference set: [Extensions.md](Extensions.md) ·
|
|
||||||
[SwiftUI.md](SwiftUI.md) · [UIKit.md](UIKit.md).
|
|
||||||
|
|
||||||
`API` is an `actor` that wraps `URLSession` for JSON REST calls and multipart
|
|
||||||
uploads. One line to send a typed request, decode the response, and get a
|
|
||||||
consistent error — instead of re-writing the same `URLRequest` / status-code /
|
|
||||||
`JSONDecoder` boilerplate in every project.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
import LCEssentials
|
|
||||||
|
|
||||||
struct User: Decodable, Sendable { let id: Int; let name: String }
|
|
||||||
|
|
||||||
let user: User = try await API.shared.request(
|
|
||||||
url: "https://api.example.com/users/{id}",
|
|
||||||
method: .get,
|
|
||||||
pathParams: ["id": "42"]
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why not hand-rolled `URLSession`
|
|
||||||
|
|
||||||
| Hand-rolled `URLSession` | `API` |
|
|
||||||
|---|---|
|
|
||||||
| Build `URLRequest`, set method, headers, body, `Content-Type`, `Content-Length` every call | `request(url:method:body:)` — headers and content metadata handled |
|
|
||||||
| `switch` on `httpResponse.statusCode` in every call site, or forget to | 2xx decodes, 4xx/5xx throw a populated `NSError` (`.code` = HTTP status, failure reason = response body) |
|
|
||||||
| `JSONDecoder().decode(T.self, from:)` + custom error messages each time | `JSONDecoder.decode` with keyed / type-mismatch / missing-value diagnostics baked in |
|
|
||||||
| Multipart body assembled by hand with `\r\n` string concatenation and force-unwrapped `.data(using:)` | `MultipartForm` builder; body streamed from a temp file, never fully in memory |
|
|
||||||
| Large file upload loads the whole file into a `Data` | `form.file(_:url:)` streams from disk in 64 KB chunks |
|
|
||||||
| Retry logic copy-pasted, often unbounded | `persistConnection: true`, bounded by `API.maxPersistRetries` |
|
|
||||||
| Client-certificate (mTLS) needs a custom `URLSessionDelegate` per project | `setupCertification(certData:password:)` |
|
|
||||||
| Progress reporting needs a delegate + KVO wiring | `upload(..., onProgress:)` |
|
|
||||||
| `@MainActor` hops or manual `DispatchQueue` juggling | `actor`-isolated, `Sendable`-checked, runs off the main thread |
|
|
||||||
| Response types must be `Codable` even when only decoding | `T: Decodable & Sendable` |
|
|
||||||
|
|
||||||
`API` is not a replacement for a full networking stack (no interceptors,
|
|
||||||
caching policy DSL, or automatic token refresh). For simple typed REST it
|
|
||||||
removes the boilerplate and the easy-to-get-wrong parts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requests
|
|
||||||
|
|
||||||
### GET
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let items: [Item] = try await API.shared.request(
|
|
||||||
url: "https://api.example.com/items",
|
|
||||||
method: .get
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST / PUT / PATCH with a JSON body
|
|
||||||
|
|
||||||
`body` takes any `HTTPBody`. `jsonBody(_:)` wraps an `Encodable & Sendable`
|
|
||||||
value; `Content-Type: application/json; charset=UTF-8` is set for you.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct CreateUser: Encodable, Sendable { let name: String; let email: String }
|
|
||||||
|
|
||||||
let created: User = try await API.shared.request(
|
|
||||||
url: "https://api.example.com/users",
|
|
||||||
method: .post,
|
|
||||||
body: jsonBody(CreateUser(name: "Ana", email: "ana@example.com"))
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Form-url-encoded body
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let token: TokenDTO = try await API.shared.request(
|
|
||||||
url: "https://api.example.com/oauth/token",
|
|
||||||
method: .post,
|
|
||||||
body: .form([
|
|
||||||
"grant_type": "password",
|
|
||||||
"username": "ana",
|
|
||||||
"password": "s3cr3t" // reserved chars are percent-escaped, never dropped
|
|
||||||
])
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Raw body (you control the bytes and content type)
|
|
||||||
|
|
||||||
```swift
|
|
||||||
body: RawBody(data: protobufData, contentType: "application/x-protobuf")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Path parameters
|
|
||||||
|
|
||||||
`{name}` placeholders in `url` are filled from `pathParams`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
url: "https://api.example.com/teams/{team}/members/{member}",
|
|
||||||
pathParams: ["team": "42", "member": "7"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom headers
|
|
||||||
|
|
||||||
Merged over the defaults — your value wins per key, the other defaults stay.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
headers: ["Authorization": "Bearer \(accessToken)"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Plain-text / string responses
|
|
||||||
|
|
||||||
When `T == String` the raw response body is returned without JSON decoding:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let csv: String = try await API.shared.request(url: "\(base)/export.csv", method: .get)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Retry on transient 4xx
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let data: Payload = try await API.shared.request(
|
|
||||||
url: "\(base)/flaky",
|
|
||||||
method: .get,
|
|
||||||
persistConnection: true // retries up to API.maxPersistRetries, then throws
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Other options
|
|
||||||
|
|
||||||
| Parameter | Default | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `debug` | `true` | Print request/response logs |
|
|
||||||
| `timeoutInterval` | `30` | Seconds |
|
|
||||||
| `networkServiceType` | `.default` | `URLRequest.NetworkServiceType` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Errors
|
|
||||||
|
|
||||||
Non-2xx responses throw an `NSError`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
do {
|
|
||||||
let u: User = try await API.shared.request(url: "\(base)/users/999", method: .get)
|
|
||||||
} catch let error as NSError {
|
|
||||||
error.code // HTTP status, e.g. 404
|
|
||||||
error.localizedDescription // from URLError
|
|
||||||
error.localizedFailureReason // pretty-printed response body
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Transport failures surface as `URLError`. Malformed success bodies throw
|
|
||||||
`DecodingError` with a readable message (missing key, type mismatch, …).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Uploads
|
|
||||||
|
|
||||||
### Build a multipart form
|
|
||||||
|
|
||||||
```swift
|
|
||||||
var form = MultipartForm()
|
|
||||||
form.field("caption", "Sunset")
|
|
||||||
form.file("thumbnail", data: jpegData, filename: "thumb.jpg") // in memory
|
|
||||||
form.file("video", url: localVideoURL) // streamed from disk
|
|
||||||
```
|
|
||||||
|
|
||||||
- `field(_:_:)` — plain text field.
|
|
||||||
- `file(_:data:filename:mime:)` — in-memory blob. MIME guessed from the
|
|
||||||
filename extension unless you pass `mime:`.
|
|
||||||
- `file(_:url:filename:mime:)` — on-disk file, streamed straight into the body
|
|
||||||
so a large file never becomes fully resident in memory.
|
|
||||||
|
|
||||||
### Send
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let result: UploadResult = try await API.shared.upload(
|
|
||||||
url: "https://api.example.com/media",
|
|
||||||
form: form
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
The body is serialised to a temp file and always removed afterwards, on success
|
|
||||||
and on throw.
|
|
||||||
|
|
||||||
### With progress
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let result: UploadResult = try await API.shared.upload(
|
|
||||||
url: "https://api.example.com/media",
|
|
||||||
form: form,
|
|
||||||
onProgress: { fraction in
|
|
||||||
Task { @MainActor in progressView.progress = Float(fraction) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`onProgress` is called on an arbitrary queue with a value in `0.0...1.0`, then
|
|
||||||
`1.0` once the body has been fully sent. Hop to the main actor yourself before
|
|
||||||
touching UI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Client certificate (mutual TLS)
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let p12 = try Data(contentsOf: certificateURL)
|
|
||||||
await API.shared.setupCertification(certData: p12, password: "cert-password")
|
|
||||||
// subsequent requests present the client certificate on TLS challenge
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Extending: custom body types
|
|
||||||
|
|
||||||
Conform to `HTTPBody`:
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct CSVBody: HTTPBody {
|
|
||||||
let rows: [[String]]
|
|
||||||
func encoded() throws -> (data: Data, contentType: String) {
|
|
||||||
let text = rows.map { $0.joined(separator: ",") }.joined(separator: "\n")
|
|
||||||
return (Data(text.utf8), "text/csv; charset=UTF-8")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try await API.shared.request(url: "\(base)/import", method: .post, body: CSVBody(rows: rows))
|
|
||||||
```
|
|
||||||
|
|
||||||
`API` needs no change to accept it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `API` is an `actor`. Every call is `await`; config setters
|
|
||||||
(`setupCertification`, `setPersistConnectionDelay`) are `await` too.
|
|
||||||
- Response and body types must be `Sendable`. Value-type structs already are.
|
|
||||||
- The shared instance is `API.shared`. Tests build isolated instances with
|
|
||||||
`API(testConfiguration:)` and a stub `URLProtocol`.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,117 +0,0 @@
|
|||||||
# LCEssentials — SwiftUI
|
|
||||||
|
|
||||||
SwiftUI components and `View` helpers. Foundation/value-type helpers are in
|
|
||||||
[Extensions.md](Extensions.md); UIKit-era helpers in [UIKit.md](UIKit.md).
|
|
||||||
|
|
||||||
Every section is a collapsible block — click a heading to expand it.
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
- [Navigation](#navigation)
|
|
||||||
- [View helpers](#view-helpers)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>LCENavigationView</b> — customizable navigation bar (iOS 15+)</summary>
|
|
||||||
|
|
||||||
A drop-in replacement for the system navigation bar with left/right buttons,
|
|
||||||
title + subtitle, background colour, and a hide toggle. Configuration methods
|
|
||||||
return `self`, so they chain. **Per the workspace iOS standards, this component
|
|
||||||
is mandatory on new SwiftUI screens instead of a hand-rolled bar.**
|
|
||||||
|
|
||||||
`@available(iOS 15, *)`, iOS only.
|
|
||||||
|
|
||||||
### `init(title: (any View) = Text(""), subTitle: (any View) = Text(""), @ViewBuilder content: () -> Content)`
|
|
||||||
|
|
||||||
The `content` closure is everything shown **below** the bar.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
LCENavigationView(title: Text("Profile")) {
|
|
||||||
ScrollView {
|
|
||||||
ProfileForm()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func setTitle(text: (any View) = Text(""), subTitle: (any View)? = nil) -> LCENavigationView`
|
|
||||||
|
|
||||||
Set (or replace) the title and optional subtitle. Passing no `subTitle` hides
|
|
||||||
the subtitle row.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
LCENavigationView { Content() }
|
|
||||||
.setTitle(text: Text("Orders"), subTitle: Text("32 open"))
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func setLeftButton(text: Text = Text(""), image: (any View)? = nil, action: @escaping () -> Void) -> LCENavigationView`
|
|
||||||
|
|
||||||
Configure the leading button. If the trailing button has no text/image yet, a
|
|
||||||
transparent placeholder is added on that side so the title stays centred.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
.setLeftButton(text: Text("Back"), image: Image(systemName: "chevron.left")) {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func setRightButton(text: Text = Text(""), image: (any View)? = nil, action: @escaping () -> Void) -> LCENavigationView`
|
|
||||||
|
|
||||||
Configure the trailing button (same placeholder behaviour for the leading side).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
.setRightButton(image: Image(systemName: "plus")) {
|
|
||||||
showingNewItem = true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func hideNavigationView(_ hide: Bool) -> LCENavigationView`
|
|
||||||
|
|
||||||
Show or hide the whole bar (the content stays).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
.hideNavigationView(isFullScreenMedia)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func setNavigationBarBackgroundColor(_ color: Color) -> LCENavigationView`
|
|
||||||
|
|
||||||
Bar background colour (extends into the top safe area). Defaults to `.clear`.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
.setNavigationBarBackgroundColor(.blue.opacity(0.1))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Full example
|
|
||||||
|
|
||||||
```swift
|
|
||||||
struct OrdersScreen: View {
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@State private var showingNew = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
LCENavigationView(title: Text("Orders")) {
|
|
||||||
OrdersList()
|
|
||||||
}
|
|
||||||
.setLeftButton(text: Text("Back"),
|
|
||||||
image: Image(systemName: "chevron.left")) { dismiss() }
|
|
||||||
.setRightButton(image: Image(systemName: "plus")) { showingNew = true }
|
|
||||||
.setNavigationBarBackgroundColor(Color(.systemBackground))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> `LCENavigationState` (the `@Published` backing store) and the reflection-based
|
|
||||||
> `Text.string` / tag helpers in `View+Ext.swift` are `internal` implementation
|
|
||||||
> details — not part of the public API.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## View helpers
|
|
||||||
|
|
||||||
There are currently no public standalone `View` extensions — the `getTag` /
|
|
||||||
`extractTag` reflection utilities in `SwiftUI/View+Ext.swift` are `internal` and
|
|
||||||
exist only to support `LCENavigationView`'s subtitle handling.
|
|
||||||
@@ -1,815 +0,0 @@
|
|||||||
# LCEssentials — UIKit
|
|
||||||
|
|
||||||
UIKit-era helpers: programmatic layout & constraints, view/control extensions,
|
|
||||||
navigation, table/collection helpers, and drop-in components (image picker, image
|
|
||||||
zoom, snackbar, GIF loading).
|
|
||||||
|
|
||||||
Foundation/value-type helpers are in [Extensions.md](Extensions.md); SwiftUI
|
|
||||||
helpers in [SwiftUI.md](SwiftUI.md).
|
|
||||||
|
|
||||||
Every section is a collapsible block — click a heading to expand it.
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
- [Layout & Constraints](#layout--constraints)
|
|
||||||
- [Views & Controls](#views--controls)
|
|
||||||
- [Navigation & Controllers](#navigation--controllers)
|
|
||||||
- [Collections & Tables](#collections--tables)
|
|
||||||
- [Media & Components](#media--components)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Layout & Constraints
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIView</b> — programmatic constraints, chainable anchors</summary>
|
|
||||||
|
|
||||||
### `enum AnchorType`
|
|
||||||
The anchor to pin when using `setConstraintsTo`. Cases: `all`, `top`, `bottom`,
|
|
||||||
`leading`, `trailing`, `left`, `right`, `centerX`, `centerY`, `width`, `heigth`
|
|
||||||
*(sic)*, `topToBottom`, `bottomToTop`, `leadingToTrailing`, `trailingToLeading`,
|
|
||||||
plus `…GreaterThanOrEqualTo` / `…LessThanOrEqualTo` variants.
|
|
||||||
|
|
||||||
### `@discardableResult func setConstraintsTo(parentView: UIView, anchorType: AnchorType, value: CGFloat, safeArea: Bool = false) -> Self`
|
|
||||||
Activate one constraint against `parentView`. Remembers `parentView` (in
|
|
||||||
`viewReference`) so follow-up calls can omit it. `safeArea: true` pins top/bottom
|
|
||||||
to the safe-area guide (iOS 11+).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
card.addSubview(label)
|
|
||||||
label.setConstraintsTo(parentView: card, anchorType: .top, value: 12, safeArea: false)
|
|
||||||
.setConstraints(.leading, 16)
|
|
||||||
.setConstraints(.trailing, -16)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `@discardableResult func setConstraintsTo(_ parentView:_ anchorType:_ value:_ safeArea: Bool = false) -> Self`
|
|
||||||
Positional-argument shorthand for the above.
|
|
||||||
|
|
||||||
### `@discardableResult func setConstraintsTo(anchorType: AnchorType, value: CGFloat, safeArea: Bool = false) -> UIView`
|
|
||||||
Reuse the last `parentView` (`viewReference`). **Traps** with `fatalError` if no
|
|
||||||
`parentView` was set first.
|
|
||||||
|
|
||||||
### `@discardableResult func setConstraints(_ anchorType: AnchorType, _ value: CGFloat, _ safeArea: Bool = false) -> UIView`
|
|
||||||
Positional shorthand for the reuse form — the one you chain.
|
|
||||||
|
|
||||||
### `func setConstraints(_ toScrollView: UIScrollView, direction: UICollectionView.ScrollDirection = .vertical)`
|
|
||||||
Pin `self` as the single content view of a scroll view (edges + matching
|
|
||||||
width/height, with a low-priority constraint on the scroll axis). Traps if
|
|
||||||
`self` is itself a `UIScrollView`.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
scrollView.addSubview(content)
|
|
||||||
content.setConstraints(scrollView, direction: .vertical)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Constraint accessors
|
|
||||||
`widthConstraint`, `heightConstraint`, `leadingConstraint`, `trailingConstraint`,
|
|
||||||
`topConstraint`, `bottomConstraint`, `centerXConstraints`, `centerYConstraints` —
|
|
||||||
the first matching `NSLayoutConstraint` found by walking up the hierarchy.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
box.widthConstraint?.constant = 120
|
|
||||||
UIView.animate(withDuration: 0.2) { self.view.layoutIfNeeded() }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func findConstraint(attribute: NSLayoutConstraint.Attribute, for view: UIView) -> NSLayoutConstraint?`
|
|
||||||
Search self and ancestors for a constraint on `attribute` involving `view`.
|
|
||||||
|
|
||||||
### `func constraints(on anchor:) -> [NSLayoutConstraint]`
|
|
||||||
Constraints touching a given `NSLayoutYAxisAnchor` / `XAxisAnchor` / `Dimension` of `self`.
|
|
||||||
|
|
||||||
### `@discardableResult func setHeight(size:) -> Self` / `setHeight(min:)` / `setWidth(size:)` / `setWidth(min:)`
|
|
||||||
Activate a fixed or minimum dimension constraint.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
avatar.setWidth(size: 44).setHeight(size: 44)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func setWidth(_ toView: UIView? = nil, constant: CGFloat, _ multiplier: CGFloat = 0) -> Self` / `setHeight(...)`
|
|
||||||
Dimension relative to another view (`multiplier`) or fixed (`multiplier == 0`).
|
|
||||||
|
|
||||||
### Frame setters
|
|
||||||
`setX(x:)`, `setY(y:)`, `setFrameWidth(width:)`, `setFrameHeight(height:)` — mutate
|
|
||||||
`self.frame` directly (manual layout, not Auto Layout).
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIView</b> — hierarchy, appearance, effects</summary>
|
|
||||||
|
|
||||||
### `static var className: String`
|
|
||||||
`String(describing: self)`.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
UIButton.className // "UIButton"
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func addSubview(_ subview:, translatesAutoresizingMaskIntoConstraints: Bool = false)` / `addSubviews(_ subviews: [UIView], …)`
|
|
||||||
Add a view (or many) and set `translatesAutoresizingMaskIntoConstraints` in one call.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
view.addSubviews([header, body, footer]) // all ready for Auto Layout
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func subviews<T>(ofType _: T.Type) -> [T]`
|
|
||||||
All descendants of a type, recursively.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
formView.subviews(ofType: UITextField.self)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func findAView<T>(_ ofType: T.Type) -> T?`
|
|
||||||
First descendant of a type.
|
|
||||||
|
|
||||||
### `var parentViewController: UIViewController?`
|
|
||||||
Nearest owning view controller (walks the responder chain).
|
|
||||||
|
|
||||||
### `var borderColor: UIColor?` / `var borderWidth: CGFloat` / `var cornerRadius: CGFloat`
|
|
||||||
Layer border/corner shortcuts (`cornerRadius` also sets `masksToBounds`).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
card.cornerRadius = 12
|
|
||||||
card.borderWidth = 1
|
|
||||||
card.borderColor = .separator
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func setRadius(top: Bool, bottom: Bool, radius: CGFloat = 8)`
|
|
||||||
Round only the top and/or bottom corners.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
sheet.setRadius(top: true, bottom: false, radius: 16)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func applyShadow(color:offSet:radius:opacity:shouldRasterize: Bool = true, rasterizationScaleTo: = UIScreen.main.scale)` / `func removeShadow()`
|
|
||||||
Layer shadow with rasterisation on by default.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
card.applyShadow(color: .black, offSet: CGSize(width: 0, height: 2),
|
|
||||||
radius: 6, opacity: 0.15)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func insertBlurView(style: UIBlurEffect.Style, color: UIColor = .black, alpha: CGFloat = 0.9)`
|
|
||||||
Insert a full-bleed `UIVisualEffectView` behind the content.
|
|
||||||
|
|
||||||
### `func drawCircle(inCoord x:y:with radius:strokeColor: = .red, fillColor: = .gray, isEmpty: Bool = false) -> [String: Any]`
|
|
||||||
Add a circular `CAShapeLayer`; returns `["path": UIBezierPath, "layer": CAShapeLayer]`.
|
|
||||||
|
|
||||||
### `var isRightToLeft: Bool`
|
|
||||||
Effective RTL layout direction.
|
|
||||||
|
|
||||||
### `var screenshot: UIImage?` / `func asImage() -> UIImage`
|
|
||||||
Render the view to an image (`screenshot` via the legacy context, `asImage` via `UIGraphicsImageRenderer`).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let png = chartView.asImage().pngData()
|
|
||||||
```
|
|
||||||
|
|
||||||
### `var globalPoint: CGPoint?` / `var globalFrame: CGRect?` / `var absolutePosition: CGRect`
|
|
||||||
Origin/frame converted to window coordinates.
|
|
||||||
|
|
||||||
### `func fadeIn(withDuration: TimeInterval = 1, withDelay: TimeInterval = 0, completionHandler: @escaping (Bool) -> Void)` / `func fadeOut(...)`
|
|
||||||
Animate `alpha` to 1 / 0. *(Completion-handler API — pre-dates async/await.)*
|
|
||||||
|
|
||||||
```swift
|
|
||||||
overlay.fadeOut { _ in overlay.removeFromSuperview() }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `var viewReference: UIView?`
|
|
||||||
Scratch reference used internally by the chained `setConstraintsTo` calls.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>GradientOrientation / EnumBorderSide</b></summary>
|
|
||||||
|
|
||||||
### `enum GradientOrientation`
|
|
||||||
`topRightBottomLeft`, `topLeftBottomRight`, `horizontal`, `vertical` — maps to a
|
|
||||||
`CAGradientLayer` start/end point pair. Passed to gradient helpers elsewhere in
|
|
||||||
the package.
|
|
||||||
|
|
||||||
### `enum EnumBorderSide`
|
|
||||||
`top`, `bottom`, `left`, `right` — which edge to draw a single-side border on.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIStackView</b> — convenience init, bulk arranged-subview ops (iOS 9+)</summary>
|
|
||||||
|
|
||||||
### `convenience init(arrangedSubviews: [UIView]? = nil, axis: = .vertical, spacing: = 0, alignment: = .fill, distribution: = .fill, layoutMargins: = .zero, isMarginsRelative: Bool = true)`
|
|
||||||
One-call configured stack view.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let stack = UIStackView(arrangedSubviews: [title, subtitle],
|
|
||||||
axis: .vertical, spacing: 4)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func addArrangedSubviews(_ views: [UIView], translateAutoresizing: Bool = false)`
|
|
||||||
Add many arranged subviews, setting their autoresizing flag.
|
|
||||||
|
|
||||||
### `func removeAllArrangedSubviews(deactivateConstraints: Bool = true)`
|
|
||||||
Remove and dispose every arranged subview.
|
|
||||||
|
|
||||||
### `func removeSubview(view: UIView, deactivateConstraints: Bool = true)`
|
|
||||||
Remove one arranged subview (optionally deactivating its constraints and removing from the hierarchy).
|
|
||||||
|
|
||||||
### `func addSpace(_ size: CGFloat, backgroundColor: UIColor = .clear)`
|
|
||||||
Insert a fixed-size spacer view sized along the stack's axis.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
stack.addArrangedSubviews([row1, row2])
|
|
||||||
stack.addSpace(24)
|
|
||||||
stack.addArrangedSubview(row3)
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
> `NSLayoutConstraint` helpers (`constraintWithMultiplier`, `matches(view:anchor:)`)
|
|
||||||
> and `[NSLayoutConstraint].filtered(view:anchor:)` are `internal` — used by the
|
|
||||||
> `UIView.constraints(on:)` accessors above, not called directly.
|
|
||||||
|
|
||||||
## Views & Controls
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIButton</b> — per-state accessors, all-state setters</summary>
|
|
||||||
|
|
||||||
### Per-state properties
|
|
||||||
`imageForNormal` / `imageForHighlighted` / `imageForSelected` / `imageForDisabled`,
|
|
||||||
`titleForNormal` / `…Highlighted` / `…Selected` / `…Disabled`,
|
|
||||||
`titleColorForNormal` / `…Highlighted` / `…Selected` / `…Disabled` — get/set
|
|
||||||
shortcuts for the matching `UIControl.State` (also `@IBInspectable`).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
button.titleForNormal = "Save"
|
|
||||||
button.titleColorForDisabled = .tertiaryLabel
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func setTitleForAllStates(_:)` / `func setTitleColorForAllStates(_:)` / `func setImageForAllStates(_:)`
|
|
||||||
Apply one value to `.normal`, `.selected`, `.highlighted`, `.disabled` at once.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
button.setTitleColorForAllStates(.white)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func centerTextAndImage(spacing: CGFloat)`
|
|
||||||
Balance title/image edge insets so text + icon sit centred with `spacing` between them.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
button.centerTextAndImage(spacing: 8)
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UILabel</b></summary>
|
|
||||||
|
|
||||||
### `func lineNumbers() -> Int`
|
|
||||||
Rendered line count at the current width.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
if bodyLabel.lineNumbers() > 3 { showMoreButton() }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `var getEstimatedHeight: CGFloat`
|
|
||||||
Height the label would need to show its full text/attributed text unclipped.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UITextField</b></summary>
|
|
||||||
|
|
||||||
### `var placeholderColor: UIColor`
|
|
||||||
Get/set the placeholder text colour (rebuilds `attributedPlaceholder`; setter is a
|
|
||||||
no-op if no placeholder text is set).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
field.placeholder = "Email"
|
|
||||||
field.placeholderColor = .secondaryLabel
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func addPaddingLeft(_ padding: CGFloat)`
|
|
||||||
Inset the text from the left with an empty spacer view.
|
|
||||||
|
|
||||||
### `func addPaddingLeftIcon(_ image: UIImage, padding: CGFloat)`
|
|
||||||
Left view = an icon plus trailing padding.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
field.addPaddingLeftIcon(UIImage(systemName: "magnifyingglass")!, padding: 8)
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIImageView</b> (<code>@MainActor</code>)</summary>
|
|
||||||
|
|
||||||
### `func changeColorOfImage(_ color: UIColor, image: UIImage?) -> UIImageView`
|
|
||||||
Set a template-rendered image tinted to `color`; returns `self`.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
iconView.changeColorOfImage(.systemBlue, image: UIImage(named: "star"))
|
|
||||||
```
|
|
||||||
|
|
||||||
### `var encodeToBase64: String?`
|
|
||||||
JPEG (quality 0.6) of the current image as a Base64 string.
|
|
||||||
|
|
||||||
### `func addAspectRatioConstraint()` / `func removeAspectRatioConstraint()`
|
|
||||||
Add / remove a width-to-height constraint matching the current image's ratio.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
photoView.image = photo
|
|
||||||
photoView.addAspectRatioConstraint()
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIImage</b> — recolour, resize, thumbnails, masks, init</summary>
|
|
||||||
|
|
||||||
### `func imageWithColor(color: UIColor) -> UIImage` / `func tintImage(color: UIColor) -> UIImage`
|
|
||||||
Return a copy filled / tinted with `color` (keeps the alpha shape).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let redIcon = icon.tintImage(color: .systemRed)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func backgroundColorTransparent(initialColor: UIColor, finalColor: UIColor) -> UIImage?`
|
|
||||||
Make pixels in a colour range transparent.
|
|
||||||
|
|
||||||
### `class func outlinedEllipse(size: CGSize, color: UIColor, lineWidth: CGFloat = 1) -> UIImage?`
|
|
||||||
Generate a stroked-ellipse image.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
UIImage.outlinedEllipse(size: CGSize(width: 24, height: 24), color: .label)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func resizeImage(newWidth: CGFloat) -> UIImage`
|
|
||||||
Scale to `newWidth`, keeping the aspect ratio.
|
|
||||||
|
|
||||||
### `func createThumbnail(_ maxPixelSize: UInt) -> UIImage`
|
|
||||||
Fast down-sampled thumbnail via `CGImageSource`.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let thumb = fullImage.createThumbnail(200)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func maskWithAlphaImage(maskImage: UIImage) -> UIImage`
|
|
||||||
Use another image's alpha as a mask.
|
|
||||||
|
|
||||||
### `func isAnimated() -> Bool`
|
|
||||||
Whether the image has more than one frame.
|
|
||||||
|
|
||||||
### `init?(base64String: String, scale: CGFloat = 1)`
|
|
||||||
Decode a Base64 string to an image.
|
|
||||||
|
|
||||||
### `init(view: UIView)` — *`@MainActor`*
|
|
||||||
Rasterise a view into an image.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let snapshot = UIImage(view: cardView)
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIColor</b> — hex, components</summary>
|
|
||||||
|
|
||||||
### `convenience init(hex: String)`
|
|
||||||
Parse `#RGB`, `#RGBA`, `#RRGGBB`, or `#RRGGBBAA` (with or without `#`).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
view.backgroundColor = UIColor(hex: "#1E88E5")
|
|
||||||
UIColor(hex: "FF0000CC") // red at 80% alpha
|
|
||||||
```
|
|
||||||
|
|
||||||
### `var hexString: String?`
|
|
||||||
`#RRGGBB` (or `#RRGGBBAA` when alpha < 1).
|
|
||||||
|
|
||||||
### `var redValue` / `var greenValue` / `var blueValue` / `var alphaValue`
|
|
||||||
Individual channel values (`CGFloat`, via `CIColor`).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
UIColor.systemBlue.redValue // 0.0…1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIScrollView</b> — snapshot, visible rect, paged scrolling</summary>
|
|
||||||
|
|
||||||
### `var snapshot: UIImage?`
|
|
||||||
Image of the **entire** content size (not just the visible part) — works on
|
|
||||||
`UITableView` / `UICollectionView` too.
|
|
||||||
|
|
||||||
### `var visibleRect: CGRect`
|
|
||||||
The currently visible content region.
|
|
||||||
|
|
||||||
### `var offsetInPage: CGFloat`
|
|
||||||
Fractional position within the current page height (`0.0`…`1.0`).
|
|
||||||
|
|
||||||
### `func scrollUp(animated:)` / `scrollDown(animated:)` / `scrollLeft(animated:)` / `scrollRight(animated:)`
|
|
||||||
Move one page (respects `isPagingEnabled`). `animated` defaults to `true`.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
nextButton.onTap = { scrollView.scrollRight() }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `enum orientation`
|
|
||||||
`horizontal` / `vertical` — helper enum used by scroll utilities.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UITapGestureRecognizer</b></summary>
|
|
||||||
|
|
||||||
### `func didTapAttributedTextInLabel(label: UILabel, textToTouch: String) -> Bool`
|
|
||||||
Whether the tap landed on a given substring of a label's attributed text — for
|
|
||||||
making part of a label tappable.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
@objc func handleTap(_ g: UITapGestureRecognizer) {
|
|
||||||
if g.didTapAttributedTextInLabel(label: termsLabel, textToTouch: "Terms of Use") {
|
|
||||||
openTerms()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Navigation & Controllers
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UINavigationController</b> — completion-handler push/pop, transparent bar</summary>
|
|
||||||
|
|
||||||
### `func pushViewController(_:animated: Bool = true, completion: (() -> Void)? = nil)`
|
|
||||||
### `func popViewController(animated: Bool = true, _ completion: (() -> Void)? = nil)`
|
|
||||||
### `func popToViewController(_:animated: Bool = true, _ completion: (() -> Void)? = nil)`
|
|
||||||
The standard transitions with a completion block (wrapped in a `CATransaction`).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
navigationController?.pushViewController(detail) {
|
|
||||||
print("detail is on screen")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func pushViewController(_:hidesBottomBar: Bool = false, animated: Bool = true)` — *not tvOS*
|
|
||||||
Push while setting `hidesBottomBarWhenPushed`.
|
|
||||||
|
|
||||||
### `func makeTransparent(withTint tint: UIColor = .white)`
|
|
||||||
Clear background + shadow, translucent, tinted bar/title.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
navigationController?.makeTransparent(withTint: .label)
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIViewController</b> — state, instantiation, dismissal, toasts, keyboard, observers</summary>
|
|
||||||
|
|
||||||
### `var isVisible: Bool` / `var isLoaded: Bool`
|
|
||||||
View is loaded **and** in a window. (`isLoaded` is an alias.)
|
|
||||||
|
|
||||||
### `var isModal: Bool`
|
|
||||||
Whether the controller is presented modally (vs. pushed).
|
|
||||||
|
|
||||||
### `static var className: String` / `static var identifier: String` / `static var segueID: String`
|
|
||||||
`"MyVC"`, `"idMyVC"`, `"idSegueMyVC"`.
|
|
||||||
|
|
||||||
### `static func instantiate<T>(storyBoard: String, identifier: String? = nil, bundle: Bundle? = …) -> T`
|
|
||||||
Load a controller from a storyboard by (default) `T.identifier`.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let vc: ProfileVC = ProfileVC.instantiate(storyBoard: "Main")
|
|
||||||
```
|
|
||||||
|
|
||||||
### `static func instatiate<T>(nibName: String, bundle: Bundle? = nil) -> T` *(sic — "instatiate")*
|
|
||||||
Load from a nib and force an initial layout pass.
|
|
||||||
|
|
||||||
### `func present(viewControllerToPresent:completion: @escaping () -> Void)`
|
|
||||||
`present(_:animated:)` with a completion block.
|
|
||||||
|
|
||||||
### `func closeController(jumpToController: UIViewController? = nil, completion: @escaping () -> Void)`
|
|
||||||
Dismiss if modal, else pop (to `jumpToController` if given), then call `completion`.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
closeController { self.refreshList() }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func show(toastWith message:, font: = system 12, toastPosition: ToastPosition, backgroundColor: = .black, textColor: = .white, duration: = 3)`
|
|
||||||
Show a temporary rounded toast label. `ToastPosition` is `.top` / `.down`
|
|
||||||
(notch-aware at the top).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
show(toastWith: "Saved", toastPosition: .down)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func addNotificationObserver(name:selector:)` / `func removeNotificationObserver(name:)` / `func removeNotificationsObserver()`
|
|
||||||
`NotificationCenter` registration shortcuts (last one removes all).
|
|
||||||
|
|
||||||
### `@objc func dismissSystemKeyboard(_ sender: UITapGestureRecognizer)`
|
|
||||||
Ready-made selector for a tap-to-dismiss-keyboard gesture.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
view.addGestureRecognizer(UITapGestureRecognizer(target: self,
|
|
||||||
action: #selector(dismissSystemKeyboard(_:))))
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UITabBarController</b> — badges, animated tab switching</summary>
|
|
||||||
|
|
||||||
### `func setBadges(badgeValues: [Int], font: UIFont = Helvetica-Light 11)`
|
|
||||||
Set numeric badges for every tab at once (`0` = no badge). Custom-drawn
|
|
||||||
(`CustomTabBadge` label), so they position above each tab item.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
tabBarController?.setBadges(badgeValues: [0, 3, 0, 12])
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func addBadge(index:value:color:font:)`
|
|
||||||
Add a single custom badge.
|
|
||||||
|
|
||||||
### `func setSelectedView(atIndex:withAnimation: Bool = false, completion:)`
|
|
||||||
Select a tab (pops that tab's nav stack to root first).
|
|
||||||
|
|
||||||
### `func setSelectedView(withNoPop atIndex:withAnimation: Bool = false, completion:)`
|
|
||||||
Same, without popping to root.
|
|
||||||
|
|
||||||
### `func animateToTab(toIndex: Int)`
|
|
||||||
Slide-transition between tabs.
|
|
||||||
|
|
||||||
### `func changeViewControllerToItem(withViewController:Item:)`
|
|
||||||
Replace a tab's root controller then switch to that tab.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
tabBarController?.changeViewControllerToItem(withViewController: NewHome(), Item: 0)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `class CustomTabBadge: UILabel`
|
|
||||||
The badge label type used above (`init(font:)`).
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIApplication</b> — environment, app info, open URL</summary>
|
|
||||||
|
|
||||||
### `enum Environment` + `static var inferredEnvironment: Environment`
|
|
||||||
`.debug` / `.testFlight` / `.appStore`, inferred from build config, simulator, and
|
|
||||||
the provisioning/receipt files.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
if UIApplication.inferredEnvironment == .appStore { enableAnalytics() }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `static var displayName: String?` / `static var buildNumber: String?` / `static var version: String?`
|
|
||||||
Bundle info values. (`LCEssentials.appVersion` etc. forward to these.)
|
|
||||||
|
|
||||||
### `static func openURL(urlStr: String)`
|
|
||||||
Open a URL string if it can be opened.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
UIApplication.openURL(urlStr: "https://loverde.com.br")
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIResponder</b></summary>
|
|
||||||
|
|
||||||
### `var getParentViewController: UIViewController?`
|
|
||||||
Walk the responder chain to the owning view controller.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
someView.getParentViewController?.present(alert, animated: true)
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UIDevice</b> — notch metrics, model name</summary>
|
|
||||||
|
|
||||||
### `static var topNotch: CGFloat` / `static var bottomNotch: CGFloat` / `static var hasNotch: Bool`
|
|
||||||
Safe-area top/bottom insets and whether the device has a notch / Dynamic Island.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let headerY: CGFloat = UIDevice.hasNotch ? 44 : 20
|
|
||||||
```
|
|
||||||
|
|
||||||
### `var modelName: String`
|
|
||||||
Marketing name from the hardware identifier (`"iPhone 15 Pro"`, `"iPad Air (5th generation)"`, …); falls back to the raw identifier for unknown devices. Reads `SIMULATOR_MODEL_IDENTIFIER` on the simulator.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
UIDevice.current.modelName // "iPhone 16 Pro"
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Collections & Tables
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UITableView</b> — typed dequeue, safe indexing, cell animations</summary>
|
|
||||||
|
|
||||||
### `func dequeueReusableCell<T: UITableViewCell>(withClass name: T.Type) -> T` / `…(withClass:for indexPath:) -> T`
|
|
||||||
Dequeue a cell by its class name as the identifier. **Traps** if the cell isn't registered.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let cell = tableView.dequeueReusableCell(withClass: OrderCell.self, for: indexPath)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func dequeueReusableHeaderFooterView<T: UITableViewHeaderFooterView>(withClass name: T.Type) -> T`
|
|
||||||
Same, for section header/footer views.
|
|
||||||
|
|
||||||
### `func dequeueCell<T: UITableViewCell>(indexPath: IndexPath) -> T`
|
|
||||||
Dequeue using `T.identifier` (`"id" + class name`).
|
|
||||||
|
|
||||||
### `func reloadData(_ completion: @escaping () -> Void)`
|
|
||||||
`reloadData()` with a callback for when layout settles.
|
|
||||||
|
|
||||||
### `func isValidIndexPath(_:) -> Bool`
|
|
||||||
Bounds check against the current section/row counts.
|
|
||||||
|
|
||||||
### `func safeScrollToRow(at:at scrollPosition:animated:)`
|
|
||||||
`scrollToRow` that silently no-ops for an out-of-range index path.
|
|
||||||
|
|
||||||
### `func makeMoveUpWithFadeAnimation(rowHeight:duration:delayFactor:) -> UITableViewCellAnimation`
|
|
||||||
Build a staggered slide-up + fade-in cell animation closure.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let animator = UITableViewAnimator(
|
|
||||||
animation: tableView.makeMoveUpWithFadeAnimation(rowHeight: 64, duration: 0.35, delayFactor: 0.03)
|
|
||||||
)
|
|
||||||
|
|
||||||
func tableView(_ t: UITableView, willDisplay cell: UITableViewCell, forRowAt ip: IndexPath) {
|
|
||||||
animator.animate(cell: cell, at: ip, in: t)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `typealias UITableViewCellAnimation = (UITableViewCell, IndexPath, UITableView) -> Void`
|
|
||||||
### `class UITableViewAnimator`
|
|
||||||
`init(animation:)` + `animate(cell:at:in:)` — runs a cell animation closure.
|
|
||||||
|
|
||||||
### `UITableViewCell.identifier` / `UITableViewCell.prepareDisclosureIndicator()`
|
|
||||||
`"id" + class name`; and re-tint the disclosure chevron to a template image so it
|
|
||||||
picks up `tintColor`.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>UICollectionView</b> — setup, counts, safe indexing, carousel layout</summary>
|
|
||||||
|
|
||||||
### `static var identifier: String`
|
|
||||||
`"id" + class name`.
|
|
||||||
|
|
||||||
### `func setupCollectionView(flowLayout: = UICollectionViewFlowLayout(), spacings: = 0, direction: = .horizontal, edgesInset: = .zero, allowMulpleSelection: Bool = false, automaticSize: CGSize? = nil)`
|
|
||||||
Configure layout spacing/direction/insets, multi-selection, and self-sizing in one call.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
collectionView.setupCollectionView(spacings: 8, direction: .vertical,
|
|
||||||
edgesInset: .init(top: 12, left: 16, bottom: 12, right: 16))
|
|
||||||
```
|
|
||||||
|
|
||||||
### `func reloadData(_ completion: @escaping () -> Void)`
|
|
||||||
Reload with a completion callback.
|
|
||||||
|
|
||||||
### `func numberOfItems() -> Int`
|
|
||||||
Total items across all sections.
|
|
||||||
|
|
||||||
### `var lastSection: Int` / `var indexPathForLastItem: IndexPath?` / `func indexPathForLastItem(inSection:) -> IndexPath?`
|
|
||||||
Last section index; index path of the last item overall or in a section.
|
|
||||||
|
|
||||||
### `func isValidIndexPath(_:) -> Bool` / `func safeScrollToItem(at:at scrollPosition:animated:)`
|
|
||||||
Bounds check; scroll that no-ops on an invalid index path.
|
|
||||||
|
|
||||||
### `enum CollectionViewFlowLayoutSpacingMode`
|
|
||||||
`.fixed(spacing:)` / `.overlap(visibleOffset:)` — spacing strategy for the carousel layout below.
|
|
||||||
|
|
||||||
### `open class CollectionViewFlowLayout: UICollectionViewFlowLayout`
|
|
||||||
A centred, paginated "cover-flow" style layout: the centred item is full size,
|
|
||||||
side items scale and fade. Tunables: `sideItemScale` (0.6), `sideItemAlpha`
|
|
||||||
(0.6), `sideItemShift` (0), `spacingMode` (`.fixed(40)`).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let layout = CollectionViewFlowLayout()
|
|
||||||
layout.itemSize = CGSize(width: 240, height: 320)
|
|
||||||
layout.sideItemScale = 0.7
|
|
||||||
layout.spacingMode = .overlap(visibleOffset: 30)
|
|
||||||
collectionView.collectionViewLayout = layout
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Media & Components
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>LCSnackBarView</b> — in-app notification banner</summary>
|
|
||||||
|
|
||||||
A chainable banner shown from the top or bottom of a controller. Configure with
|
|
||||||
`configure(...)` calls, then `present()`.
|
|
||||||
|
|
||||||
### `init(style: LCSnackBarViewType = .default, orientation: LCSnackBarOrientation = .top, delegate: LCSnackBarViewDelegate? = nil)`
|
|
||||||
|
|
||||||
### Enums
|
|
||||||
- `LCSnackBarViewType` — `.default` (rectangular) / `.rounded`
|
|
||||||
- `LCSnackBarOrientation` — `.top` / `.bottom`
|
|
||||||
- `LCSnackBarTimer: CGFloat` — `.infinity` (0, manual dismiss), `.minimum` (2s), `.medium` (5s), `.maximum` (10s)
|
|
||||||
|
|
||||||
### Configuration (each returns `Self`)
|
|
||||||
| Method | Sets |
|
|
||||||
|---|---|
|
|
||||||
| `configure(text: String)` | the message |
|
|
||||||
| `configure(textColor: UIColor)` | text colour |
|
|
||||||
| `configure(textFont: UIFont, alignment: NSTextAlignment = .center)` | font + alignment |
|
|
||||||
| `configure(backgroundColor: UIColor)` | banner background |
|
|
||||||
| `configure(exibition timer: LCSnackBarTimer)` | how long it stays |
|
|
||||||
| `configure(imageIconBefore icon: UIImageView, withTintColor: UIColor? = nil)` | leading icon |
|
|
||||||
|
|
||||||
### `func present(completion: (() -> Void)? = nil)`
|
|
||||||
Show on the top-most view controller.
|
|
||||||
|
|
||||||
### `weak var delegate: LCSnackBarViewDelegate?`
|
|
||||||
`snackbar(didStartExibition:)`, `snackbar(didTouchOn:)`, `snackbar(didEndExibition:)` — all optional.
|
|
||||||
|
|
||||||
### Full example
|
|
||||||
|
|
||||||
```swift
|
|
||||||
LCSnackBarView(style: .rounded, orientation: .bottom)
|
|
||||||
.configure(text: "Profile saved")
|
|
||||||
.configure(backgroundColor: .systemGreen)
|
|
||||||
.configure(exibition: .minimum)
|
|
||||||
.present()
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>ImagePickerController</b> — camera / photo-library picker with permissions</summary>
|
|
||||||
|
|
||||||
Wraps `UIImagePickerController`, handling camera & photo-library authorization
|
|
||||||
(including the "go to Settings" path) and presenting a source-choice alert.
|
|
||||||
|
|
||||||
### `init()`
|
|
||||||
### `weak var delegate: ImagePickerControllerDelegate?`
|
|
||||||
### `var isEditable: Bool` — allow in-picker cropping (default `false`)
|
|
||||||
### `func openImagePicker()`
|
|
||||||
Check permissions, then present the camera/library choice.
|
|
||||||
|
|
||||||
### `protocol ImagePickerControllerDelegate: AnyObject`
|
|
||||||
`imagePicker(didSelect image: UIImage?)` — the picked (or edited) image, or `nil` on cancel/failure.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let picker = ImagePickerController()
|
|
||||||
picker.delegate = self
|
|
||||||
picker.isEditable = true
|
|
||||||
present(picker, animated: false) { picker.openImagePicker() }
|
|
||||||
|
|
||||||
// ImagePickerControllerDelegate
|
|
||||||
func imagePicker(didSelect image: UIImage?) {
|
|
||||||
avatarView.image = image
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>ImageZoomController</b> — full-screen pinch-zoom / pan viewer</summary>
|
|
||||||
|
|
||||||
### `init(_ withImage: UIImage)`
|
|
||||||
### `var minimumZoomScale: CGFloat` (default `1.0`) / `var maximumZoomScale: CGFloat` (default `6.0`)
|
|
||||||
### `var addGestureToDismiss: Bool` (default `true`) — drag down to close
|
|
||||||
### `weak var delegate: ImageZoomControllerDelegate?`
|
|
||||||
### `func present(completion: (() -> Void)? = nil)` / `func dismiss(completion: (() -> Void)? = nil)`
|
|
||||||
Present from / dismiss to the top-most controller.
|
|
||||||
|
|
||||||
### `@objc protocol ImageZoomControllerDelegate`
|
|
||||||
`imageZoomController(controller:didZoom:)`, `imageZoomController(controller:didClose:)` — both optional.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let viewer = ImageZoomController(photo)
|
|
||||||
viewer.maximumZoomScale = 4
|
|
||||||
viewer.present()
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>GifHelper</b> — animated GIF loading</summary>
|
|
||||||
|
|
||||||
### `UIImageView.loadGif(name: String)` / `UIImageView.loadGif(asset: String)` *(iOS 9+)*
|
|
||||||
Decode a bundled `.gif` (or an asset-catalog data set) off the main thread and
|
|
||||||
assign the resulting animated `UIImage`.
|
|
||||||
|
|
||||||
```swift
|
|
||||||
bannerView.loadGif(name: "loading") // loading.gif in the bundle
|
|
||||||
bannerView.loadGif(asset: "confetti") // NSDataAsset "confetti"
|
|
||||||
```
|
|
||||||
|
|
||||||
### `UIImage.gif(data: Data) -> UIImage?` / `gif(url: String) -> UIImage?` / `gif(name: String) -> UIImage?` / `gif(asset: String) -> UIImage?`
|
|
||||||
Build an animated `UIImage` from GIF bytes / a URL string / a bundled file / an
|
|
||||||
asset-catalog entry. Frame delays are honoured (via the GCD of per-frame delays).
|
|
||||||
|
|
||||||
```swift
|
|
||||||
let spinner = UIImage.gif(name: "spinner")
|
|
||||||
imageView.image = spinner
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
3195
LCEssentials_DOCUMENTATION.md
Normal file
3195
LCEssentials_DOCUMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,5 +22,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
|
|
||||||
Autor: Daniel Arantes Loverde
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"originHash" : "6e3fcf8724d6b7d2d0bbffc25b4ec3b9d8b65a6ef53d157498d5447fd26cf5db",
|
|
||||||
"pins" : [
|
|
||||||
{
|
|
||||||
"identity" : "lcecryptokitbinary",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://60c260c85d3a2fe840411b0ff98f521b5eca3c56@git.loverde.com.br/Loverde-Company-LTDA/LCECryptoKitBinary.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "11f66b1b9785b4965e027c2fa4742f61e8453403",
|
|
||||||
"version" : "1.0.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version" : 3
|
|
||||||
}
|
|
||||||
@@ -1,46 +1,16 @@
|
|||||||
// swift-tools-version: 6.0
|
// swift-tools-version: 6.0
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
import Foundation
|
|
||||||
|
|
||||||
let isLocalDevelopment = false //FileManager.default.fileExists(atPath: "../LCECryptoKit/PrivateLib/LCECryptoKitBinary")
|
|
||||||
let enableCryptoBinary = ProcessInfo.processInfo.environment["LCE_ENABLE_CRYPTO_BINARY"] != "0"
|
|
||||||
|
|
||||||
let cryptoPackageURL = isLocalDevelopment
|
|
||||||
? "../LCECryptoKit/PrivateLib/LCECryptoKitBinary"
|
|
||||||
: "https://60c260c85d3a2fe840411b0ff98f521b5eca3c56@git.loverde.com.br/Loverde-Company-LTDA/LCECryptoKitBinary.git"
|
|
||||||
|
|
||||||
let packageDependencies: [Package.Dependency] = enableCryptoBinary
|
|
||||||
? [
|
|
||||||
.package(url: cryptoPackageURL, exact: "1.0.4")
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
|
|
||||||
let targetDependencies: [Target.Dependency] = enableCryptoBinary
|
|
||||||
? [
|
|
||||||
.product(name: "LCECryptoKit", package: "lcecryptokitbinary")
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "LCEssentials",
|
name: "LCEssentials",
|
||||||
platforms: [
|
|
||||||
.iOS(.v15),
|
|
||||||
.macOS(.v10_15),
|
|
||||||
.tvOS(.v13),
|
|
||||||
.watchOS(.v8)
|
|
||||||
],
|
|
||||||
products: [
|
products: [
|
||||||
.library(
|
.library(
|
||||||
name: "LCEssentials",
|
name: "LCEssentials",
|
||||||
targets: ["LCEssentials"]),
|
targets: ["LCEssentials"]),
|
||||||
],
|
],
|
||||||
dependencies: packageDependencies,
|
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
name: "LCEssentials",
|
name: "LCEssentials"),
|
||||||
dependencies: targetDependencies),
|
|
||||||
.testTarget(
|
|
||||||
name: "LCEssentialsTests",
|
|
||||||
dependencies: ["LCEssentials"]),
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -1,43 +1,41 @@
|
|||||||
|
|
||||||

|

|
||||||
|
Loverde Co. Essentials Swift Scripts
|
||||||
|
----
|
||||||
|
|
||||||
# Loverde Co. Essentials
|
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!
|
||||||
|
|
||||||
Essential Swift scripts, extensions, SwiftUI components, and UIKit-era helpers,
|
|
||||||
shared across Loverde Co. projects. Evolves with Swift, improves every release.
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
- iOS 15.* or newer, Swift 5.* or newer.
|
||||||
|
|
||||||
- iOS 15 or newer · Swift 5 or newer
|
## Features
|
||||||
|
- [x] Many usefull scripts extensions
|
||||||
|
|
||||||
## Installation — Swift Package Manager
|
|
||||||
|
|
||||||
|
Installation
|
||||||
|
----
|
||||||
|
#### Swift Package Manager (SPM)
|
||||||
``` swift
|
``` swift
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials", .upToNextMajor(from: "2.0.0"))
|
.package(url: "http://git.loverde.com.br:3000/git/LCEssentials.git", .upToNextMajor(from: "1.0.0"))
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
Or add it in Xcode via **File ▸ Add Package Dependencies…** with the URL:
|
You can also add it via XCode SPM editor with URL:
|
||||||
|
|
||||||
```
|
|
||||||
https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials
|
|
||||||
```
|
|
||||||
|
|
||||||
``` swift
|
``` swift
|
||||||
import LCEssentials
|
http://git.loverde.com.br:3000/git/LCEssentials.git
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Usage example
|
||||||
|
[Read full documentation](LCEssentials_DOCUMENTATION.md)
|
||||||
|
|
||||||
| Guide | Covers |
|
Author:
|
||||||
| --- | --- |
|
----
|
||||||
| **[API.md](Documentation/API.md)** | `API` networking — typed requests, multipart uploads, client certificates, error handling, and why it beats a hand-rolled `URLSession` |
|
|
||||||
| **[Extensions.md](Documentation/Extensions.md)** | Foundation / value-type / string / collection / numeric / date / crypto extensions and the `LCEssentials` namespace |
|
|
||||||
| **[SwiftUI.md](Documentation/SwiftUI.md)** | SwiftUI components (`LCENavigationView`) and `View` helpers |
|
|
||||||
| **[UIKit.md](Documentation/UIKit.md)** | Programmatic layout & constraints, view/control extensions, navigation, tables, and drop-in components (`LCSnackBarView`, image picker/zoom, GIF loading) |
|
|
||||||
|
|
||||||
---
|
Any question or doubts, please send thru email
|
||||||
|
|
||||||
Daniel Arantes Loverde — <daniel@loverde.com.br>
|
Daniel Arantes Loverde - <daniel@loverde.com.br>
|
||||||
|
|
||||||
[<img src="https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" width="28" alt="GitHub">](https://github.com/loverde-co/resume/)
|
[](https://github.com/loverde-co/resume/)
|
||||||
|
[](https://github.com/loverde-co)
|
||||||
|
|||||||
@@ -5,10 +5,8 @@
|
|||||||
// Created by iOSDevCenters on 11/12/15.
|
// Created by iOSDevCenters on 11/12/15.
|
||||||
// Copyright © 2016 iOSDevCenters. All rights reserved.
|
// Copyright © 2016 iOSDevCenters. All rights reserved.
|
||||||
//
|
//
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#if os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import ImageIO
|
import ImageIO
|
||||||
|
|
||||||
//let jeremyGif = UIImage.gifWithName("jeremy")
|
//let jeremyGif = UIImage.gifWithName("jeremy")
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
|
|
||||||
#if canImport(LCECryptoKit)
|
|
||||||
import LCECryptoKit
|
|
||||||
|
|
||||||
public final class LCECryptoKitManager {
|
|
||||||
|
|
||||||
private let hashKey: String
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
self.hashKey = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(privateKey: String){
|
|
||||||
self.hashKey = privateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func generateKey() -> String {
|
|
||||||
LCECryptoKit.generateRandomAESKeyString()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encodeTP(email: String, password: String) -> String? {
|
|
||||||
return LCECryptoKit.encodeSeed(email: email, password: password)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func decodeOTP(_ otpHash: String) -> String? {
|
|
||||||
return LCECryptoKit.decodeSeed(otpKey: otpHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Need hashKey to decode
|
|
||||||
|
|
||||||
public func encodeOTPWithKey(email: String, password: String) -> String? {
|
|
||||||
return LCECryptoKit.encodeSeed(email: email, password: password, hashKey: self.hashKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func decodeOTPWithKey(_ otpHash: String) -> Bool {
|
|
||||||
LCECryptoKit.decodeSeed(otpKey: otpHash, hashKey: self.hashKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Salted/Iterated/Peppered Login (atomenta-cryptokit-pepper-refactor-sdd.md)
|
|
||||||
|
|
||||||
public static func generateSalt() -> String {
|
|
||||||
LCECryptoKit.generateSalt()
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func computeClientHash(email: String, password: String, salt: String) -> String {
|
|
||||||
LCECryptoKit.computeClientHash(email: email, password: password, salt: salt)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func computeLoginBearerToken(userId: String, clientHash: String) -> String? {
|
|
||||||
LCECryptoKit.computeLoginBearerToken(userId: userId, clientHash: clientHash)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func otpEncode(_ plainText: String) -> String? {
|
|
||||||
LCECryptoKit.otpEncode(plainText)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func otpDecode(_ otpEncoded: String) -> String? {
|
|
||||||
LCECryptoKit.otpDecode(otpEncoded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
|
|
||||||
public final class LCECryptoKitManager {
|
|
||||||
|
|
||||||
private let hashKey: String
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
self.hashKey = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(privateKey: String){
|
|
||||||
self.hashKey = privateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func generateKey() -> String {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encodeTP(email: String, password: String) -> String? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func decodeOTP(_ otpHash: String) -> String? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encodeOTPWithKey(email: String, password: String) -> String? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func decodeOTPWithKey(_ otpHash: String) -> Bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Salted/Iterated/Peppered Login (atomenta-cryptokit-pepper-refactor-sdd.md)
|
|
||||||
|
|
||||||
public static func generateSalt() -> String {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func computeClientHash(email: String, password: String, salt: String) -> String {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func computeLoginBearerToken(userId: String, clientHash: String) -> String? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func otpEncode(_ plainText: String) -> String? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func otpDecode(_ otpEncoded: String) -> String? {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
|
|
||||||
/// A request payload plus the `Content-Type` it implies.
|
|
||||||
///
|
|
||||||
/// Adopt this to teach `API.request` a new body encoding without changing `API`.
|
|
||||||
public protocol HTTPBody: Sendable {
|
|
||||||
/// - Returns: the encoded bytes and the `Content-Type` header value to send with them.
|
|
||||||
func encoded() throws -> (data: Data, contentType: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - JSON
|
|
||||||
|
|
||||||
/// JSON-encodes an `Encodable` payload.
|
|
||||||
public struct JSONBody<Payload: Encodable & Sendable>: HTTPBody {
|
|
||||||
|
|
||||||
public let payload: Payload
|
|
||||||
private let encoder: @Sendable () -> JSONEncoder
|
|
||||||
|
|
||||||
/// - Parameters:
|
|
||||||
/// - payload: the value to encode.
|
|
||||||
/// - encoderProvider: builds the `JSONEncoder` to use. Defaults to a plain encoder.
|
|
||||||
public init(_ payload: Payload,
|
|
||||||
encoderProvider: @escaping @Sendable () -> JSONEncoder = { JSONEncoder() }) {
|
|
||||||
self.payload = payload
|
|
||||||
self.encoder = encoderProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encoded() throws -> (data: Data, contentType: String) {
|
|
||||||
(try encoder().encode(payload), "application/json; charset=UTF-8")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Form URL Encoded
|
|
||||||
|
|
||||||
/// `application/x-www-form-urlencoded` body. Every value is percent-escaped —
|
|
||||||
/// nothing is silently dropped for containing reserved characters.
|
|
||||||
public struct FormURLEncodedBody: HTTPBody {
|
|
||||||
|
|
||||||
public let fields: [String: String]
|
|
||||||
|
|
||||||
public init(_ fields: [String: String]) {
|
|
||||||
self.fields = fields
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encoded() throws -> (data: Data, contentType: String) {
|
|
||||||
var allowed = CharacterSet.alphanumerics
|
|
||||||
allowed.insert(charactersIn: "-._~") // RFC 3986 unreserved
|
|
||||||
|
|
||||||
let pairs: [String] = fields.map { key, value in
|
|
||||||
let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
|
|
||||||
let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
|
|
||||||
return "\(k)=\(v)"
|
|
||||||
}
|
|
||||||
let body = Data(pairs.joined(separator: "&").utf8)
|
|
||||||
return (body, "application/x-www-form-urlencoded; charset=UTF-8")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Raw
|
|
||||||
|
|
||||||
/// A pre-encoded body with an explicit content type.
|
|
||||||
public struct RawBody: HTTPBody {
|
|
||||||
|
|
||||||
public let data: Data
|
|
||||||
public let contentType: String
|
|
||||||
|
|
||||||
public init(data: Data, contentType: String) {
|
|
||||||
self.data = data
|
|
||||||
self.contentType = contentType
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encoded() throws -> (data: Data, contentType: String) {
|
|
||||||
(data, contentType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Factories
|
|
||||||
|
|
||||||
/// JSON body from any `Encodable & Sendable` value.
|
|
||||||
public func jsonBody<T: Encodable & Sendable>(_ value: T) -> JSONBody<T> {
|
|
||||||
JSONBody(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Form-url-encoded body from a string dictionary.
|
|
||||||
public func formBody(_ fields: [String: String]) -> FormURLEncodedBody {
|
|
||||||
FormURLEncodedBody(fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
public extension HTTPBody where Self == FormURLEncodedBody {
|
|
||||||
/// Call-site sugar for `request(body: .form([...]))`.
|
|
||||||
static func form(_ fields: [String: String]) -> FormURLEncodedBody {
|
|
||||||
FormURLEncodedBody(fields)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
|
|
||||||
/// A `multipart/form-data` body builder.
|
|
||||||
///
|
|
||||||
/// Text fields and small in-memory blobs are held as `Data`; on-disk files are
|
|
||||||
/// referenced by `URL` and streamed straight into the serialised body, so a
|
|
||||||
/// large upload never becomes fully resident in memory.
|
|
||||||
public struct MultipartForm: Sendable {
|
|
||||||
|
|
||||||
/// Where a part's content comes from.
|
|
||||||
public enum Source: Sendable {
|
|
||||||
case data(Data)
|
|
||||||
case file(URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Part: Sendable {
|
|
||||||
let name: String
|
|
||||||
let filename: String?
|
|
||||||
let mimeType: String?
|
|
||||||
let source: Source
|
|
||||||
/// `true` → plain form field: no `filename` / `Content-Type` header lines.
|
|
||||||
let isField: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Error thrown while serialising the body.
|
|
||||||
public enum SerializationError: Error {
|
|
||||||
case cannotCreateTempFile(URL)
|
|
||||||
case cannotOpenOutput(URL)
|
|
||||||
case cannotOpenInput(URL)
|
|
||||||
case writeFailed(underlying: Error?)
|
|
||||||
case readFailed(URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
private(set) var parts: [Part] = []
|
|
||||||
public let boundary: String
|
|
||||||
|
|
||||||
private static let chunkSize = 64 * 1024
|
|
||||||
private static let crlf = "\r\n"
|
|
||||||
|
|
||||||
/// - Parameter boundary: multipart boundary token. Defaults to a random value.
|
|
||||||
public init(boundary: String = "LCEssentials-\(UUID().uuidString)") {
|
|
||||||
self.boundary = boundary
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Building
|
|
||||||
|
|
||||||
/// Appends a plain text field.
|
|
||||||
public mutating func field(_ name: String, _ value: String) {
|
|
||||||
parts.append(Part(name: name, filename: nil, mimeType: nil,
|
|
||||||
source: .data(Data(value.utf8)), isField: true))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Appends an in-memory file part.
|
|
||||||
public mutating func file(_ name: String, data: Data, filename: String, mime: String? = nil) {
|
|
||||||
parts.append(Part(name: name, filename: filename,
|
|
||||||
mimeType: mime ?? Self.mimeType(for: filename),
|
|
||||||
source: .data(data), isField: false))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Appends an on-disk file part. The file is streamed at serialisation time.
|
|
||||||
public mutating func file(_ name: String, url: URL, filename: String? = nil, mime: String? = nil) {
|
|
||||||
let resolvedName = filename ?? url.lastPathComponent
|
|
||||||
parts.append(Part(name: name, filename: resolvedName,
|
|
||||||
mimeType: mime ?? Self.mimeType(for: resolvedName),
|
|
||||||
source: .file(url), isField: false))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Serialisation
|
|
||||||
|
|
||||||
/// Writes the whole body to a temporary file.
|
|
||||||
///
|
|
||||||
/// - Returns: the temp file URL (caller must delete it once the upload
|
|
||||||
/// finishes) and the `multipart/form-data; boundary=…` content type.
|
|
||||||
public func serialize() throws -> (fileURL: URL, contentType: String) {
|
|
||||||
let fileURL = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent("lce-multipart-\(UUID().uuidString).tmp")
|
|
||||||
|
|
||||||
guard FileManager.default.createFile(atPath: fileURL.path, contents: nil) else {
|
|
||||||
throw SerializationError.cannotCreateTempFile(fileURL)
|
|
||||||
}
|
|
||||||
guard let output = OutputStream(url: fileURL, append: false) else {
|
|
||||||
throw SerializationError.cannotOpenOutput(fileURL)
|
|
||||||
}
|
|
||||||
output.open()
|
|
||||||
defer { output.close() }
|
|
||||||
|
|
||||||
for part in parts {
|
|
||||||
try write(Data(header(for: part).utf8), to: output)
|
|
||||||
switch part.source {
|
|
||||||
case .data(let data):
|
|
||||||
try write(data, to: output)
|
|
||||||
case .file(let url):
|
|
||||||
try stream(fileAt: url, to: output)
|
|
||||||
}
|
|
||||||
try write(Data(Self.crlf.utf8), to: output)
|
|
||||||
}
|
|
||||||
try write(Data("--\(boundary)--\(Self.crlf)".utf8), to: output)
|
|
||||||
|
|
||||||
return (fileURL, "multipart/form-data; boundary=\(boundary)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func header(for part: Part) -> String {
|
|
||||||
var header = "--\(boundary)\(Self.crlf)"
|
|
||||||
header += "Content-Disposition: form-data; name=\"\(part.name)\""
|
|
||||||
if let filename = part.filename, !part.isField {
|
|
||||||
header += "; filename=\"\(filename)\""
|
|
||||||
}
|
|
||||||
header += Self.crlf
|
|
||||||
if !part.isField, let mime = part.mimeType {
|
|
||||||
header += "Content-Type: \(mime)\(Self.crlf)"
|
|
||||||
}
|
|
||||||
header += Self.crlf
|
|
||||||
return header
|
|
||||||
}
|
|
||||||
|
|
||||||
private func write(_ data: Data, to output: OutputStream) throws {
|
|
||||||
guard !data.isEmpty else { return }
|
|
||||||
var bytesRemaining = data
|
|
||||||
while !bytesRemaining.isEmpty {
|
|
||||||
let written = bytesRemaining.withUnsafeBytes { raw -> Int in
|
|
||||||
guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return -1 }
|
|
||||||
return output.write(base, maxLength: bytesRemaining.count)
|
|
||||||
}
|
|
||||||
guard written > 0 else {
|
|
||||||
throw SerializationError.writeFailed(underlying: output.streamError)
|
|
||||||
}
|
|
||||||
bytesRemaining.removeFirst(written)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stream(fileAt url: URL, to output: OutputStream) throws {
|
|
||||||
guard let input = InputStream(url: url) else {
|
|
||||||
throw SerializationError.cannotOpenInput(url)
|
|
||||||
}
|
|
||||||
input.open()
|
|
||||||
defer { input.close() }
|
|
||||||
|
|
||||||
var buffer = [UInt8](repeating: 0, count: Self.chunkSize)
|
|
||||||
while input.hasBytesAvailable {
|
|
||||||
let read = input.read(&buffer, maxLength: buffer.count)
|
|
||||||
if read == 0 { break }
|
|
||||||
guard read > 0 else { throw SerializationError.readFailed(url) }
|
|
||||||
try write(Data(buffer[0..<read]), to: output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - MIME
|
|
||||||
|
|
||||||
/// Best-effort MIME type from a file name's extension.
|
|
||||||
/// Falls back to `application/octet-stream`.
|
|
||||||
public static func mimeType(for path: String) -> String {
|
|
||||||
let ext = (path as NSString).pathExtension.lowercased()
|
|
||||||
return mimeTypes[ext] ?? "application/octet-stream"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static 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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
//
|
|
||||||
// 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(watchOS)
|
|
||||||
|
|
||||||
@available(iOS 13.0.0, *)
|
|
||||||
extension API {
|
|
||||||
|
|
||||||
/// Logs details of an outgoing network request for debugging purposes.
|
|
||||||
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("<======================================================================================>")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Logs details of an incoming network response for debugging purposes.
|
|
||||||
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) ?? "-")
|
|
||||||
}
|
|
||||||
logResponseError(error, data: data, statusCode: statusCode)
|
|
||||||
print("<======================================================================================>")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func logResponseError(_ error: Error?, data: Data?, statusCode: Int) {
|
|
||||||
if let 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, statusCode != 200 {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
//
|
|
||||||
// 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(watchOS)
|
|
||||||
|
|
||||||
@available(iOS 13.0.0, *)
|
|
||||||
public extension API {
|
|
||||||
|
|
||||||
/// Uploads a `multipart/form-data` body and decodes the JSON response.
|
|
||||||
///
|
|
||||||
/// The body is serialised to a temporary file and streamed from disk, so a
|
|
||||||
/// large file never becomes fully resident in memory. The temp file is
|
|
||||||
/// always removed before returning, on success and on throw.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
|
|
||||||
/// - method: The HTTP method. Defaults to `.post`.
|
|
||||||
/// - form: The multipart body (see ``MultipartForm``).
|
|
||||||
/// - pathParams: Values substituted into `{name}` placeholders in `url`.
|
|
||||||
/// - headers: Custom headers, merged over the defaults (custom wins). The
|
|
||||||
/// `Content-Type` is always set to the multipart type.
|
|
||||||
/// - debug: Print request/response debug logs. Defaults to `true`.
|
|
||||||
/// - timeoutInterval: Request timeout in seconds. Defaults to `120`.
|
|
||||||
/// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`.
|
|
||||||
/// - Returns: `T` decoded from the response body.
|
|
||||||
/// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP
|
|
||||||
/// status for non-2xx responses, or `DecodingError` on a malformed body.
|
|
||||||
func upload<T: Decodable & Sendable>(
|
|
||||||
url: String,
|
|
||||||
method: httpMethod = .post,
|
|
||||||
form: MultipartForm,
|
|
||||||
pathParams: [String: String] = [:],
|
|
||||||
headers: [String: String] = [:],
|
|
||||||
debug: Bool = true,
|
|
||||||
timeoutInterval: TimeInterval = 120,
|
|
||||||
networkServiceType: URLRequest.NetworkServiceType = .default
|
|
||||||
) async throws -> T {
|
|
||||||
try await runUpload(url: url, method: method, form: form, pathParams: pathParams,
|
|
||||||
headers: headers, debug: debug, timeout: timeoutInterval,
|
|
||||||
serviceType: networkServiceType, progressDelegate: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Multipart upload that reports progress.
|
|
||||||
///
|
|
||||||
/// Same as ``upload(url:method:form:pathParams:headers:debug:timeoutInterval:networkServiceType:)``
|
|
||||||
/// but calls `onProgress` with a fraction in `0.0...1.0` as bytes are sent,
|
|
||||||
/// then `1.0` once the body has been fully transmitted.
|
|
||||||
///
|
|
||||||
/// - Parameter onProgress: invoked on an arbitrary queue; hop to the main
|
|
||||||
/// actor yourself before touching UI.
|
|
||||||
func upload<T: Decodable & Sendable>(
|
|
||||||
url: String,
|
|
||||||
method: httpMethod = .post,
|
|
||||||
form: MultipartForm,
|
|
||||||
pathParams: [String: String] = [:],
|
|
||||||
headers: [String: String] = [:],
|
|
||||||
debug: Bool = true,
|
|
||||||
timeoutInterval: TimeInterval = 120,
|
|
||||||
networkServiceType: URLRequest.NetworkServiceType = .default,
|
|
||||||
onProgress: @escaping @Sendable (Double) -> Void
|
|
||||||
) async throws -> T {
|
|
||||||
let result: T = try await runUpload(url: url, method: method, form: form,
|
|
||||||
pathParams: pathParams, headers: headers, debug: debug,
|
|
||||||
timeout: timeoutInterval, serviceType: networkServiceType,
|
|
||||||
progressDelegate: UploadProgressDelegate(onProgress: onProgress))
|
|
||||||
onProgress(1.0)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Shared internals
|
|
||||||
|
|
||||||
@available(iOS 13.0.0, *)
|
|
||||||
extension API {
|
|
||||||
|
|
||||||
private func runUpload<T: Decodable & Sendable>(
|
|
||||||
url: String,
|
|
||||||
method: httpMethod,
|
|
||||||
form: MultipartForm,
|
|
||||||
pathParams: [String: String],
|
|
||||||
headers: [String: String],
|
|
||||||
debug: Bool,
|
|
||||||
timeout: TimeInterval,
|
|
||||||
serviceType: URLRequest.NetworkServiceType,
|
|
||||||
progressDelegate: UploadProgressDelegate?
|
|
||||||
) async throws -> T {
|
|
||||||
let prepared = try buildUploadRequest(url: url, method: method, form: form,
|
|
||||||
pathParams: pathParams, headers: headers,
|
|
||||||
timeout: timeout, serviceType: serviceType)
|
|
||||||
defer { try? FileManager.default.removeItem(at: prepared.bodyFile) }
|
|
||||||
|
|
||||||
if debug { API.requestLOG(method: method, request: prepared.request) }
|
|
||||||
let (session, mustInvalidate) = makeSession()
|
|
||||||
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
|
|
||||||
|
|
||||||
let (data, response) = try await session.upload(for: prepared.request,
|
|
||||||
fromFile: prepared.bodyFile,
|
|
||||||
delegate: progressDelegate)
|
|
||||||
return try API.finishUpload(data: data, response: response,
|
|
||||||
method: method, request: prepared.request, debug: debug)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func buildUploadRequest(
|
|
||||||
url: String,
|
|
||||||
method: httpMethod,
|
|
||||||
form: MultipartForm,
|
|
||||||
pathParams: [String: String],
|
|
||||||
headers: [String: String],
|
|
||||||
timeout: TimeInterval,
|
|
||||||
serviceType: URLRequest.NetworkServiceType
|
|
||||||
) throws -> (request: URLRequest, bodyFile: URL) {
|
|
||||||
let resolvedURL = try makeURL(url, pathParams: pathParams)
|
|
||||||
var request = buildRequest(url: resolvedURL, method: method, headers: headers,
|
|
||||||
timeout: timeout, serviceType: serviceType)
|
|
||||||
let serialized = try form.serialize()
|
|
||||||
request.setValue(serialized.contentType, forHTTPHeaderField: "Content-Type")
|
|
||||||
return (request, serialized.fileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate static func finishUpload<T: Decodable & Sendable>(
|
|
||||||
data: Data,
|
|
||||||
response: URLResponse,
|
|
||||||
method: httpMethod,
|
|
||||||
request: URLRequest,
|
|
||||||
debug: Bool
|
|
||||||
) throws -> T {
|
|
||||||
let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE
|
|
||||||
switch classify(code: code, data: data, method: method, request: request, debug: debug) {
|
|
||||||
case .success:
|
|
||||||
return try decodeResponse(data)
|
|
||||||
case .clientError, .otherError:
|
|
||||||
throw friendlyError(code: code, data: data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Forwards `URLSession` upload progress to a `@Sendable` closure. Immutable
|
|
||||||
/// after `init`, safe to hand to `URLSession` as a task delegate.
|
|
||||||
@available(iOS 13.0.0, *)
|
|
||||||
private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
|
||||||
|
|
||||||
private let onProgress: @Sendable (Double) -> Void
|
|
||||||
|
|
||||||
init(onProgress: @escaping @Sendable (Double) -> Void) {
|
|
||||||
self.onProgress = onProgress
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func urlSession(_ session: URLSession,
|
|
||||||
task: URLSessionTask,
|
|
||||||
didSendBodyData bytesSent: Int64,
|
|
||||||
totalBytesSent: Int64,
|
|
||||||
totalBytesExpectedToSend: Int64) {
|
|
||||||
guard totalBytesExpectedToSend > 0 else { return }
|
|
||||||
let fraction = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
|
|
||||||
onProgress(min(max(fraction, 0), 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -29,6 +29,14 @@ import Security
|
|||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/// A generic `Result` enumeration to represent either a success `Value` or a failure `Error`.
|
||||||
|
public enum Result<Value, Error: Swift.Error> {
|
||||||
|
/// Indicates a successful operation with an associated `Value`.
|
||||||
|
case success(Value)
|
||||||
|
/// Indicates a failed operation with an associated `Error`.
|
||||||
|
case failure(Error)
|
||||||
|
}
|
||||||
|
|
||||||
/// Enumeration defining common HTTP methods.
|
/// Enumeration defining common HTTP methods.
|
||||||
public enum httpMethod: String {
|
public enum httpMethod: String {
|
||||||
/// The POST method.
|
/// The POST method.
|
||||||
@@ -37,206 +45,216 @@ public enum httpMethod: String {
|
|||||||
case get = "GET"
|
case get = "GET"
|
||||||
/// The PUT method.
|
/// The PUT method.
|
||||||
case put = "PUT"
|
case put = "PUT"
|
||||||
/// The PATCH method.
|
|
||||||
case patch = "PATCH"
|
|
||||||
/// The DELETE method.
|
/// The DELETE method.
|
||||||
case delete = "DELETE"
|
case delete = "DELETE"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loverde Co.: API entry point for simple requests.
|
/// Loverde Co.: API generic struct for simple requests.
|
||||||
///
|
///
|
||||||
/// An `actor`, so callers are never forced onto the main thread. It performs
|
/// This struct provides a convenient way to perform network requests with various configurations,
|
||||||
/// network requests with various configurations, handling different HTTP
|
/// including handling different HTTP methods, parameter encoding, and certificate-based authentication.
|
||||||
/// methods, typed request bodies, multipart uploads, and certificate-based
|
|
||||||
/// authentication.
|
|
||||||
@available(iOS 13.0.0, *)
|
@available(iOS 13.0.0, *)
|
||||||
public actor API {
|
@MainActor
|
||||||
|
public struct API {
|
||||||
|
|
||||||
/// Client-certificate data (`.p12`) for mutual-TLS, if configured.
|
private static var certData: Data?
|
||||||
private var certData: Data?
|
private static var certPassword: String?
|
||||||
/// Password for `certData`, if any.
|
|
||||||
private var certPassword: String?
|
|
||||||
|
|
||||||
/// Session configuration used to build `URLSession`s. `nil` in production
|
|
||||||
/// (the shared session / a dedicated cert session is used); injected by
|
|
||||||
/// tests to register a stub `URLProtocol`.
|
|
||||||
private let sessionConfiguration: URLSessionConfiguration?
|
|
||||||
|
|
||||||
/// The default error used when an unexpected issue occurs during a request.
|
/// The default error used when an unexpected issue occurs during a request.
|
||||||
nonisolated static var defaultError: NSError {
|
static let defaultError = NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
|
||||||
NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
|
|
||||||
description: LCEssentials.DEFAULT_ERROR_MSG,
|
description: LCEssentials.DEFAULT_ERROR_MSG,
|
||||||
reasonForError: LCEssentials.DEFAULT_ERROR_MSG)
|
reasonForError: LCEssentials.DEFAULT_ERROR_MSG)
|
||||||
}
|
|
||||||
|
|
||||||
/// The delay in seconds before retrying a persistent connection request.
|
/// The delay in seconds before retrying a persistent connection request.
|
||||||
public private(set) var persistConnectionDelay: Double = 3
|
public static var persistConnectionDelay: Double = 3
|
||||||
|
|
||||||
|
/// Default parameters that will be included in all requests unless explicitly overridden.
|
||||||
|
public static var defaultParams: [String:Any] = [String: Any]()
|
||||||
|
|
||||||
/// Default HTTP headers for requests.
|
/// Default HTTP headers for requests.
|
||||||
///
|
///
|
||||||
/// By default, it includes "Accept", "Content-Type", and "Accept-Encoding" headers.
|
/// By default, it includes "Accept", "Content-Type", and "Accept-Encoding" headers.
|
||||||
nonisolated let defaultHeaders: [String: String] = ["Accept": "application/json",
|
var defaultHeaders: [String: String] = ["Accept": "application/json",
|
||||||
"Content-Type": "application/json; charset=UTF-8",
|
"Content-Type": "application/json; charset=UTF-8",
|
||||||
"Accept-Encoding": "gzip"]
|
"Accept-Encoding": "gzip"]
|
||||||
|
|
||||||
/// The shared singleton instance of `API`.
|
/// The shared singleton instance of the `API` struct.
|
||||||
public static let shared = API()
|
public static let shared = API()
|
||||||
|
|
||||||
private init() {
|
private init(){}
|
||||||
self.sessionConfiguration = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Test-only. Builds an isolated instance whose `URLSession`s use
|
/// Performs an asynchronous network request and decodes the response into a `Codable` type.
|
||||||
/// `configuration` — register a stub `URLProtocol` on it. Never call from
|
|
||||||
/// production code; `shared` state is untouched.
|
|
||||||
init(testConfiguration configuration: URLSessionConfiguration) {
|
|
||||||
self.sessionConfiguration = configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `true` when a client certificate has been supplied via ``setupCertification(certData:password:)``.
|
|
||||||
var hasClientCertificateConfigured: Bool { certData != nil }
|
|
||||||
|
|
||||||
/// Overrides ``persistConnectionDelay``.
|
|
||||||
public func setPersistConnectionDelay(_ seconds: Double) {
|
|
||||||
persistConnectionDelay = seconds
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximum number of extra attempts made when `persistConnection` is set and
|
|
||||||
/// the server keeps returning a 4xx. Bounds what was previously an unbounded
|
|
||||||
/// recursion on a permanent client error.
|
|
||||||
public static let maxPersistRetries = 3
|
|
||||||
|
|
||||||
/// Performs an asynchronous network request and decodes the JSON response.
|
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
|
/// - url: The URL string for the request.
|
||||||
/// - method: The HTTP method (`.get`, `.post`, `.put`, `.delete`, `.patch`).
|
/// - params: Optional parameters for the request. Can be `[String: Any]` for JSON/form-data, or `Data` for raw body.
|
||||||
/// - body: Optional typed request body (``JSONBody``, ``FormURLEncodedBody``,
|
/// - method: The HTTP method to use for the request (`.get`, `.post`, `.put`, `.delete`).
|
||||||
/// ``RawBody``, or any ``HTTPBody``). Its `Content-Type` and
|
/// - headers: Optional custom HTTP headers to be added to the request. These override default headers if there are conflicts.
|
||||||
/// `Content-Length` are set automatically.
|
/// - jsonEncoding: A boolean indicating whether parameters should be JSON encoded. Defaults to `true`.
|
||||||
/// - pathParams: Values substituted into `{name}` placeholders in `url`.
|
/// - debug: A boolean indicating whether to print debug logs for the request and response. Defaults to `true`.
|
||||||
/// - headers: Custom headers, merged over the defaults (custom wins).
|
/// - timeoutInterval: The timeout interval in seconds for the request. Defaults to `30`.
|
||||||
/// - debug: Print request/response debug logs. Defaults to `true`.
|
/// - networkServiceType: The `URLRequest.NetworkServiceType` for the request. Defaults to `.default`.
|
||||||
/// - timeoutInterval: Request timeout in seconds. Defaults to `30`.
|
/// - persistConnection: A boolean indicating whether to persist the connection on certain error codes (e.g., 4xx). Defaults to `false`.
|
||||||
/// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`.
|
/// - Returns: An instance of the `T` type, decoded from the response data.
|
||||||
/// - persistConnection: Retry (bounded by ``maxPersistRetries``) on a 4xx.
|
/// - Throws: An `Error` if the request fails, including `URLError` for network issues or `DecodingError` for JSON decoding failures.
|
||||||
/// - Returns: `T` decoded from the response body, or the raw string when `T == String`.
|
public func request<T: Codable>(url: String,
|
||||||
/// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP
|
params: Any? = nil,
|
||||||
/// status for non-2xx responses, or `DecodingError` on a malformed body.
|
|
||||||
public func request<T: Decodable & Sendable>(
|
|
||||||
url: String,
|
|
||||||
method: httpMethod,
|
method: httpMethod,
|
||||||
body: (any HTTPBody)? = nil,
|
|
||||||
pathParams: [String: String] = [:],
|
|
||||||
headers: [String: String] = [:],
|
headers: [String: String] = [:],
|
||||||
|
jsonEncoding: Bool = true,
|
||||||
debug: Bool = true,
|
debug: Bool = true,
|
||||||
timeoutInterval: TimeInterval = 30,
|
timeoutInterval: TimeInterval = 30,
|
||||||
networkServiceType: URLRequest.NetworkServiceType = .default,
|
networkServiceType: URLRequest.NetworkServiceType = .default,
|
||||||
persistConnection: Bool = false
|
persistConnection: Bool = false) async throws -> T {
|
||||||
) async throws -> T {
|
|
||||||
let resolvedURL = try makeURL(url, pathParams: pathParams)
|
if let urlReq = URL(string: url.replaceURL(params as? [String: Any] ?? [:] )) {
|
||||||
var urlRequest = buildRequest(url: resolvedURL, method: method, headers: headers,
|
var request = URLRequest(url: urlReq, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30)
|
||||||
timeout: timeoutInterval, serviceType: networkServiceType)
|
if method == .post || method == .put || method == .delete {
|
||||||
if let body {
|
if let params = params as? [String: Any],
|
||||||
try Self.attach(body: body, to: &urlRequest)
|
let pathFile = params["file"] as? String,
|
||||||
}
|
let fileURL = URL(string: pathFile) {
|
||||||
return try await send(urlRequest, method: method, debug: debug,
|
let boundary = UUID().uuidString
|
||||||
persistConnection: persistConnection, retriesLeft: Self.maxPersistRetries)
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
|
var body = Data()
|
||||||
|
|
||||||
|
// Add additional fields (if any)
|
||||||
|
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)!)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends a fully-built request, handles the response, and applies bounded
|
// Add the file
|
||||||
/// `persistConnection` retries.
|
let fileName = fileURL.lastPathComponent
|
||||||
private func send<T: Decodable & Sendable>(
|
let mimeType = mimeTypeForPath(path: fileName)
|
||||||
_ urlRequest: URLRequest,
|
do {
|
||||||
method: httpMethod,
|
let fileData = try Data(contentsOf: fileURL)
|
||||||
debug: Bool,
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
persistConnection: Bool,
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
|
||||||
retriesLeft: Int
|
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
|
||||||
) async throws -> T {
|
body.append(fileData)
|
||||||
if debug { API.requestLOG(method: method, request: urlRequest) }
|
body.append("\r\n".data(using: .utf8)!)
|
||||||
|
} catch {
|
||||||
let (session, mustInvalidate) = makeSession()
|
printError(title: "Upload File", msg: error.localizedDescription)
|
||||||
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
|
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: urlRequest)
|
|
||||||
let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE
|
|
||||||
|
|
||||||
switch Self.classify(code: code, data: data, method: method, request: urlRequest, debug: debug) {
|
|
||||||
case .success:
|
|
||||||
return try Self.decodeResponse(data)
|
|
||||||
case .clientError where persistConnection && retriesLeft > 0:
|
|
||||||
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST (\(retriesLeft) left)")
|
|
||||||
return try await send(urlRequest, method: method, debug: debug,
|
|
||||||
persistConnection: persistConnection, retriesLeft: retriesLeft - 1)
|
|
||||||
case .clientError, .otherError:
|
|
||||||
throw Self.friendlyError(code: code, data: data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Request building
|
// Finalize the request body
|
||||||
|
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
func makeURL(_ template: String, pathParams: [String: String]) throws -> URL {
|
request.httpBody = body
|
||||||
guard let url = URL(string: template.replaceURL(pathParams)) else { throw API.defaultError }
|
request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
|
||||||
return url
|
// Debug logs
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildRequest(url: URL,
|
|
||||||
method: httpMethod,
|
|
||||||
headers: [String: String],
|
|
||||||
timeout: TimeInterval,
|
|
||||||
serviceType: URLRequest.NetworkServiceType) -> URLRequest {
|
|
||||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout)
|
|
||||||
request.httpMethod = method.rawValue
|
request.httpMethod = method.rawValue
|
||||||
request.timeoutInterval = timeout
|
request.timeoutInterval = timeoutInterval
|
||||||
request.networkServiceType = serviceType
|
request.networkServiceType = networkServiceType
|
||||||
defaultHeaders.merging(headers) { _, custom in custom }
|
|
||||||
.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
// - Put Default Headers together with user defined params
|
||||||
return request
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the body plus its `Content-Type` and `Content-Length`. The body's own
|
|
||||||
/// content type wins over any set through `headers`.
|
|
||||||
private static func attach(body: any HTTPBody, to request: inout URLRequest) throws {
|
|
||||||
let (data, contentType) = try body.encoded()
|
|
||||||
request.httpBody = data
|
|
||||||
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
|
||||||
request.setValue("\(data.count)", forHTTPHeaderField: "Content-Length")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Response handling
|
|
||||||
|
|
||||||
enum ResponseDisposition { case success, clientError, otherError }
|
|
||||||
|
|
||||||
static func classify(code: Int, data: Data, method: httpMethod,
|
|
||||||
request: URLRequest, debug: Bool) -> ResponseDisposition {
|
|
||||||
let error = (200..<300).contains(code) ? nil : URLError(URLError.Code(rawValue: code))
|
|
||||||
if debug {
|
if debug {
|
||||||
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
API.requestLOG(method: method, request: request)
|
||||||
}
|
|
||||||
switch code {
|
|
||||||
case 200..<300: return .success
|
|
||||||
case 400..<500: return .clientError
|
|
||||||
default: return .otherError
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func decodeResponse<T: Decodable & Sendable>(_ data: Data) throws -> T {
|
let session = URLSession(
|
||||||
if T.self == String.self, let string = String(data: data, encoding: .utf8) as? T {
|
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
|
return string
|
||||||
}
|
}
|
||||||
|
// - Normal decoding
|
||||||
do {
|
do {
|
||||||
return try JSONDecoder.decode(data: data)
|
return try JSONDecoder.decode(data: data)
|
||||||
} catch {
|
} catch {
|
||||||
printError(title: "JSONDecoder", msg: error.localizedDescription)
|
printError(title: "JSONDecoder", msg: error.localizedDescription)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
case 400..<500:
|
||||||
|
// - Debug LOG
|
||||||
|
if debug {
|
||||||
|
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
||||||
}
|
}
|
||||||
|
if persistConnection {
|
||||||
static func friendlyError(code: Int, data: Data) -> NSError {
|
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST")
|
||||||
let urlError = URLError(URLError.Code(rawValue: code))
|
// Recursive call for persistence
|
||||||
return NSError.createErrorWith(code: code,
|
let persist: T = try await self.request(
|
||||||
description: urlError.localizedDescription,
|
url: url,
|
||||||
reasonForError: data.prettyJson ?? "")
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets up client certificate data and an optional password for authentication.
|
/// Sets up client certificate data and an optional password for authentication.
|
||||||
@@ -244,52 +262,21 @@ public actor API {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - certData: The `Data` representation of the client certificate (e.g., a .p12 file).
|
/// - certData: The `Data` representation of the client certificate (e.g., a .p12 file).
|
||||||
/// - password: The password for the certificate, if required. Defaults to an empty string.
|
/// - password: The password for the certificate, if required. Defaults to an empty string.
|
||||||
public func setupCertification(certData: Data, password: String = "") {
|
public func setupCertificationRequest(certData: Data, password: String = "") {
|
||||||
self.certData = certData
|
API.certData = certData
|
||||||
self.certPassword = password
|
API.certPassword = password
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the `URLSession` for one request.
|
|
||||||
///
|
|
||||||
/// - A test configuration (if injected) always wins, carrying the cert
|
|
||||||
/// delegate when one is configured.
|
|
||||||
/// - Otherwise a dedicated, delegate-backed session is created only when a
|
|
||||||
/// client certificate is configured, and must be invalidated afterwards.
|
|
||||||
/// - The common no-cert case reuses `URLSession.shared`.
|
|
||||||
///
|
|
||||||
/// - Returns: the session and whether the caller must invalidate it.
|
|
||||||
func makeSession() -> (session: URLSession, mustInvalidate: Bool) {
|
|
||||||
#if canImport(Security)
|
|
||||||
let delegate: URLSessionDelegateHandler? = certData != nil
|
|
||||||
? URLSessionDelegateHandler(certData: certData, password: certPassword)
|
|
||||||
: nil
|
|
||||||
#else
|
|
||||||
let delegate: URLSessionDelegate? = nil
|
|
||||||
#endif
|
|
||||||
|
|
||||||
if let configuration = sessionConfiguration {
|
|
||||||
return (URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil), true)
|
|
||||||
}
|
|
||||||
if delegate != nil {
|
|
||||||
return (URLSession(configuration: .default, delegate: delegate, delegateQueue: nil), true)
|
|
||||||
}
|
|
||||||
return (URLSession.shared, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(Security)
|
#if canImport(Security)
|
||||||
/// A custom `URLSessionDelegate` handler for managing URL session challenges,
|
/// A custom `URLSessionDelegate` handler for managing URL session challenges,
|
||||||
/// particularly for client and server trust authentication.
|
/// particularly for client and server trust authentication.
|
||||||
///
|
|
||||||
/// Immutable after `init`, so it is safe to hand to `URLSession` and have its
|
|
||||||
/// challenge callback invoked on any thread. The auth logic touches only the
|
|
||||||
/// Security framework, which is thread-safe.
|
|
||||||
@available(iOS 13.0.0, *)
|
@available(iOS 13.0.0, *)
|
||||||
private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @unchecked Sendable {
|
@MainActor
|
||||||
|
private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
|
||||||
|
|
||||||
private let certData: Data?
|
private var certData: Data?
|
||||||
private let certPass: String?
|
private var certPass: String?
|
||||||
|
|
||||||
/// Initializes a new `URLSessionDelegateHandler` instance.
|
/// Initializes a new `URLSessionDelegateHandler` instance.
|
||||||
///
|
///
|
||||||
@@ -297,9 +284,9 @@ private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @un
|
|||||||
/// - certData: Optional `Data` for the client certificate.
|
/// - certData: Optional `Data` for the client certificate.
|
||||||
/// - password: Optional password for the client certificate.
|
/// - password: Optional password for the client certificate.
|
||||||
init(certData: Data? = nil, password: String? = nil) {
|
init(certData: Data? = nil, password: String? = nil) {
|
||||||
|
super.init()
|
||||||
self.certData = certData
|
self.certData = certData
|
||||||
self.certPass = password
|
self.certPass = password
|
||||||
super.init()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles URL session authentication challenges.
|
/// Handles URL session authentication challenges.
|
||||||
@@ -346,29 +333,16 @@ private final class URLSessionDelegateHandler: NSObject, URLSessionDelegate, @un
|
|||||||
// Import the .p12 certificate to get the identity
|
// Import the .p12 certificate to get the identity
|
||||||
let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items)
|
let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items)
|
||||||
|
|
||||||
guard status == errSecSuccess,
|
if status == errSecSuccess,
|
||||||
let items,
|
let item = (items as? [[String: Any]])?.first,
|
||||||
CFArrayGetCount(items) > 0,
|
let identityRef = item[kSecImportItemIdentity as String] as CFTypeRef?,
|
||||||
let itemPtr = CFArrayGetValueAtIndex(items, 0) else {
|
CFGetTypeID(identityRef) == SecIdentityGetTypeID() {
|
||||||
printError(title: "Certificate", msg: "Failed to import client identity from .p12 (status: \(status))")
|
return (identityRef as! SecIdentity)
|
||||||
|
} else {
|
||||||
|
print("Erro ao importar a identidade do certificado: \(status)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the identity straight out of the CF result via typed `Unmanaged`
|
|
||||||
// bridging — no `as!`. `SecPKCS12Import` boxes a CoreFoundation ref that
|
|
||||||
// Swift will not let us downcast without a force operation.
|
|
||||||
let itemDict = Unmanaged<CFDictionary>.fromOpaque(itemPtr).takeUnretainedValue()
|
|
||||||
let identityKey = Unmanaged.passUnretained(kSecImportItemIdentity).toOpaque()
|
|
||||||
guard let identityPtr = CFDictionaryGetValue(itemDict, identityKey) else {
|
|
||||||
printError(title: "Certificate", msg: "Imported .p12 contained no client identity")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let identity = Unmanaged<SecIdentity>.fromOpaque(identityPtr).takeUnretainedValue()
|
|
||||||
guard CFGetTypeID(identity) == SecIdentityGetTypeID() else {
|
|
||||||
printError(title: "Certificate", msg: "Imported .p12 entry is not a SecIdentity")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return identity
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if the provided data is a valid PKCS#12 (P12) certificate with the given password.
|
/// Checks if the provided data is a valid PKCS#12 (P12) certificate with the given password.
|
||||||
@@ -397,4 +371,133 @@ extension Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 13.0.0, *)
|
||||||
|
extension API {
|
||||||
|
|
||||||
|
/// Logs details of an outgoing network request for debugging purposes.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - method: The HTTP method of the request.
|
||||||
|
/// - request: The `URLRequest` object.
|
||||||
|
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("<======================================================================================>")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logs details of an incoming network response for debugging purposes.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - method: The HTTP method of the original request.
|
||||||
|
/// - request: The `URLRequest` object that generated this response.
|
||||||
|
/// - data: The data received in the response.
|
||||||
|
/// - statusCode: The HTTP status code of the response.
|
||||||
|
/// - error: An optional `Error` object if the request failed.
|
||||||
|
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("<======================================================================================>")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines the MIME type for a given file path based on its extension.
|
||||||
|
///
|
||||||
|
/// - Parameter path: The file path string.
|
||||||
|
/// - Returns: A string representing the MIME type. Defaults to "application/octet-stream" if the type is unknown.
|
||||||
|
func mimeTypeForPath(path: String) -> String {
|
||||||
|
let url = URL(fileURLWithPath: path)
|
||||||
|
let pathExtension = url.pathExtension.lowercased()
|
||||||
|
|
||||||
|
// Dictionary of common extensions and MIME types
|
||||||
|
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"
|
||||||
|
]
|
||||||
|
|
||||||
|
// Returns the corresponding MIME type for the extension, or "application/octet-stream" as default
|
||||||
|
return mimeTypes[pathExtension] ?? "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -21,12 +21,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
#if canImport(AVFoundation)
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
#endif
|
|
||||||
#if os(watchOS)
|
#if os(watchOS)
|
||||||
import WatchKit
|
import WatchKit
|
||||||
#endif
|
#endif
|
||||||
@@ -159,7 +155,7 @@ public struct LCEssentials {
|
|||||||
diskPath: "file_cache"
|
diskPath: "file_cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
/// Extract the file name from the file path
|
/// Extract the file name from the file path
|
||||||
///
|
///
|
||||||
/// - Parameter filePath: Full file path in bundle
|
/// - Parameter filePath: Full file path in bundle
|
||||||
@@ -270,11 +266,7 @@ public struct LCEssentials {
|
|||||||
/// - LoverdeCo: Check if app is running in debug mode.
|
/// - LoverdeCo: Check if app is running in debug mode.
|
||||||
@MainActor
|
@MainActor
|
||||||
public static var isInDebuggingMode: Bool {
|
public static var isInDebuggingMode: Bool {
|
||||||
#if canImport(UIKit)
|
|
||||||
return UIApplication.inferredEnvironment == .debug
|
return UIApplication.inferredEnvironment == .debug
|
||||||
#else
|
|
||||||
return false
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@@ -424,7 +416,7 @@ public struct LCEssentials {
|
|||||||
// MARK: - Methods
|
// MARK: - Methods
|
||||||
public extension LCEssentials {
|
public extension LCEssentials {
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
/// - LoverdeCo: Share link with message
|
/// - LoverdeCo: Share link with message
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
|
|||||||
@@ -20,9 +20,7 @@
|
|||||||
// THE SOFTWARE.
|
// THE SOFTWARE.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
@objc public protocol LCESingletonDelegate: AnyObject {
|
@objc public protocol LCESingletonDelegate: AnyObject {
|
||||||
@objc optional func singleton(object: Any?, withData: Any)
|
@objc optional func singleton(object: Any?, withData: Any)
|
||||||
|
|||||||
@@ -21,9 +21,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Methods (Equatable)
|
// MARK: - Methods (Equatable)
|
||||||
public extension Array where Element: Equatable {
|
public extension Array where Element: Equatable {
|
||||||
@@ -150,7 +148,6 @@ public extension Array where Element: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
extension Array where Element == NSLayoutConstraint {
|
extension Array where Element == NSLayoutConstraint {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -172,5 +169,3 @@ extension Array where Element == NSLayoutConstraint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -21,12 +21,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(CryptoKit)
|
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
#endif
|
|
||||||
#if canImport(CommonCrypto)
|
|
||||||
import CommonCrypto
|
import CommonCrypto
|
||||||
#endif
|
|
||||||
|
|
||||||
|
|
||||||
public extension Data {
|
public extension Data {
|
||||||
@@ -75,25 +71,17 @@ public extension Data {
|
|||||||
|
|
||||||
@available(iOS 13.0, *)
|
@available(iOS 13.0, *)
|
||||||
func HMACSHA512(key: Data) -> Data {
|
func HMACSHA512(key: Data) -> Data {
|
||||||
#if canImport(CryptoKit)
|
|
||||||
var hmac = HMAC<SHA512>.init(key: SymmetricKey(data: key))
|
var hmac = HMAC<SHA512>.init(key: SymmetricKey(data: key))
|
||||||
hmac.update(data: self)
|
hmac.update(data: self)
|
||||||
return Data(hmac.finalize())
|
return Data(hmac.finalize())
|
||||||
#else
|
|
||||||
return Data()
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func SHA512() -> Data {
|
func SHA512() -> Data {
|
||||||
#if canImport(CommonCrypto)
|
|
||||||
var digest = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH))
|
var digest = [UInt8](repeating: 0, count: Int(CC_SHA512_DIGEST_LENGTH))
|
||||||
self.withUnsafeBytes {
|
self.withUnsafeBytes {
|
||||||
_ = CC_SHA512($0.baseAddress, CC_LONG(self.count), &digest)
|
_ = CC_SHA512($0.baseAddress, CC_LONG(self.count), &digest)
|
||||||
}
|
}
|
||||||
return Data(digest)
|
return Data(digest)
|
||||||
#else
|
|
||||||
return Data()
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func XOR(with other: Data) -> Data {
|
func XOR(with other: Data) -> Data {
|
||||||
@@ -101,18 +89,14 @@ public extension Data {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func SHA256() -> Data {
|
func SHA256() -> Data {
|
||||||
#if canImport(CommonCrypto)
|
|
||||||
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
||||||
self.withUnsafeBytes {
|
self.withUnsafeBytes {
|
||||||
_ = CC_SHA256($0.baseAddress, CC_LONG(self.count), &digest)
|
_ = CC_SHA256($0.baseAddress, CC_LONG(self.count), &digest)
|
||||||
}
|
}
|
||||||
return Data(digest)
|
return Data(digest)
|
||||||
#else
|
|
||||||
return Data()
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func object<T: Codable & Sendable>() -> T? {
|
func object<T: Codable>() -> T? {
|
||||||
do {
|
do {
|
||||||
let outPut: T = try JSONDecoder.decode(data: self)
|
let outPut: T = try JSONDecoder.decode(data: self)
|
||||||
return outPut
|
return outPut
|
||||||
@@ -140,13 +124,9 @@ public extension Data {
|
|||||||
///print("md5Hex: \(md5Hex)")
|
///print("md5Hex: \(md5Hex)")
|
||||||
@available(iOS 13.0, *)
|
@available(iOS 13.0, *)
|
||||||
static func MD5(string: String) -> Data {
|
static func MD5(string: String) -> Data {
|
||||||
#if canImport(CryptoKit)
|
|
||||||
let messageData = string.data(using: .utf8)!
|
let messageData = string.data(using: .utf8)!
|
||||||
let digestData = Insecure.MD5.hash (data: messageData)
|
let digestData = Insecure.MD5.hash (data: messageData)
|
||||||
let digestHex = String(digestData.map { String(format: "%02hhx", $0) }.joined().prefix(32))
|
let digestHex = String(digestData.map { String(format: "%02hhx", $0) }.joined().prefix(32))
|
||||||
return Data(digestHex.utf8)
|
return Data(digestHex.utf8)
|
||||||
#else
|
|
||||||
return Data()
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
public extension Dictionary {
|
public extension Dictionary {
|
||||||
|
|
||||||
@@ -137,10 +135,11 @@ public extension Dictionary {
|
|||||||
|
|
||||||
/// - LoverdeCo: Convert Dictonary to Object
|
/// - LoverdeCo: Convert Dictonary to Object
|
||||||
///
|
///
|
||||||
/// - returns: Object: Decodable & Sendable
|
/// - returns: Object: Codable/Decodable
|
||||||
/// - throws: `DecodingError` when the dictionary does not match `T`.
|
func toObjetct<T: Codable>() -> T {
|
||||||
func toObjetct<T: Codable & Sendable>() throws -> T {
|
let jsonString = self.convertToJSON
|
||||||
try JSONDecoder.decode(self.convertToJSON)
|
let output: T = try! JSONDecoder.decode(jsonString)
|
||||||
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if key exists in dictionary.
|
/// Check if key exists in dictionary.
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ extension JSONDecoder {
|
|||||||
/// - LoverdeCo: Decode JSON Data to Object
|
/// - LoverdeCo: Decode JSON Data to Object
|
||||||
///
|
///
|
||||||
/// - Parameter data: Data
|
/// - Parameter data: Data
|
||||||
/// - returns: Object: Decodable & Sendable
|
/// - returns: Object: Codable/Decodable
|
||||||
public static func decode<T: Decodable & Sendable>(data: Data) throws -> T {
|
public static func decode<T: Codable>(data: Data) throws -> T {
|
||||||
var error = NSError(domain: "", code: 0)
|
var error = NSError(domain: "", code: 0)
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
decoder.keyDecodingStrategy = .useDefaultKeys
|
decoder.keyDecodingStrategy = .useDefaultKeys
|
||||||
@@ -70,24 +70,28 @@ extension JSONDecoder {
|
|||||||
/// - LoverdeCo: Decode JSON String to Object
|
/// - LoverdeCo: Decode JSON String to Object
|
||||||
///
|
///
|
||||||
/// - Parameter json: String
|
/// - Parameter json: String
|
||||||
/// - returns: Object: Decodable & Sendable
|
/// - returns: Object: Codable/Decodable
|
||||||
public static func decode<T: Decodable & Sendable>(_ json: String, using encoding: String.Encoding = .utf8) throws -> T {
|
public static func decode<T: Codable>(_ json: String, using encoding: String.Encoding = .utf8) throws -> T {
|
||||||
guard let jsonData = json.data(using: encoding) else {
|
var error = NSError()
|
||||||
let msg = "Could not convert string to \(encoding) data for \(T.self)"
|
if let jsonData = json.data(using: .utf8) {
|
||||||
throw NSError.createErrorWith(code: 0, description: msg, reasonForError: msg)
|
do {
|
||||||
}
|
|
||||||
return try decode(data: jsonData)
|
return try decode(data: jsonData)
|
||||||
|
} catch {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
/// - LoverdeCo: Decode JSON URL to Object
|
/// - LoverdeCo: Decode JSON URL to Object
|
||||||
///
|
///
|
||||||
/// - Parameter url: URL
|
/// - Parameter url: URL
|
||||||
/// - returns: Object: Decodable & Sendable
|
/// - returns: Object: Codable/Decodable
|
||||||
public static func decode<T: Decodable & Sendable>(fromURL url: URL) throws -> T {
|
public static func decode<T: Codable>(fromURL url: URL) throws -> T {
|
||||||
return try decode(data: Data(contentsOf: url))
|
return try decode(data: try! Data(contentsOf: url))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func decode<T: Decodable & Sendable>(dictionary: Any) throws -> T {
|
public static func decode<T: Codable>(dictionary: Any) throws -> T {
|
||||||
do {
|
do {
|
||||||
let json = try JSONSerialization.data(withJSONObject: dictionary)
|
let json = try JSONSerialization.data(withJSONObject: dictionary)
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
|
|||||||
@@ -21,9 +21,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
public extension FileManager {
|
public extension FileManager {
|
||||||
|
|
||||||
@@ -56,7 +54,7 @@ public extension FileManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
func saveImageToDirectory( _ imageWithPath : String, imagem : UIImage ) -> Bool {
|
func saveImageToDirectory( _ imageWithPath : String, imagem : UIImage ) -> Bool {
|
||||||
|
|
||||||
let data = imagem.pngData()
|
let data = imagem.pngData()
|
||||||
|
|||||||
@@ -21,11 +21,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
extension NSLayoutConstraint {
|
extension NSLayoutConstraint {
|
||||||
|
|
||||||
func constraintWithMultiplier(_ multiplier: CGFloat) -> NSLayoutConstraint {
|
func constraintWithMultiplier(_ multiplier: CGFloat) -> NSLayoutConstraint {
|
||||||
@@ -75,4 +72,3 @@ extension NSLayoutConstraint {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -21,10 +21,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
public extension NSMutableAttributedString {
|
public extension NSMutableAttributedString {
|
||||||
@discardableResult func customize(_ text: String,
|
@discardableResult func customize(_ text: String,
|
||||||
|
|||||||
@@ -20,9 +20,7 @@
|
|||||||
// THE SOFTWARE.
|
// THE SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
public extension Optional {
|
public extension Optional {
|
||||||
/// Get self of default value (if self is nil).
|
/// Get self of default value (if self is nil).
|
||||||
|
|||||||
@@ -21,9 +21,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
#if canImport(CommonCrypto)
|
#if canImport(CommonCrypto)
|
||||||
import CommonCrypto
|
import CommonCrypto
|
||||||
#endif
|
#endif
|
||||||
@@ -50,14 +48,10 @@ public extension String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var convertToHTML: NSAttributedString? {
|
var convertToHTML: NSAttributedString? {
|
||||||
#if canImport(UIKit)
|
|
||||||
return convertHtmlToAttributedStringWithCSS(font: nil,
|
return convertHtmlToAttributedStringWithCSS(font: nil,
|
||||||
csscolor: "",
|
csscolor: "",
|
||||||
lineheight: 0,
|
lineheight: 0,
|
||||||
csstextalign: "")
|
csstextalign: "")
|
||||||
#else
|
|
||||||
return convertHtmlToNSAttributedString
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if string is a valid URL.
|
/// Check if string is a valid URL.
|
||||||
@@ -652,7 +646,7 @@ public extension String {
|
|||||||
return strDate?.date(withCurrFormatt: newFormatt, localeIdentifier: localeIdentifier, timeZone: timeZone)
|
return strDate?.date(withCurrFormatt: newFormatt, localeIdentifier: localeIdentifier, timeZone: timeZone)
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
|
func height(withConstrainedWidth width: CGFloat, font: UIFont) -> CGFloat {
|
||||||
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
|
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
|
||||||
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)
|
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: [NSAttributedString.Key.font: font], context: nil)
|
||||||
@@ -763,7 +757,7 @@ public extension String {
|
|||||||
}
|
}
|
||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
#if canImport(UIKit)
|
|
||||||
/// Converte String para HTML com CSS.
|
/// Converte String para HTML com CSS.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
@@ -807,7 +801,6 @@ public extension String {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Float value from string (if applicable).
|
/// Float value from string (if applicable).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -19,10 +19,8 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
// THE SOFTWARE.
|
// THE SOFTWARE.
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit) && os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
public extension UIApplication {
|
public extension UIApplication {
|
||||||
/// Application running environment.
|
/// Application running environment.
|
||||||
|
|||||||
@@ -21,11 +21,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
public extension UIButton {
|
public extension UIButton {
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
public extension UICollectionView {
|
public extension UICollectionView {
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
public extension UIColor {
|
public extension UIColor {
|
||||||
|
|
||||||
var redValue: CGFloat{ return CIColor(color: self).red }
|
var redValue: CGFloat{ return CIColor(color: self).red }
|
||||||
|
|||||||
@@ -21,11 +21,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
public extension UIDevice {
|
public extension UIDevice {
|
||||||
|
|
||||||
static var topNotch: CGFloat {
|
static var topNotch: CGFloat {
|
||||||
@@ -80,6 +78,7 @@ public extension UIDevice {
|
|||||||
case "iPhone8,2": return "iPhone 6s Plus"
|
case "iPhone8,2": return "iPhone 6s Plus"
|
||||||
case "iPhone9,1", "iPhone9,3": return "iPhone 7"
|
case "iPhone9,1", "iPhone9,3": return "iPhone 7"
|
||||||
case "iPhone9,2", "iPhone9,4": return "iPhone 7 Plus"
|
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,1", "iPhone10,4": return "iPhone 8"
|
||||||
case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus"
|
case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus"
|
||||||
case "iPhone10,3", "iPhone10,6": return "iPhone X"
|
case "iPhone10,3", "iPhone10,6": return "iPhone X"
|
||||||
@@ -93,76 +92,30 @@ public extension UIDevice {
|
|||||||
case "iPhone13,2": return "iPhone 12"
|
case "iPhone13,2": return "iPhone 12"
|
||||||
case "iPhone13,3": return "iPhone 12 Pro"
|
case "iPhone13,3": return "iPhone 12 Pro"
|
||||||
case "iPhone13,4": return "iPhone 12 Pro Max"
|
case "iPhone13,4": return "iPhone 12 Pro Max"
|
||||||
case "iPhone14,4": return "iPhone 13 mini"
|
|
||||||
case "iPhone14,5": return "iPhone 13"
|
|
||||||
case "iPhone14,2": return "iPhone 13 Pro"
|
|
||||||
case "iPhone14,3": return "iPhone 13 Pro Max"
|
|
||||||
case "iPhone14,7": return "iPhone 14"
|
|
||||||
case "iPhone14,8": return "iPhone 14 Plus"
|
|
||||||
case "iPhone15,2": return "iPhone 14 Pro"
|
|
||||||
case "iPhone15,3": return "iPhone 14 Pro Max"
|
|
||||||
case "iPhone15,4": return "iPhone 15"
|
|
||||||
case "iPhone15,5": return "iPhone 15 Plus"
|
|
||||||
case "iPhone16,1": return "iPhone 15 Pro"
|
|
||||||
case "iPhone16,2": return "iPhone 15 Pro Max"
|
|
||||||
case "iPhone17,3": return "iPhone 16"
|
|
||||||
case "iPhone17,4": return "iPhone 16 Plus"
|
|
||||||
case "iPhone17,1": return "iPhone 16 Pro"
|
|
||||||
case "iPhone17,2": return "iPhone 16 Pro Max"
|
|
||||||
case "iPhone17,5": return "iPhone 16e"
|
|
||||||
case "iPhone18,3": return "iPhone 17"
|
|
||||||
case "iPhone18,4": return "iPhone Air"
|
|
||||||
case "iPhone18,1": return "iPhone 17 Pro"
|
|
||||||
case "iPhone18,2": return "iPhone 17 Pro Max"
|
|
||||||
case "iPhone8,4": return "iPhone SE"
|
|
||||||
case "iPhone12,8": return "iPhone SE (2nd generation)"
|
|
||||||
case "iPhone14,6": return "iPhone SE (3rd generation)"
|
|
||||||
case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4":return "iPad 2"
|
case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4":return "iPad 2"
|
||||||
case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad (3rd generation)"
|
case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad 3"
|
||||||
case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad (4th generation)"
|
case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad 4"
|
||||||
case "iPad6,11", "iPad6,12": return "iPad (5th generation)"
|
|
||||||
case "iPad7,5", "iPad7,6": return "iPad (6th generation)"
|
|
||||||
case "iPad7,11", "iPad7,12": return "iPad (7th generation)"
|
|
||||||
case "iPad11,6", "iPad11,7": return "iPad (8th generation)"
|
|
||||||
case "iPad12,1", "iPad12,2": return "iPad (9th generation)"
|
|
||||||
case "iPad13,18", "iPad13,19": return "iPad (10th generation)"
|
|
||||||
case "iPad15,7", "iPad15,8": return "iPad (11th generation)"
|
|
||||||
case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air"
|
case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air"
|
||||||
case "iPad5,3", "iPad5,4": return "iPad Air 2"
|
case "iPad5,3", "iPad5,4": return "iPad Air 2"
|
||||||
case "iPad11,3", "iPad11,4": return "iPad Air (3rd generation)"
|
case "iPad6,11", "iPad6,12": return "iPad 5"
|
||||||
case "iPad13,1", "iPad13,2": return "iPad Air (4th generation)"
|
case "iPad7,5", "iPad7,6": return "iPad 6"
|
||||||
case "iPad13,16", "iPad13,17": return "iPad Air (5th generation)"
|
case "iPad7,11", "iPad7,12": return "iPad 7"
|
||||||
case "iPad14,8", "iPad14,9": return "iPad Air (11-inch) (M2)"
|
case "iPad11,4", "iPad11,5": return "iPad Air (3rd generation)"
|
||||||
case "iPad14,10", "iPad14,11": return "iPad Air (13-inch) (M2)"
|
case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad Mini"
|
||||||
case "iPad15,3", "iPad15,4": return "iPad Air (11-inch) (M3)"
|
case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad Mini 2"
|
||||||
case "iPad15,5", "iPad15,6": return "iPad Air (13-inch) (M3)"
|
case "iPad4,7", "iPad4,8", "iPad4,9": return "iPad Mini 3"
|
||||||
case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad mini"
|
case "iPad5,1", "iPad5,2": return "iPad Mini 4"
|
||||||
case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad mini 2"
|
case "iPad11,1", "iPad11,2": return "iPad Mini 5"
|
||||||
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 (5th generation)"
|
|
||||||
case "iPad14,1", "iPad14,2": return "iPad mini (6th generation)"
|
|
||||||
case "iPad16,1", "iPad16,2": return "iPad mini (A17 Pro)"
|
|
||||||
case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)"
|
case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)"
|
||||||
case "iPad7,3", "iPad7,4": return "iPad Pro (10.5-inch)"
|
case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch)"
|
||||||
case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return "iPad Pro (11-inch) (1st generation)"
|
|
||||||
case "iPad8,9", "iPad8,10": return "iPad Pro (11-inch) (2nd generation)"
|
|
||||||
case "iPad13,4", "iPad13,5", "iPad13,6", "iPad13,7": return "iPad Pro (11-inch) (3rd generation)"
|
|
||||||
case "iPad14,3", "iPad14,4": return "iPad Pro (11-inch) (4th generation)"
|
|
||||||
case "iPad16,3", "iPad16,4": return "iPad Pro (11-inch) (M4)"
|
|
||||||
case "iPad17,1", "iPad17,2": return "iPad Pro (11-inch) (M5)"
|
|
||||||
case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch) (1st generation)"
|
|
||||||
case "iPad7,1", "iPad7,2": return "iPad Pro (12.9-inch) (2nd generation)"
|
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 "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8":return "iPad Pro (12.9-inch) (3rd generation)"
|
||||||
case "iPad8,11", "iPad8,12": return "iPad Pro (12.9-inch) (4th generation)"
|
|
||||||
case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11":return "iPad Pro (12.9-inch) (5th generation)"
|
|
||||||
case "iPad14,5", "iPad14,6": return "iPad Pro (12.9-inch) (6th generation)"
|
|
||||||
case "iPad16,5", "iPad16,6": return "iPad Pro (13-inch) (M4)"
|
|
||||||
case "iPad17,3", "iPad17,4": return "iPad Pro (13-inch) (M5)"
|
|
||||||
case "AppleTV5,3": return "Apple TV"
|
case "AppleTV5,3": return "Apple TV"
|
||||||
case "AppleTV6,2": return "Apple TV 4K"
|
case "AppleTV6,2": return "Apple TV 4K"
|
||||||
case "AudioAccessory1,1": return "HomePod"
|
case "AudioAccessory1,1": return "HomePod"
|
||||||
case "AudioAccessory5,1": return "HomePod mini"
|
case "i386", "x86_64": return "Simulator \(identifier)"
|
||||||
default: return identifier
|
default: return identifier
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -21,11 +21,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
public extension UIImage {
|
public extension UIImage {
|
||||||
//Extension Required by RoundedButton to create UIImage from UIColor
|
//Extension Required by RoundedButton to create UIImage from UIColor
|
||||||
func imageWithColor(color: UIColor) -> UIImage {
|
func imageWithColor(color: UIColor) -> UIImage {
|
||||||
|
|||||||
@@ -21,11 +21,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
@MainActor
|
@MainActor
|
||||||
public extension UIImageView {
|
public extension UIImageView {
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
public extension UILabel {
|
public extension UILabel {
|
||||||
|
|
||||||
func lineNumbers() -> Int{
|
func lineNumbers() -> Int{
|
||||||
|
|||||||
@@ -21,10 +21,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
|
||||||
public extension UINavigationController {
|
public extension UINavigationController {
|
||||||
|
|||||||
@@ -21,10 +21,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
public extension UIResponder {
|
public extension UIResponder {
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,7 @@
|
|||||||
|
|
||||||
|
|
||||||
#if canImport(UIKit) && !os(watchOS)
|
#if canImport(UIKit) && !os(watchOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Methods
|
// MARK: - Methods
|
||||||
public extension UIScrollView {
|
public extension UIScrollView {
|
||||||
|
|||||||
@@ -21,9 +21,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|
||||||
// MARK: - Initializers
|
// MARK: - Initializers
|
||||||
|
|||||||
@@ -21,10 +21,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
public class CustomTabBadge: UILabel {
|
public class CustomTabBadge: UILabel {
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
//MARK: - UITableView Animation Cell
|
//MARK: - UITableView Animation Cell
|
||||||
public typealias UITableViewCellAnimation = (UITableViewCell, IndexPath, UITableView) -> Void
|
public typealias UITableViewCellAnimation = (UITableViewCell, IndexPath, UITableView) -> Void
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit) && os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
public extension UITapGestureRecognizer {
|
public extension UITapGestureRecognizer {
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
public extension UITextField {
|
public extension UITextField {
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,8 @@
|
|||||||
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
typealias GradientPoints = (startPoint: CGPoint, endPoint: CGPoint)
|
typealias GradientPoints = (startPoint: CGPoint, endPoint: CGPoint)
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,8 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
|
||||||
public enum ToastPosition {
|
public enum ToastPosition {
|
||||||
|
|||||||
@@ -20,10 +20,8 @@
|
|||||||
// THE SOFTWARE.
|
// THE SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Photos
|
import Photos
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,9 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
// THE SOFTWARE.
|
// THE SOFTWARE.
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if os(iOS) || os(macOS)
|
||||||
/// A protocol for delegates of `ImageZoomController` to provide callbacks for zoom and close events.
|
/// A protocol for delegates of `ImageZoomController` to provide callbacks for zoom and close events.
|
||||||
@objc public protocol ImageZoomControllerDelegate {
|
@objc public protocol ImageZoomControllerDelegate {
|
||||||
/// Called when the image in the controller is zoomed.
|
/// Called when the image in the controller is zoomed.
|
||||||
|
|||||||
@@ -20,8 +20,6 @@
|
|||||||
// THE SOFTWARE.
|
// THE SOFTWARE.
|
||||||
|
|
||||||
// MARK: - Framework headers
|
// MARK: - Framework headers
|
||||||
#if canImport(UIKit)
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
// MARK: - Protocols
|
// MARK: - Protocols
|
||||||
@@ -233,37 +231,54 @@ public extension LCSnackBarView {
|
|||||||
|
|
||||||
/// Handles the `keyboardWillShowNotification` to adjust the snackbar's position.
|
/// Handles the `keyboardWillShowNotification` to adjust the snackbar's position.
|
||||||
/// - Parameter notification: The `Notification` object containing keyboard information.
|
/// - Parameter notification: The `Notification` object containing keyboard information.
|
||||||
@MainActor
|
@objc private func keyboardWillShow(_ notification: Notification?) -> Void {
|
||||||
@objc private func keyboardWillShow(_ notification: Notification?) {
|
|
||||||
guard let info = notification?.userInfo else { return }
|
if let info = notification?.userInfo {
|
||||||
|
|
||||||
systemKeyboardVisible = true
|
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
|
||||||
|
|
||||||
let animationCurveRaw = (info[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue
|
// Getting keyboard animation.
|
||||||
?? UInt(UIView.AnimationCurve.easeOut.rawValue)
|
if let curve = info[curveUserInfoKey] as? UIView.AnimationOptions {
|
||||||
let animationDuration = (info[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue
|
animationCurve = curve
|
||||||
?? 0.25
|
}
|
||||||
let keyboardFrame = (info[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue
|
|
||||||
?? .zero
|
|
||||||
let height = keyboardFrame.height
|
|
||||||
let animationCurve = UIView.AnimationOptions(rawValue: animationCurveRaw << 16)
|
|
||||||
|
|
||||||
UIView.animate(
|
// Getting keyboard animation duration
|
||||||
withDuration: animationDuration,
|
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,
|
delay: 0,
|
||||||
options: animationCurve
|
options: animationCurve,
|
||||||
) { [weak self] in
|
animations: {
|
||||||
self?.frame.origin.y += height
|
self?.frame.origin.y += height
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the `keyboardWillHideNotification`.
|
/// Handles the `keyboardWillHideNotification`.
|
||||||
/// - Parameter notification: The `Notification` object.
|
/// - Parameter notification: The `Notification` object.
|
||||||
@MainActor
|
@objc private func keyboardWillHide(_ notification: Notification?) -> Void {
|
||||||
@objc private func keyboardWillHide(_ notification: Notification?) {
|
DispatchQueue.main.async { [weak self] in
|
||||||
systemKeyboardVisible = false
|
self?.systemKeyboardVisible = false
|
||||||
// keyboard is hidded
|
// keyboard is hidded
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Updates the snackbar's style properties, such as width and corner radius, based on `_style`.
|
/// Updates the snackbar's style properties, such as width and corner radius, based on `_style`.
|
||||||
private func updateStyle() {
|
private func updateStyle() {
|
||||||
@@ -497,5 +512,3 @@ public extension LCSnackBarView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
// 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
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
// THE SOFTWARE.
|
// THE SOFTWARE.
|
||||||
#if canImport(SwiftUI) && os(iOS)
|
#if canImport(SwiftUI)
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// `LCENavigationState` is an `ObservableObject` that manages the state for `LCENavigationView`.
|
/// `LCENavigationState` is an `ObservableObject` that manages the state for `LCENavigationView`.
|
||||||
@@ -47,41 +47,6 @@ class LCENavigationState: ObservableObject {
|
|||||||
@Published var title: (any View) = Text("")
|
@Published var title: (any View) = Text("")
|
||||||
/// The subtitle view of the navigation bar.
|
/// The subtitle view of the navigation bar.
|
||||||
@Published var subTitle: (any View) = Text("")
|
@Published var subTitle: (any View) = Text("")
|
||||||
/// The background color of the navigation bar.
|
|
||||||
@Published var navigationBarBackgroundColor: Color = .clear
|
|
||||||
|
|
||||||
/// Whether a left button was actually configured via `setLeftButton`.
|
|
||||||
@Published var hasLeftButton: Bool = false
|
|
||||||
/// Whether a right button was actually configured via `setRightButton`.
|
|
||||||
@Published var hasRightButton: Bool = false
|
|
||||||
/// Whether buttons should opt into the system Liquid Glass button style (iOS 26+). Off by default.
|
|
||||||
@Published var useGlassButtons: Bool = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `PreferenceKey` used to measure the widest side button so the title can be padded symmetrically and stay centered without overlapping either button.
|
|
||||||
@available(iOS 15, *)
|
|
||||||
private struct LCENavButtonWidthPreferenceKey: PreferenceKey {
|
|
||||||
static var defaultValue: CGFloat { 0 }
|
|
||||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
||||||
value = max(value, nextValue())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15, *)
|
|
||||||
private extension View {
|
|
||||||
/// Applies `.glass` button style on iOS 26+ when enabled, otherwise falls back to `.plain` — the navigation bar is not designed around glass, but stays opt-in ready.
|
|
||||||
@ViewBuilder
|
|
||||||
func lce_applyGlassIfEnabled(_ enabled: Bool) -> some View {
|
|
||||||
if enabled {
|
|
||||||
if #available(iOS 26.0, *) {
|
|
||||||
self.buttonStyle(.glass)
|
|
||||||
} else {
|
|
||||||
self.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `LCENavigationView` is a SwiftUI `View` that provides a customizable navigation bar.
|
/// `LCENavigationView` is a SwiftUI `View` that provides a customizable navigation bar.
|
||||||
@@ -94,9 +59,6 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
/// The content view displayed below the navigation bar.
|
/// The content view displayed below the navigation bar.
|
||||||
let content: Content
|
let content: Content
|
||||||
|
|
||||||
/// The measured width of the widest side button, used to pad the title so it never overlaps either button.
|
|
||||||
@State private var maxButtonWidth: CGFloat = 0
|
|
||||||
|
|
||||||
/// Initializes a new `LCENavigationView` instance.
|
/// Initializes a new `LCENavigationView` instance.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - title: The title view for the navigation bar. Defaults to an empty `Text`.
|
/// - title: The title view for the navigation bar. Defaults to an empty `Text`.
|
||||||
@@ -117,7 +79,7 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
|
|
||||||
/// The body of the `LCENavigationView`.
|
/// The body of the `LCENavigationView`.
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack {
|
||||||
if !state.hideNavigationBar {
|
if !state.hideNavigationBar {
|
||||||
NavigationBarView
|
NavigationBarView
|
||||||
}
|
}
|
||||||
@@ -128,29 +90,18 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
|
|
||||||
/// The private `NavigationBarView` that lays out the navigation bar components.
|
/// The private `NavigationBarView` that lays out the navigation bar components.
|
||||||
private var NavigationBarView: some View {
|
private var NavigationBarView: some View {
|
||||||
ZStack {
|
|
||||||
TitleView
|
|
||||||
.padding(.horizontal, maxButtonWidth)
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.7)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
if state.hasLeftButton {
|
|
||||||
NavLeftButton
|
NavLeftButton
|
||||||
}
|
|
||||||
Spacer()
|
Spacer()
|
||||||
if state.hasRightButton {
|
TitleView
|
||||||
|
Spacer()
|
||||||
NavRightButton
|
NavRightButton
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.padding()
|
.padding()
|
||||||
.background {
|
.background {
|
||||||
state.navigationBarBackgroundColor.ignoresSafeArea(edges: .top)
|
Color.clear.ignoresSafeArea(edges: .top)
|
||||||
}
|
}
|
||||||
.onPreferenceChange(LCENavButtonWidthPreferenceKey.self) { maxButtonWidth = $0 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The private `TitleView` that displays the title and subtitle.
|
/// The private `TitleView` that displays the title and subtitle.
|
||||||
@@ -163,7 +114,7 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The private `NavLeftButton` view. Only rendered when `setLeftButton` was actually called — never a hidden placeholder.
|
/// The private `NavLeftButton` view.
|
||||||
private var NavLeftButton: some View {
|
private var NavLeftButton: some View {
|
||||||
Button(action: state.leftButtonAction) {
|
Button(action: state.leftButtonAction) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -173,15 +124,9 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
state.leftButtonText
|
state.leftButtonText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.lce_applyGlassIfEnabled(state.useGlassButtons)
|
|
||||||
.background(
|
|
||||||
GeometryReader { proxy in
|
|
||||||
Color.clear.preference(key: LCENavButtonWidthPreferenceKey.self, value: proxy.size.width)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The private `NavRightButton` view. Only rendered when `setRightButton` was actually called — never a hidden placeholder.
|
/// The private `NavRightButton` view.
|
||||||
private var NavRightButton: some View {
|
private var NavRightButton: some View {
|
||||||
Button(action: state.rightButtonAction) {
|
Button(action: state.rightButtonAction) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -191,12 +136,6 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.lce_applyGlassIfEnabled(state.useGlassButtons)
|
|
||||||
.background(
|
|
||||||
GeometryReader { proxy in
|
|
||||||
Color.clear.preference(key: LCENavButtonWidthPreferenceKey.self, value: proxy.size.width)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets the configuration for the right button of the navigation bar.
|
/// Sets the configuration for the right button of the navigation bar.
|
||||||
@@ -217,7 +156,13 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
state.rightButtonText = text
|
state.rightButtonText = text
|
||||||
state.rightButtonAction = action
|
state.rightButtonAction = action
|
||||||
state.hasRightButton = true
|
|
||||||
|
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
|
return self
|
||||||
}
|
}
|
||||||
@@ -240,17 +185,14 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
state.leftButtonText = text
|
state.leftButtonText = text
|
||||||
state.leftButtonAction = action
|
state.leftButtonAction = action
|
||||||
state.hasLeftButton = true
|
|
||||||
|
|
||||||
return self
|
if let string = state.rightButtonText.string, string.isEmpty {
|
||||||
|
state.rightButtonText = text.foregroundColor(.clear)
|
||||||
|
}
|
||||||
|
if state.rightButtonImage == nil {
|
||||||
|
state.rightButtonImage = image?.foregroundColor(.clear) as? AnyView
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opts the navigation bar's buttons into the system Liquid Glass `.glass` button style on iOS 26+.
|
|
||||||
/// The navigation bar is designed as a plain, non-glass component by default; call this to enable glass explicitly.
|
|
||||||
/// - Parameter enabled: Whether buttons should use the glass style.
|
|
||||||
/// - Returns: The `LCENavigationView` instance for chaining.
|
|
||||||
public func setGlassButtonsEnabled(_ enabled: Bool) -> LCENavigationView {
|
|
||||||
state.useGlassButtons = enabled
|
|
||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,13 +217,100 @@ public struct LCENavigationView<Content: View>: View {
|
|||||||
state.hideNavigationBar = hide
|
state.hideNavigationBar = hide
|
||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the background color for the navigation bar.
|
/// Extension to `FormatStyle` to format any value as a string.
|
||||||
/// - Parameter color: The color to use as the navigation bar background.
|
@available(iOS 15.0, *)
|
||||||
/// - Returns: The `LCENavigationView` instance for chaining.
|
extension FormatStyle {
|
||||||
public func setNavigationBarBackgroundColor(_ color: Color) -> LCENavigationView {
|
/// Formats an input value if it matches the `FormatInput` type.
|
||||||
state.navigationBarBackgroundColor = color
|
/// - Parameter value: The value to format as `Any`.
|
||||||
return self
|
/// - Returns: The formatted output, or `nil` if the value type does not match.
|
||||||
|
func format(any value: Any) -> FormatOutput? {
|
||||||
|
if let v = value as? FormatInput {
|
||||||
|
return format(v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension to `LocalizedStringKey` to resolve localized strings.
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
extension LocalizedStringKey {
|
||||||
|
/// Resolves the localized string key into a `String`.
|
||||||
|
/// - Returns: The resolved string, or `nil` if resolution fails.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension to `Text` to retrieve its string content.
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
extension Text {
|
||||||
|
/// Returns the string representation of the `Text` view.
|
||||||
|
/// - Returns: The string content, or `nil` if it cannot be extracted.
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import LCEssentials
|
|
||||||
|
|
||||||
final class APIActorTests: XCTestCase {
|
|
||||||
|
|
||||||
func testAPIIsAnActorNotMainActorBound() async {
|
|
||||||
let isActor = (API.shared as Any) is any Actor
|
|
||||||
XCTAssertTrue(isActor, "API must be an actor so callers are not forced onto the main thread")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPersistConnectionDelayRoundTrips() async {
|
|
||||||
let api = API(testConfiguration: .ephemeral)
|
|
||||||
await api.setPersistConnectionDelay(9)
|
|
||||||
let value = await api.persistConnectionDelay
|
|
||||||
XCTAssertEqual(value, 9)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testIsolatedTestInstanceDoesNotTouchSharedCertState() async {
|
|
||||||
let api = API(testConfiguration: .ephemeral)
|
|
||||||
await api.setupCertification(certData: Data([0x01, 0x02]), password: "pw")
|
|
||||||
let sharedHasCert = await API.shared.hasClientCertificateConfigured
|
|
||||||
let instanceHasCert = await api.hasClientCertificateConfigured
|
|
||||||
XCTAssertFalse(sharedHasCert)
|
|
||||||
XCTAssertTrue(instanceHasCert)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import LCEssentials
|
|
||||||
|
|
||||||
private struct Echo: Decodable, Sendable, Equatable {
|
|
||||||
let id: Int
|
|
||||||
let name: String
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct CreateDTO: Encodable, Sendable {
|
|
||||||
let name: String
|
|
||||||
}
|
|
||||||
|
|
||||||
final class APIRequestTests: XCTestCase {
|
|
||||||
|
|
||||||
private var api: API!
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
|
||||||
cfg.protocolClasses = [StubURLProtocol.self]
|
|
||||||
api = API(testConfiguration: cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
StubURLProtocol.reset()
|
|
||||||
api = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testJSONBodyRequestSendsEncodedBodyAndDecodesResponse() async throws {
|
|
||||||
var stub = StubURLProtocol.Stub()
|
|
||||||
stub.statusCode = 200
|
|
||||||
stub.body = Data(#"{"id":10,"name":"x"}"#.utf8)
|
|
||||||
StubURLProtocol.setStub(stub)
|
|
||||||
|
|
||||||
let result: Echo = try await api.request(
|
|
||||||
url: "https://api.example.com/users",
|
|
||||||
method: .post,
|
|
||||||
body: jsonBody(CreateDTO(name: "x"))
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(result, Echo(id: 10, name: "x"))
|
|
||||||
let sent = StubURLProtocol.capturedRequests.first
|
|
||||||
XCTAssertEqual(sent?.httpMethod, "POST")
|
|
||||||
XCTAssertEqual(sent?.value(forHTTPHeaderField: "Content-Type"), "application/json; charset=UTF-8")
|
|
||||||
XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCustomHeaderOverridesDefault() async throws {
|
|
||||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":1,"name":"a"}"#.utf8)))
|
|
||||||
|
|
||||||
let _: Echo = try await api.request(
|
|
||||||
url: "https://api.example.com/x",
|
|
||||||
method: .get,
|
|
||||||
headers: ["Accept": "application/xml"]
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.value(forHTTPHeaderField: "Accept"),
|
|
||||||
"application/xml")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPathParamsSubstitution() async throws {
|
|
||||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"id":42,"name":"a"}"#.utf8)))
|
|
||||||
|
|
||||||
let _: Echo = try await api.request(
|
|
||||||
url: "https://api.example.com/users/{id}/posts",
|
|
||||||
method: .get,
|
|
||||||
pathParams: ["id": "42"]
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.url?.absoluteString,
|
|
||||||
"https://api.example.com/users/42/posts")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testStringResponsePassthroughSkipsJSONDecoding() async throws {
|
|
||||||
StubURLProtocol.setStub(.init(statusCode: 200,
|
|
||||||
headers: ["Content-Type": "text/plain"],
|
|
||||||
body: Data("plain hello".utf8)))
|
|
||||||
|
|
||||||
let text: String = try await api.request(url: "https://api.example.com/ping", method: .get)
|
|
||||||
XCTAssertEqual(text, "plain hello")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testClientErrorThrowsNSErrorWithStatusAndBody() async {
|
|
||||||
StubURLProtocol.setStub(.init(statusCode: 422,
|
|
||||||
body: Data(#"{"error":"invalid"}"#.utf8)))
|
|
||||||
|
|
||||||
do {
|
|
||||||
let _: Echo = try await api.request(url: "https://api.example.com/x", method: .post,
|
|
||||||
body: jsonBody(CreateDTO(name: "")))
|
|
||||||
XCTFail("expected throw")
|
|
||||||
} catch let error as NSError {
|
|
||||||
XCTAssertEqual(error.code, 422)
|
|
||||||
XCTAssertTrue(error.localizedFailureReason?.contains("invalid") ?? false,
|
|
||||||
"reason: \(error.localizedFailureReason ?? "nil")")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPersistConnectionRetriesButIsBoundedOnPermanentClientError() async {
|
|
||||||
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
|
|
||||||
|
|
||||||
do {
|
|
||||||
let _: Echo = try await api.request(url: "https://api.example.com/x",
|
|
||||||
method: .get, persistConnection: true)
|
|
||||||
XCTFail("expected throw after retries exhausted")
|
|
||||||
} catch let error as NSError {
|
|
||||||
XCTAssertEqual(error.code, 400)
|
|
||||||
} catch {
|
|
||||||
XCTFail("unexpected error type: \(error)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// retried, but did NOT loop forever
|
|
||||||
XCTAssertGreaterThanOrEqual(StubURLProtocol.requestCount, 2)
|
|
||||||
XCTAssertLessThanOrEqual(StubURLProtocol.requestCount, API.maxPersistRetries + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testNoPersistConnectionDoesNotRetry() async {
|
|
||||||
StubURLProtocol.setStub(.init(statusCode: 400, body: Data(#"{"error":"nope"}"#.utf8)))
|
|
||||||
let _: Echo? = try? await api.request(url: "https://api.example.com/x", method: .get)
|
|
||||||
XCTAssertEqual(StubURLProtocol.requestCount, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import LCEssentials
|
|
||||||
|
|
||||||
private struct UploadEcho: Decodable, Sendable, Equatable {
|
|
||||||
let ok: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Thread-safe sink for progress callbacks (invoked on an arbitrary queue).
|
|
||||||
private final class ProgressBox: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var storage: [Double] = []
|
|
||||||
func record(_ value: Double) { lock.lock(); storage.append(value); lock.unlock() }
|
|
||||||
var values: [Double] { lock.lock(); defer { lock.unlock() }; return storage }
|
|
||||||
}
|
|
||||||
|
|
||||||
final class APIUploadTests: XCTestCase {
|
|
||||||
|
|
||||||
private var api: API!
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
|
||||||
cfg.protocolClasses = [StubURLProtocol.self]
|
|
||||||
api = API(testConfiguration: cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
StubURLProtocol.reset()
|
|
||||||
api = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tempCountInDir(_ dir: URL) -> Int {
|
|
||||||
(try? FileManager.default.contentsOfDirectory(atPath: dir.path).filter {
|
|
||||||
$0.hasPrefix("lce-multipart-")
|
|
||||||
}.count) ?? -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func testUploadSendsMultipartBodyAndDecodesResponse() async throws {
|
|
||||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
|
|
||||||
|
|
||||||
var form = MultipartForm()
|
|
||||||
form.field("caption", "hi")
|
|
||||||
form.file("photo", data: Data([0x89, 0x50, 0x4E, 0x47]), filename: "p.png")
|
|
||||||
let expectedBody = try Data(contentsOf: form.serialize().fileURL)
|
|
||||||
|
|
||||||
let result: UploadEcho = try await api.upload(
|
|
||||||
url: "https://api.example.com/media",
|
|
||||||
form: form
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(result, UploadEcho(ok: true))
|
|
||||||
let sent = StubURLProtocol.capturedRequests.first
|
|
||||||
XCTAssertEqual(sent?.httpMethod, "POST")
|
|
||||||
XCTAssertTrue(sent?.value(forHTTPHeaderField: "Content-Type")?
|
|
||||||
.hasPrefix("multipart/form-data; boundary=") ?? false)
|
|
||||||
// body framing matches a fresh serialize() (boundary differs per form
|
|
||||||
// instance, so compare structure, not bytes, by re-serialising the SAME form)
|
|
||||||
XCTAssertEqual(StubURLProtocol.lastCapturedBody?.count, expectedBody.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testTempBodyFileRemovedAfterSuccess() async throws {
|
|
||||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
|
||||||
let before = tempCountInDir(dir)
|
|
||||||
|
|
||||||
var form = MultipartForm()
|
|
||||||
form.file("f", data: Data("x".utf8), filename: "a.txt")
|
|
||||||
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
|
|
||||||
|
|
||||||
XCTAssertEqual(tempCountInDir(dir), before)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testTempBodyFileRemovedAfterThrow() async {
|
|
||||||
StubURLProtocol.setStub(.init(statusCode: 500, body: Data(#"{"error":"boom"}"#.utf8)))
|
|
||||||
let dir = FileManager.default.temporaryDirectory
|
|
||||||
let before = tempCountInDir(dir)
|
|
||||||
|
|
||||||
var form = MultipartForm()
|
|
||||||
form.file("f", data: Data("x".utf8), filename: "a.txt")
|
|
||||||
do {
|
|
||||||
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
|
|
||||||
XCTFail("expected throw")
|
|
||||||
} catch {
|
|
||||||
// expected
|
|
||||||
}
|
|
||||||
XCTAssertEqual(tempCountInDir(dir), before)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testProgressOverloadDeliversFinalCompletionAndDecodes() async throws {
|
|
||||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
|
|
||||||
|
|
||||||
var form = MultipartForm()
|
|
||||||
form.file("f", data: Data(repeating: 0x41, count: 4096), filename: "a.bin")
|
|
||||||
|
|
||||||
let progressBox = ProgressBox()
|
|
||||||
let result: UploadEcho = try await api.upload(
|
|
||||||
url: "https://api.example.com/x",
|
|
||||||
form: form,
|
|
||||||
onProgress: { progressBox.record($0) }
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(result, UploadEcho(ok: true))
|
|
||||||
let values = progressBox.values
|
|
||||||
XCTAssertEqual(values.last, 1.0, "final progress must be 1.0")
|
|
||||||
XCTAssertTrue(values.allSatisfy { $0 >= 0 && $0 <= 1 })
|
|
||||||
XCTAssertEqual(values, values.sorted(), "progress must be monotonic non-decreasing")
|
|
||||||
}
|
|
||||||
|
|
||||||
func testUploadServerErrorThrowsWithStatus() async {
|
|
||||||
StubURLProtocol.setStub(.init(statusCode: 413, body: Data(#"{"error":"too big"}"#.utf8)))
|
|
||||||
var form = MultipartForm()
|
|
||||||
form.file("f", data: Data("x".utf8), filename: "a.txt")
|
|
||||||
do {
|
|
||||||
let _: UploadEcho = try await api.upload(url: "https://api.example.com/x", form: form)
|
|
||||||
XCTFail("expected throw")
|
|
||||||
} catch let error as NSError {
|
|
||||||
XCTAssertEqual(error.code, 413)
|
|
||||||
} catch {
|
|
||||||
XCTFail("wrong error: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import LCEssentials
|
|
||||||
|
|
||||||
private struct SamplePayload: Encodable, Sendable {
|
|
||||||
let name: String
|
|
||||||
let age: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
final class HTTPBodyTests: XCTestCase {
|
|
||||||
|
|
||||||
// MARK: - JSONBody
|
|
||||||
|
|
||||||
func testJSONBodyEncodesPayloadAndContentType() throws {
|
|
||||||
let payload = SamplePayload(name: "loverde", age: 3)
|
|
||||||
let (data, contentType) = try JSONBody(payload).encoded()
|
|
||||||
|
|
||||||
XCTAssertEqual(contentType, "application/json; charset=UTF-8")
|
|
||||||
|
|
||||||
let decoded = try JSONDecoder().decode([String: AnyDecodable].self, from: data)
|
|
||||||
XCTAssertEqual(decoded["name"]?.value as? String, "loverde")
|
|
||||||
XCTAssertEqual(decoded["age"]?.value as? Int, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testJSONBodyFactory() throws {
|
|
||||||
let (data, _) = try jsonBody(SamplePayload(name: "x", age: 1)).encoded()
|
|
||||||
XCTAssertFalse(data.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - FormURLEncodedBody
|
|
||||||
|
|
||||||
func testFormURLEncodedEncodesPairsAndPercentEscapes() throws {
|
|
||||||
let (data, contentType) = try FormURLEncodedBody(["a": "1", "b": "two words"]).encoded()
|
|
||||||
|
|
||||||
XCTAssertEqual(contentType, "application/x-www-form-urlencoded; charset=UTF-8")
|
|
||||||
|
|
||||||
let pairs = Set(String(data: data, encoding: .utf8)!.split(separator: "&").map(String.init))
|
|
||||||
XCTAssertEqual(pairs, ["a=1", "b=two%20words"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func testFormURLEncodedEscapesReservedCharactersInsteadOfDropping() throws {
|
|
||||||
let (data, _) = try FormURLEncodedBody(["q": "a&b=c"]).encoded()
|
|
||||||
let body = String(data: data, encoding: .utf8)!
|
|
||||||
XCTAssertEqual(body, "q=a%26b%3Dc")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal type-erased decoder for asserting JSON shape in tests.
|
|
||||||
struct AnyDecodable: Decodable {
|
|
||||||
let value: Any
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
let c = try decoder.singleValueContainer()
|
|
||||||
if let i = try? c.decode(Int.self) { value = i }
|
|
||||||
else if let s = try? c.decode(String.self) { value = s }
|
|
||||||
else if let b = try? c.decode(Bool.self) { value = b }
|
|
||||||
else if let d = try? c.decode(Double.self) { value = d }
|
|
||||||
else { value = "" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import LCEssentials
|
|
||||||
|
|
||||||
private struct OnlyDecodable: Decodable, Sendable, Equatable {
|
|
||||||
let id: Int
|
|
||||||
let name: String
|
|
||||||
}
|
|
||||||
|
|
||||||
final class JSONDecoderDecodeTests: XCTestCase {
|
|
||||||
|
|
||||||
func testDecodesTypeThatIsDecodableAndSendableButNotEncodable() throws {
|
|
||||||
let data = Data(#"{"id":7,"name":"loverde"}"#.utf8)
|
|
||||||
let value: OnlyDecodable = try JSONDecoder.decode(data: data)
|
|
||||||
XCTAssertEqual(value, OnlyDecodable(id: 7, name: "loverde"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDecodeFromStringOverload() throws {
|
|
||||||
let value: OnlyDecodable = try JSONDecoder.decode(#"{"id":1,"name":"a"}"#)
|
|
||||||
XCTAssertEqual(value, OnlyDecodable(id: 1, name: "a"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDecodeFromURLThrowsInsteadOfCrashingOnMissingFile() {
|
|
||||||
let missing = URL(fileURLWithPath: "/tmp/does-not-exist-\(UUID().uuidString).json")
|
|
||||||
XCTAssertThrowsError(try JSONDecoder.decode(fromURL: missing) as OnlyDecodable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import LCEssentials
|
|
||||||
|
|
||||||
final class MultipartFormTests: XCTestCase {
|
|
||||||
|
|
||||||
private func readSerialized(_ form: MultipartForm) throws -> (body: Data, contentType: String, url: URL) {
|
|
||||||
let result = try form.serialize()
|
|
||||||
let data = try Data(contentsOf: result.fileURL)
|
|
||||||
return (data, result.contentType, result.fileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func addCleanup(_ url: URL) {
|
|
||||||
addTeardownBlock { try? FileManager.default.removeItem(at: url) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fields
|
|
||||||
|
|
||||||
func testFieldPartHasNoContentTypeLine() throws {
|
|
||||||
var form = MultipartForm()
|
|
||||||
form.field("caption", "hello world")
|
|
||||||
|
|
||||||
let (body, contentType, url) = try readSerialized(form)
|
|
||||||
addCleanup(url)
|
|
||||||
let text = String(data: body, encoding: .utf8)!
|
|
||||||
|
|
||||||
XCTAssertTrue(contentType.hasPrefix("multipart/form-data; boundary="))
|
|
||||||
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
|
|
||||||
|
|
||||||
XCTAssertEqual(text, [
|
|
||||||
"--\(boundary)\r\n",
|
|
||||||
"Content-Disposition: form-data; name=\"caption\"\r\n",
|
|
||||||
"\r\n",
|
|
||||||
"hello world\r\n",
|
|
||||||
"--\(boundary)--\r\n"
|
|
||||||
].joined())
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - In-memory data file
|
|
||||||
|
|
||||||
func testDataFilePartCarriesFilenameAndMimeAndRawBytes() throws {
|
|
||||||
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF])
|
|
||||||
var form = MultipartForm()
|
|
||||||
form.file("photo", data: png, filename: "p.png")
|
|
||||||
|
|
||||||
let (body, contentType, url) = try readSerialized(form)
|
|
||||||
addCleanup(url)
|
|
||||||
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
|
|
||||||
let text = String(data: body, encoding: .isoLatin1)!
|
|
||||||
|
|
||||||
XCTAssertTrue(text.contains("Content-Disposition: form-data; name=\"photo\"; filename=\"p.png\"\r\n"))
|
|
||||||
XCTAssertTrue(text.contains("Content-Type: image/png\r\n"))
|
|
||||||
XCTAssertTrue(text.hasSuffix("--\(boundary)--\r\n"))
|
|
||||||
|
|
||||||
// raw bytes appear verbatim between the blank line and the trailing CRLF
|
|
||||||
let marker = Data("\r\n\r\n".utf8)
|
|
||||||
let range = body.range(of: marker)!
|
|
||||||
let afterHeader = body[range.upperBound...]
|
|
||||||
XCTAssertTrue(afterHeader.starts(with: png))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testExplicitMimeOverridesGuess() throws {
|
|
||||||
var form = MultipartForm()
|
|
||||||
form.file("f", data: Data("x".utf8), filename: "a.png", mime: "application/octet-stream")
|
|
||||||
let (body, _, url) = try readSerialized(form)
|
|
||||||
addCleanup(url)
|
|
||||||
XCTAssertTrue(String(data: body, encoding: .utf8)!.contains("Content-Type: application/octet-stream\r\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Disk file, streamed
|
|
||||||
|
|
||||||
func testDiskFilePartIsStreamedAndContentMatches() throws {
|
|
||||||
let big = Data((0..<(2 * 1024 * 1024)).map { UInt8($0 % 251) })
|
|
||||||
let src = FileManager.default.temporaryDirectory.appendingPathComponent("src-\(UUID().uuidString).bin")
|
|
||||||
try big.write(to: src)
|
|
||||||
addCleanup(src)
|
|
||||||
|
|
||||||
var form = MultipartForm()
|
|
||||||
form.field("kind", "raw")
|
|
||||||
form.file("doc", url: src)
|
|
||||||
|
|
||||||
let (body, contentType, out) = try readSerialized(form)
|
|
||||||
addCleanup(out)
|
|
||||||
let boundary = String(contentType.split(separator: "=", maxSplits: 1)[1])
|
|
||||||
|
|
||||||
// header uses the source filename
|
|
||||||
XCTAssertTrue(String(data: body.prefix(400), encoding: .isoLatin1)!
|
|
||||||
.contains("filename=\"\(src.lastPathComponent)\""))
|
|
||||||
|
|
||||||
// the 2 MB payload is present verbatim
|
|
||||||
let marker = Data("Content-Type: application/octet-stream\r\n\r\n".utf8)
|
|
||||||
let r = body.range(of: marker)!
|
|
||||||
let payload = body[r.upperBound..<(body.index(r.upperBound, offsetBy: big.count))]
|
|
||||||
XCTAssertEqual(Data(payload), big)
|
|
||||||
|
|
||||||
XCTAssertTrue(String(data: body.suffix(boundary.count + 8), encoding: .utf8)!
|
|
||||||
.hasSuffix("--\(boundary)--\r\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - MIME lookup
|
|
||||||
|
|
||||||
func testMimeTypeLookup() {
|
|
||||||
XCTAssertEqual(MultipartForm.mimeType(for: "a.jpg"), "image/jpeg")
|
|
||||||
XCTAssertEqual(MultipartForm.mimeType(for: "a.PDF"), "application/pdf")
|
|
||||||
XCTAssertEqual(MultipartForm.mimeType(for: "a.unknownext"), "application/octet-stream")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import LCEssentials
|
|
||||||
|
|
||||||
final class SmokeTests: XCTestCase {
|
|
||||||
func testTargetBuildsAndRuns() {
|
|
||||||
XCTAssertTrue(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Test double for `URLProtocol`. Intercepts every request on a `URLSession`
|
|
||||||
/// configured with it, records the outgoing `URLRequest`, and replays a canned
|
|
||||||
/// response or error supplied by the test.
|
|
||||||
///
|
|
||||||
/// Register via:
|
|
||||||
/// ```
|
|
||||||
/// let cfg = URLSessionConfiguration.ephemeral
|
|
||||||
/// cfg.protocolClasses = [StubURLProtocol.self]
|
|
||||||
/// let session = URLSession(configuration: cfg)
|
|
||||||
/// ```
|
|
||||||
final class StubURLProtocol: URLProtocol, @unchecked Sendable {
|
|
||||||
|
|
||||||
struct Stub {
|
|
||||||
var statusCode: Int = 200
|
|
||||||
var headers: [String: String] = ["Content-Type": "application/json"]
|
|
||||||
var body: Data = Data()
|
|
||||||
var error: Error?
|
|
||||||
/// Bytes reported through `URLSession`'s upload progress, in order.
|
|
||||||
var uploadProgressChunks: [Int] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Test-facing state (guarded)
|
|
||||||
|
|
||||||
private static let lock = NSLock()
|
|
||||||
// Access is serialised through `lock`; the unsafe opt-out is the documented
|
|
||||||
// pattern for lock-guarded mutable statics under strict concurrency.
|
|
||||||
nonisolated(unsafe) private static var _stub = Stub()
|
|
||||||
nonisolated(unsafe) private static var _capturedRequests: [URLRequest] = []
|
|
||||||
nonisolated(unsafe) private static var _capturedBodies: [Data] = []
|
|
||||||
|
|
||||||
static func setStub(_ stub: Stub) {
|
|
||||||
lock.lock(); defer { lock.unlock() }
|
|
||||||
_stub = stub
|
|
||||||
_capturedRequests = []
|
|
||||||
_capturedBodies = []
|
|
||||||
}
|
|
||||||
|
|
||||||
static func reset() { setStub(Stub()) }
|
|
||||||
|
|
||||||
static var capturedRequests: [URLRequest] {
|
|
||||||
lock.lock(); defer { lock.unlock() }
|
|
||||||
return _capturedRequests
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Body of the last intercepted request. `URLProtocol` strips `httpBody` for
|
|
||||||
/// stream bodies, so this reads `httpBodyStream` when needed.
|
|
||||||
static var lastCapturedBody: Data? {
|
|
||||||
lock.lock(); defer { lock.unlock() }
|
|
||||||
return _capturedBodies.last
|
|
||||||
}
|
|
||||||
|
|
||||||
static var requestCount: Int {
|
|
||||||
lock.lock(); defer { lock.unlock() }
|
|
||||||
return _capturedRequests.count
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func currentStub() -> Stub {
|
|
||||||
lock.lock(); defer { lock.unlock() }
|
|
||||||
return _stub
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func record(_ request: URLRequest, body: Data) {
|
|
||||||
lock.lock(); defer { lock.unlock() }
|
|
||||||
_capturedRequests.append(request)
|
|
||||||
_capturedBodies.append(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - URLProtocol
|
|
||||||
|
|
||||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
|
||||||
|
|
||||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
||||||
|
|
||||||
override func startLoading() {
|
|
||||||
let stub = Self.currentStub()
|
|
||||||
Self.record(request, body: Self.bodyData(from: request))
|
|
||||||
|
|
||||||
guard let client = client else { return }
|
|
||||||
|
|
||||||
if let error = stub.error {
|
|
||||||
client.urlProtocol(self, didFailWithError: error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = request.url ?? URL(string: "https://stub.invalid")!
|
|
||||||
let response = HTTPURLResponse(url: url,
|
|
||||||
statusCode: stub.statusCode,
|
|
||||||
httpVersion: "HTTP/1.1",
|
|
||||||
headerFields: stub.headers)!
|
|
||||||
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
|
||||||
client.urlProtocol(self, didLoad: stub.body)
|
|
||||||
client.urlProtocolDidFinishLoading(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func stopLoading() {}
|
|
||||||
|
|
||||||
// MARK: - Body extraction
|
|
||||||
|
|
||||||
private static func bodyData(from request: URLRequest) -> Data {
|
|
||||||
if let body = request.httpBody { return body }
|
|
||||||
guard let stream = request.httpBodyStream else { return Data() }
|
|
||||||
stream.open()
|
|
||||||
defer { stream.close() }
|
|
||||||
var data = Data()
|
|
||||||
let bufferSize = 64 * 1024
|
|
||||||
var buffer = [UInt8](repeating: 0, count: bufferSize)
|
|
||||||
while stream.hasBytesAvailable {
|
|
||||||
let read = stream.read(&buffer, maxLength: bufferSize)
|
|
||||||
if read <= 0 { break }
|
|
||||||
data.append(buffer, count: read)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
final class StubURLProtocolTests: XCTestCase {
|
|
||||||
|
|
||||||
private func makeSession() -> URLSession {
|
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
|
||||||
cfg.protocolClasses = [StubURLProtocol.self]
|
|
||||||
return URLSession(configuration: cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
StubURLProtocol.reset()
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testReplaysCannedResponseAndCapturesRequest() async throws {
|
|
||||||
var stub = StubURLProtocol.Stub()
|
|
||||||
stub.statusCode = 201
|
|
||||||
stub.body = Data(#"{"ok":true}"#.utf8)
|
|
||||||
StubURLProtocol.setStub(stub)
|
|
||||||
|
|
||||||
var request = URLRequest(url: URL(string: "https://example.com/things")!)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.httpBody = Data(#"{"name":"x"}"#.utf8)
|
|
||||||
|
|
||||||
let (data, response) = try await makeSession().data(for: request)
|
|
||||||
|
|
||||||
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201)
|
|
||||||
XCTAssertEqual(String(data: data, encoding: .utf8), #"{"ok":true}"#)
|
|
||||||
XCTAssertEqual(StubURLProtocol.requestCount, 1)
|
|
||||||
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.httpMethod, "POST")
|
|
||||||
XCTAssertEqual(StubURLProtocol.lastCapturedBody, Data(#"{"name":"x"}"#.utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testReplaysError() async {
|
|
||||||
var stub = StubURLProtocol.Stub()
|
|
||||||
stub.error = URLError(.notConnectedToInternet)
|
|
||||||
StubURLProtocol.setStub(stub)
|
|
||||||
|
|
||||||
do {
|
|
||||||
_ = try await makeSession().data(for: URLRequest(url: URL(string: "https://example.com")!))
|
|
||||||
XCTFail("expected throw")
|
|
||||||
} catch {
|
|
||||||
XCTAssertEqual((error as? URLError)?.code, .notConnectedToInternet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user