mirror of
https://github.com/tappollo/makata.git
synced 2026-01-12 17:03:04 +08:00
Initial commit
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
/*.xcodeproj
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/config/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
||||||
|
.swiftpm
|
||||||
|
.vscode
|
||||||
5
.swiftformat
Normal file
5
.swiftformat
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
--swiftversion 5.7
|
||||||
|
--indent 4
|
||||||
|
--maxwidth 120
|
||||||
|
--wrapparameters before-first
|
||||||
|
--header "{file}\n\nCode Copyright Buslo Collective\nCreated {created}"
|
||||||
23
Package.resolved
Normal file
23
Package.resolved
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "snapkit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/snapkit/snapkit.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f222cbdf325885926566172f6f5f06af95473158",
|
||||||
|
"version" : "5.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftformat",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/nicklockwood/swiftformat",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "ae4d0b672cd7094ae513097c617951a5ac714418",
|
||||||
|
"version" : "0.50.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 2
|
||||||
|
}
|
||||||
26
Package.swift
Normal file
26
Package.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// swift-tools-version: 5.7
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "makata",
|
||||||
|
platforms: [.iOS(.v15), .macCatalyst(.v15)],
|
||||||
|
products: [
|
||||||
|
.library(name: "makata", targets: ["makataInteraction", "makataForm", "makataUserInterface"]),
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/snapkit/snapkit.git", .upToNextMajor(from: "5.0.1")),
|
||||||
|
.package(url: "https://github.com/nicklockwood/swiftformat", from: "0.50.4"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(name: "makataUserInterface",
|
||||||
|
dependencies: ["makataForm", "makataInteraction", .product(name: "SnapKit", package: "snapkit")],
|
||||||
|
path: "Sources/user-interface"),
|
||||||
|
.target(name: "makataForm",
|
||||||
|
dependencies: ["makataInteraction"],
|
||||||
|
path: "Sources/form"),
|
||||||
|
.target(name: "makataInteraction",
|
||||||
|
path: "Sources/interaction"),
|
||||||
|
]
|
||||||
|
)
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# makata
|
||||||
|
|
||||||
|
Foundations for writing iOS apps in the least amount of time
|
||||||
32
Sources/form/field/field-error.swift
Normal file
32
Sources/form/field/field-error.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum FieldError: LocalizedError {
|
||||||
|
case required
|
||||||
|
case wrongFormat
|
||||||
|
case invalid(String)
|
||||||
|
case incomplete(Error?)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .required:
|
||||||
|
return "is required"
|
||||||
|
case .wrongFormat:
|
||||||
|
return "is in a wrong format"
|
||||||
|
case let .incomplete(error):
|
||||||
|
if let error {
|
||||||
|
return "is incomplete, \(error.localizedDescription)"
|
||||||
|
} else {
|
||||||
|
return "is incomplete"
|
||||||
|
}
|
||||||
|
case let .invalid(string):
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Sources/form/field/field-partial-value.swift
Normal file
41
Sources/form/field/field-partial-value.swift
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum FieldPartialValue<Complete, Incomplete> {
|
||||||
|
case complete(Complete)
|
||||||
|
case partial(Incomplete, Error?)
|
||||||
|
}
|
||||||
|
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct EnsureCompleteFields<Shape> {
|
||||||
|
var shape: Shape
|
||||||
|
|
||||||
|
public init(checking shape: Shape) {
|
||||||
|
self.shape = shape
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<Value>(dynamicMember member: KeyPath<Shape, FieldPartialValue<Value, some Any>>) -> Value {
|
||||||
|
get throws {
|
||||||
|
switch shape[keyPath: member] {
|
||||||
|
case let .partial(_, error):
|
||||||
|
if let error {
|
||||||
|
throw error
|
||||||
|
} else {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
case let .complete(value):
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<Value>(dynamicMember member: KeyPath<Shape, Value>) -> Value {
|
||||||
|
shape[keyPath: member]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
Sources/form/field/field-validator.swift
Normal file
16
Sources/form/field/field-validator.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct FieldValidator<Value> {
|
||||||
|
public let validate: (Value) throws -> Void
|
||||||
|
|
||||||
|
public init(validate: @escaping (Value) throws -> Void) {
|
||||||
|
self.validate = validate
|
||||||
|
}
|
||||||
|
}
|
||||||
153
Sources/form/form-binding.swift
Normal file
153
Sources/form/form-binding.swift
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Binding<Source: AnyObject, Value> {
|
||||||
|
public let action: (Value) throws -> Value
|
||||||
|
public let initialValue: Value?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
source: Source,
|
||||||
|
to path: ReferenceWritableKeyPath<Source, Value>
|
||||||
|
) {
|
||||||
|
action = { [unowned source] value in
|
||||||
|
source[keyPath: path] = value
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
initialValue = source[keyPath: path]
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
source: Source,
|
||||||
|
to path: ReferenceWritableKeyPath<Source, Value?>
|
||||||
|
) {
|
||||||
|
action = { [unowned source] value in
|
||||||
|
source[keyPath: path] = value
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
initialValue = source[keyPath: path]
|
||||||
|
}
|
||||||
|
|
||||||
|
public init<Out, T: FieldTransformable>(
|
||||||
|
source: Source,
|
||||||
|
to path: ReferenceWritableKeyPath<Source, Out>,
|
||||||
|
transform: T
|
||||||
|
) throws where T.Value == Value, T.Output == Out {
|
||||||
|
action = { [unowned source] value in
|
||||||
|
source[keyPath: path] = try transform.encode(to: value)
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
initialValue = try transform.decode(from: source[keyPath: path])
|
||||||
|
}
|
||||||
|
|
||||||
|
public init<F: FieldFormattable>(
|
||||||
|
source: Source,
|
||||||
|
to path: ReferenceWritableKeyPath<Source, Value>,
|
||||||
|
format: F
|
||||||
|
) where F.Input == Value, F.Output == Value {
|
||||||
|
action = { [unowned source] value in
|
||||||
|
source[keyPath: path] = value
|
||||||
|
|
||||||
|
return format.format(value: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
initialValue = source[keyPath: path]
|
||||||
|
}
|
||||||
|
|
||||||
|
public init<Out, T: FieldTransformable, F: FieldFormattable>(
|
||||||
|
source: Source,
|
||||||
|
to path: ReferenceWritableKeyPath<Source, Out>,
|
||||||
|
transform: T,
|
||||||
|
format: F
|
||||||
|
) throws where T.Value == Value, T.Output == Out, F.Input == Out, F.Output == Value {
|
||||||
|
action = { [unowned source] value in
|
||||||
|
let rawValue = try transform.encode(to: value)
|
||||||
|
source[keyPath: path] = rawValue
|
||||||
|
|
||||||
|
return format.format(value: rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
initialValue = try transform.decode(from: source[keyPath: path])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Binding {
|
||||||
|
init<Complete, T: FieldTransformable>(
|
||||||
|
source: Source,
|
||||||
|
to path: FieldPartialValueKeyPath<Source, Complete, Value>,
|
||||||
|
transform: T
|
||||||
|
) where T.Output == Complete, T.Value == Value {
|
||||||
|
action = { [unowned source] value in
|
||||||
|
do {
|
||||||
|
source[keyPath: path] = .complete(try transform.encode(to: value))
|
||||||
|
} catch {
|
||||||
|
source[keyPath: path] = .partial(value, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
switch source[keyPath: path] {
|
||||||
|
case let .complete(complete):
|
||||||
|
initialValue = try transform.decode(from: complete)
|
||||||
|
case let .partial(partial, _):
|
||||||
|
initialValue = partial
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fatalError("Are you intentionally setting incorrect data?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init<Complete, T: FieldTransformable, F: FieldFormattable>(
|
||||||
|
source: Source,
|
||||||
|
to path: FieldPartialValueKeyPath<Source, Complete, Value>,
|
||||||
|
transform: T,
|
||||||
|
format: F
|
||||||
|
) where T.Value == Value, T.Output == Complete, F.Input == Complete, F.Output == Value {
|
||||||
|
action = { [unowned source] value in
|
||||||
|
let partialResult: FieldPartialValue<Complete, Value>
|
||||||
|
|
||||||
|
do {
|
||||||
|
partialResult = .complete(try transform.encode(to: value))
|
||||||
|
} catch {
|
||||||
|
partialResult = .partial(value, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
source[keyPath: path] = partialResult
|
||||||
|
|
||||||
|
switch partialResult {
|
||||||
|
case let .complete(complete):
|
||||||
|
return format.format(value: complete)
|
||||||
|
case let .partial(incomplete, _):
|
||||||
|
return incomplete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
switch source[keyPath: path] {
|
||||||
|
case let .complete(complete):
|
||||||
|
initialValue = try transform.decode(from: complete)
|
||||||
|
case let .partial(partial, _):
|
||||||
|
initialValue = partial
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fatalError("Are you intentionally setting incorrect data?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public typealias FieldPartialValueKeyPath<Source, Complete, Value> = ReferenceWritableKeyPath<
|
||||||
|
Source, FieldPartialValue<Complete, Value>
|
||||||
|
>
|
||||||
146
Sources/form/form-validation.swift
Normal file
146
Sources/form/form-validation.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct FormValidation<Shape> {
|
||||||
|
public struct Result {
|
||||||
|
public static var noErrors: Result { Result(fields: [:]) }
|
||||||
|
|
||||||
|
public let fields: [PartialKeyPath<Shape>: [Error]]
|
||||||
|
}
|
||||||
|
|
||||||
|
let validationsDict: [PartialKeyPath<Shape>: (Shape) -> [Error]]
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
validationsDict = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
init(_ newDict: [PartialKeyPath<Shape>: (Shape) -> [Error]]) {
|
||||||
|
validationsDict = newDict
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validations<Value>(
|
||||||
|
for path: KeyPath<Shape, Value>,
|
||||||
|
are fields: FieldValidator<Value>...
|
||||||
|
) -> Self {
|
||||||
|
var newDict = validationsDict
|
||||||
|
newDict[path] = { shape in
|
||||||
|
var fieldErrors = [Error]()
|
||||||
|
|
||||||
|
fields.forEach { field in
|
||||||
|
do {
|
||||||
|
try field.validate(shape[keyPath: path])
|
||||||
|
} catch {
|
||||||
|
fieldErrors.append(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fieldErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(newDict)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validations<Value>(
|
||||||
|
for path: KeyPath<Shape, Value?>,
|
||||||
|
are fields: FieldValidator<Value>...
|
||||||
|
) -> Self {
|
||||||
|
var newDict = validationsDict
|
||||||
|
newDict[path] = { shape in
|
||||||
|
var fieldErrors = [Error]()
|
||||||
|
|
||||||
|
fields.forEach { field in
|
||||||
|
do {
|
||||||
|
if let value = shape[keyPath: path] {
|
||||||
|
try field.validate(value)
|
||||||
|
} else {
|
||||||
|
fatalError("Tried to validate a field that does not exist or is nil.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fieldErrors.append(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fieldErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(newDict)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validations<Value>(
|
||||||
|
for path: KeyPath<Shape, FieldPartialValue<Value, some Any>>,
|
||||||
|
are fields: FieldValidator<Value>...
|
||||||
|
) -> Self {
|
||||||
|
var newDict = validationsDict
|
||||||
|
newDict[path] = { shape in
|
||||||
|
var fieldErrors = [Error]()
|
||||||
|
|
||||||
|
fields.forEach { field in
|
||||||
|
do {
|
||||||
|
switch shape[keyPath: path] {
|
||||||
|
case let .complete(complete):
|
||||||
|
try field.validate(complete)
|
||||||
|
case let .partial(_, error):
|
||||||
|
throw FieldError.incomplete(error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fieldErrors.append(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fieldErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(newDict)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validations<Value>(
|
||||||
|
for path: KeyPath<Shape, FieldPartialValue<Value, some Any>?>,
|
||||||
|
are fields: FieldValidator<Value>...
|
||||||
|
) -> Self {
|
||||||
|
var newDict = validationsDict
|
||||||
|
newDict[path] = { shape in
|
||||||
|
var fieldErrors = [Error]()
|
||||||
|
|
||||||
|
fields.forEach { field in
|
||||||
|
do {
|
||||||
|
if let value = shape[keyPath: path] {
|
||||||
|
switch value {
|
||||||
|
case let .complete(complete):
|
||||||
|
try field.validate(complete)
|
||||||
|
case let .partial(_, error):
|
||||||
|
throw FieldError.incomplete(error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fatalError("Tried to validate a field that does not exist or is nil.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fieldErrors.append(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fieldErrors
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(newDict)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func validate(_ shape: Shape) -> Result {
|
||||||
|
let errors = validationsDict.compactMap { key, value in
|
||||||
|
let fieldErrors = value(shape)
|
||||||
|
|
||||||
|
if !fieldErrors.isEmpty {
|
||||||
|
return (key, fieldErrors)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .init(fields: .init(uniqueKeysWithValues: errors))
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Sources/form/form.swift
Normal file
8
Sources/form/form.swift
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
15
Sources/form/protocol/protocol-field-formattable.swift
Normal file
15
Sources/form/protocol/protocol-field-formattable.swift
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol FieldFormattable {
|
||||||
|
associatedtype Input
|
||||||
|
associatedtype Output
|
||||||
|
|
||||||
|
func format(value: Input) -> Output
|
||||||
|
}
|
||||||
16
Sources/form/protocol/protocol-field-transformable.swift
Normal file
16
Sources/form/protocol/protocol-field-transformable.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol FieldTransformable {
|
||||||
|
associatedtype Value
|
||||||
|
associatedtype Output
|
||||||
|
|
||||||
|
func encode(to value: Value) throws -> Output
|
||||||
|
func decode(from value: Output) throws -> Value
|
||||||
|
}
|
||||||
97
Sources/form/protocol/protocol-formable.swift
Normal file
97
Sources/form/protocol/protocol-formable.swift
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol Formable: AnyObject {
|
||||||
|
associatedtype FormData
|
||||||
|
|
||||||
|
var formHandler: FormHandler<FormData> { get }
|
||||||
|
|
||||||
|
func submitData(form: FormData) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Formable {
|
||||||
|
func submit() async throws {
|
||||||
|
try await formHandler.submit { [unowned self] shape in
|
||||||
|
try await submitData(form: shape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public class FormHandler<Shape> {
|
||||||
|
public enum Errors: Error {
|
||||||
|
case invalid(FormValidation<Shape>.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct State {
|
||||||
|
public let isValid: Bool
|
||||||
|
public let validationResult: FormValidation<Shape>.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
public typealias UpdatesHandler = (Shape, State) async -> Void
|
||||||
|
|
||||||
|
public var current: Shape
|
||||||
|
|
||||||
|
var updateHandler: UpdatesHandler = { _, _ async in }
|
||||||
|
|
||||||
|
var validations: FormValidation<Shape>?
|
||||||
|
|
||||||
|
public init(initial: Shape) {
|
||||||
|
self.current = initial
|
||||||
|
}
|
||||||
|
|
||||||
|
func submit(_ callback: @escaping (Shape) async throws -> Void) async throws {
|
||||||
|
let errors = validations?.validate(current) ?? .noErrors
|
||||||
|
|
||||||
|
if !errors.fields.isEmpty {
|
||||||
|
throw Errors.invalid(errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await callback(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushUpdates() {
|
||||||
|
Task {
|
||||||
|
let result = validations?.validate(current) ?? .noErrors
|
||||||
|
await updateHandler(current, .init(isValid: result.fields.isEmpty, validationResult: result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FormHandler {
|
||||||
|
@discardableResult
|
||||||
|
public func callAsFunction(_ callback: @escaping UpdatesHandler) -> Self {
|
||||||
|
updateHandler = callback
|
||||||
|
|
||||||
|
pushUpdates()
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func setValidationHandler(_ handler: FormValidation<Shape>?) -> Self {
|
||||||
|
validations = handler
|
||||||
|
|
||||||
|
pushUpdates()
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FormHandler {
|
||||||
|
public subscript<Value>(dynamicMember member: WritableKeyPath<Shape, Value>) -> Value {
|
||||||
|
get {
|
||||||
|
current[keyPath: member]
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
current[keyPath: member] = newValue
|
||||||
|
pushUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Sources/interaction/interaction.swift
Normal file
6
Sources/interaction/interaction.swift
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// interaction.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
16
Sources/interaction/protocol/protocol-assignable.swift
Normal file
16
Sources/interaction/protocol/protocol-assignable.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// protocol-assignable.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol Assignable: AnyObject {}
|
||||||
|
|
||||||
|
public extension Assignable {
|
||||||
|
func assign(to target: inout Self?) -> Self {
|
||||||
|
target = self
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// protocol-hookable+routeable.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public extension Hookable where Hook: Routeable {
|
||||||
|
init(hook: Hook, routeHandler: @MainActor @escaping (Hook.Route) async -> Void) {
|
||||||
|
self.init(hook: hook)
|
||||||
|
|
||||||
|
hook.routeHandler(routeHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(hook: Hook, routeHandler: @MainActor @escaping (Self, Hook.Route) async -> Void) {
|
||||||
|
self.init(hook: hook)
|
||||||
|
|
||||||
|
hook.routeHandler { [unowned self] route in
|
||||||
|
await routeHandler(self, route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
Sources/interaction/protocol/protocol-hookable.swift
Normal file
14
Sources/interaction/protocol/protocol-hookable.swift
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// protocol-hookable.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol Hookable: AnyObject {
|
||||||
|
associatedtype Hook
|
||||||
|
|
||||||
|
var client: Hook { get }
|
||||||
|
|
||||||
|
init(hook: Hook)
|
||||||
|
}
|
||||||
10
Sources/interaction/protocol/protocol-lifetimeable.swift
Normal file
10
Sources/interaction/protocol/protocol-lifetimeable.swift
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// protocol-lifetimeable.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol Lifetimeable: AnyObject {
|
||||||
|
func cancel()
|
||||||
|
}
|
||||||
42
Sources/interaction/protocol/protocol-loadable.swift
Normal file
42
Sources/interaction/protocol/protocol-loadable.swift
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// protocol-loadable.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol Loadable: Stateable where State == LoadableDataState<LoadableData> {
|
||||||
|
associatedtype LoadableData
|
||||||
|
|
||||||
|
func loadData() async throws -> LoadableData
|
||||||
|
|
||||||
|
func invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Loadable {
|
||||||
|
func invalidate() async {
|
||||||
|
let previousData: LoadableData?
|
||||||
|
|
||||||
|
if case let .success(pd) = stateHandler.current {
|
||||||
|
previousData = pd
|
||||||
|
} else {
|
||||||
|
previousData = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateState(to: .pending(previousData))
|
||||||
|
|
||||||
|
do {
|
||||||
|
let newData = try await loadData()
|
||||||
|
|
||||||
|
await updateState(to: .success(newData))
|
||||||
|
} catch {
|
||||||
|
await updateState(to: .failure(previousData, error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LoadableDataState<Data> {
|
||||||
|
case success(Data)
|
||||||
|
case pending(Data?)
|
||||||
|
case failure(Data?, Error)
|
||||||
|
}
|
||||||
90
Sources/interaction/protocol/protocol-observable.swift
Normal file
90
Sources/interaction/protocol/protocol-observable.swift
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// protocol-observable.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@propertyWrapper
|
||||||
|
public final class Observable<Value> {
|
||||||
|
public var wrappedValue: Value {
|
||||||
|
didSet {
|
||||||
|
subscriptions.forEach { item in
|
||||||
|
item.action(wrappedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var projectedValue: Projection {
|
||||||
|
.init(observable: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscriptions: [Subscription] = []
|
||||||
|
|
||||||
|
public init(wrappedValue: Value) {
|
||||||
|
self.wrappedValue = wrappedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Observable {
|
||||||
|
class Subscription: Lifetimeable {
|
||||||
|
public static func equal(source: Subscription) -> (Subscription) -> Bool {
|
||||||
|
{ check in check.id == source.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: Int
|
||||||
|
let source: String
|
||||||
|
let action: (Value) -> Void
|
||||||
|
|
||||||
|
weak var observable: Observable<Value>!
|
||||||
|
|
||||||
|
init(id: Int, source: String, action: @escaping (Value) -> Void) {
|
||||||
|
self.id = id
|
||||||
|
self.source = source
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cancel() {
|
||||||
|
observable.subscriptions.removeAll(where: Subscription.equal(source: self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Projection {
|
||||||
|
weak var observable: Observable<Value>!
|
||||||
|
|
||||||
|
public func subscribe(
|
||||||
|
source: @autoclosure () -> String = "[\(#file)]@\(#line) > \(#filePath)",
|
||||||
|
action: @escaping (Value) -> Void
|
||||||
|
) -> Lifetimeable {
|
||||||
|
let newSubscription = Subscription(id: observable.subscriptions.count, source: source(), action: action)
|
||||||
|
newSubscription.observable = observable
|
||||||
|
|
||||||
|
observable.subscriptions.append(newSubscription)
|
||||||
|
|
||||||
|
return newSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
public func bind(
|
||||||
|
source: @autoclosure () -> String = "[\(#file)]@\(#line) > \(#filePath)",
|
||||||
|
to loadable: some Loadable
|
||||||
|
) -> Lifetimeable {
|
||||||
|
subscribe(source: source()) { [weak loadable] _ in
|
||||||
|
guard let loadable else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await loadable.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Sources/interaction/protocol/protocol-routeable.swift
Normal file
30
Sources/interaction/protocol/protocol-routeable.swift
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// protocol-routeable.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol Routeable: AnyObject {
|
||||||
|
associatedtype Route
|
||||||
|
|
||||||
|
var routeHandler: RouteCallback<Route> { get set }
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Routeable {
|
||||||
|
func updateRoute(to route: Route) async {
|
||||||
|
await routeHandler.provider(route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RouteCallback<Route> {
|
||||||
|
public typealias Provider = @MainActor (Route) async -> Void
|
||||||
|
|
||||||
|
var provider: Provider = { _ in }
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func callAsFunction(_ callback: @escaping Provider) {
|
||||||
|
provider = callback
|
||||||
|
}
|
||||||
|
}
|
||||||
38
Sources/interaction/protocol/protocol-stateable.swift
Normal file
38
Sources/interaction/protocol/protocol-stateable.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// protocol-stateable.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol Stateable: AnyObject {
|
||||||
|
associatedtype State
|
||||||
|
|
||||||
|
var stateHandler: StateHandler<State> { get set }
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Stateable {
|
||||||
|
func updateState(to state: State) async {
|
||||||
|
await stateHandler.provider(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StateHandler<State> {
|
||||||
|
public typealias Provider = @MainActor (State) async -> Void
|
||||||
|
|
||||||
|
var provider: Provider = { _ in }
|
||||||
|
|
||||||
|
public var current: State
|
||||||
|
|
||||||
|
public init(initial: State) {
|
||||||
|
current = initial
|
||||||
|
}
|
||||||
|
|
||||||
|
public func callAsFunction(_ callback: @escaping Provider) {
|
||||||
|
provider = callback
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await callback(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Sources/interaction/protocol/protocol-withable.swift
Normal file
22
Sources/interaction/protocol/protocol-withable.swift
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// protocol-withable.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol Withable {}
|
||||||
|
|
||||||
|
public extension Withable {
|
||||||
|
func with(_ closure: (Self) -> Void) -> Self {
|
||||||
|
closure(self)
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func with<Value>(path: KeyPath<Self, Value>, _ closure: (Value) -> Void) -> Self {
|
||||||
|
closure(self[keyPath: path])
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
// controller-template-collection.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public protocol CollectionDelegate: AnyObject {
|
||||||
|
associatedtype DelegateItemType: Hashable
|
||||||
|
|
||||||
|
@MainActor func collectionItemSelected(at indexPath: IndexPath, _ item: DelegateItemType) async
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Templates {
|
||||||
|
final class Collection<S: Hashable, E: Hashable>: UIView {
|
||||||
|
public typealias SectionLayout = (DataSource, Int, S) -> NSCollectionLayoutSection
|
||||||
|
|
||||||
|
public class DataSource: UICollectionViewDiffableDataSource<S, E> {}
|
||||||
|
|
||||||
|
public let dataSource: DataSource
|
||||||
|
|
||||||
|
public private(set) weak var collectionView: UICollectionView!
|
||||||
|
|
||||||
|
let delegate = DelegateProxy<E>()
|
||||||
|
|
||||||
|
public init(
|
||||||
|
header: UIView? = nil,
|
||||||
|
footer: UIView? = nil,
|
||||||
|
source: (UICollectionView) -> DataSource,
|
||||||
|
layout: @escaping SectionLayout
|
||||||
|
) {
|
||||||
|
let headerRegistration = Header.Registration
|
||||||
|
let footerRegistration = Footer.Registration
|
||||||
|
|
||||||
|
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: .init())
|
||||||
|
|
||||||
|
collectionView.contentInset = .init(top: 0, left: 0, bottom: 8, right: 0)
|
||||||
|
collectionView.alwaysBounceVertical = false
|
||||||
|
collectionView.delegate = delegate
|
||||||
|
collectionView.backgroundColor = .systemBackground
|
||||||
|
|
||||||
|
let source = source(collectionView)
|
||||||
|
dataSource = source
|
||||||
|
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
let initialSupplementaryProvider = source.supplementaryViewProvider
|
||||||
|
|
||||||
|
source.supplementaryViewProvider = { cv, ek, ip in
|
||||||
|
switch ek {
|
||||||
|
case CollectionHeaderElementType:
|
||||||
|
return cv
|
||||||
|
.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: ip)
|
||||||
|
.setContainingView(header!)
|
||||||
|
case CollectionFooterElementType:
|
||||||
|
return cv
|
||||||
|
.dequeueConfiguredReusableSupplementary(using: footerRegistration, for: ip)
|
||||||
|
.setContainingView(footer!)
|
||||||
|
default:
|
||||||
|
if let initialSupplementaryProvider {
|
||||||
|
return initialSupplementaryProvider(cv, ek, ip)
|
||||||
|
} else {
|
||||||
|
fatalError("Called for a supplementary view, but no provider.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubview(view: collectionView) { make in
|
||||||
|
make.edges
|
||||||
|
.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
let configuration = UICollectionViewCompositionalLayoutConfiguration()
|
||||||
|
configuration.contentInsetsReference = .layoutMargins
|
||||||
|
|
||||||
|
if header != nil {
|
||||||
|
let headerSupplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(
|
||||||
|
layoutSize: .init(
|
||||||
|
widthDimension: .fractionalWidth(1),
|
||||||
|
heightDimension: .estimated(120)
|
||||||
|
),
|
||||||
|
elementKind: CollectionHeaderElementType,
|
||||||
|
alignment: .top
|
||||||
|
)
|
||||||
|
|
||||||
|
headerSupplementaryItem.pinToVisibleBounds = true
|
||||||
|
|
||||||
|
configuration.boundarySupplementaryItems.append(headerSupplementaryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if footer != nil {
|
||||||
|
let footerSupplementaryItem = NSCollectionLayoutBoundarySupplementaryItem(
|
||||||
|
layoutSize: .init(
|
||||||
|
widthDimension: .fractionalWidth(1),
|
||||||
|
heightDimension: .estimated(30)
|
||||||
|
),
|
||||||
|
elementKind: CollectionFooterElementType,
|
||||||
|
alignment: .bottom
|
||||||
|
)
|
||||||
|
|
||||||
|
footerSupplementaryItem.pinToVisibleBounds = true
|
||||||
|
|
||||||
|
configuration.boundarySupplementaryItems.append(footerSupplementaryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionView.setCollectionViewLayout(
|
||||||
|
UICollectionViewCompositionalLayout(sectionProvider: { section, _ in
|
||||||
|
guard let sectionKind = source.sectionIdentifier(for: section) else {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return layout(source, section, sectionKind)
|
||||||
|
}, configuration: configuration),
|
||||||
|
animated: false
|
||||||
|
)
|
||||||
|
|
||||||
|
self.collectionView = collectionView
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
public required init?(coder _: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setCollectionDelegate<D: CollectionDelegate>(_ target: D) where D.DelegateItemType == E {
|
||||||
|
delegate.setupDelegate(delegate: target, dataSource: dataSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateLayout(action: (LayoutUpdateContext) -> Void) {
|
||||||
|
action(.init(source: self))
|
||||||
|
|
||||||
|
collectionView.collectionViewLayout.invalidateLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setRefresh(action: @escaping @MainActor () async -> Void) {
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
|
let refreshControl = UIRefreshControl(frame: .zero)
|
||||||
|
refreshControl.layer.zPosition = 10000
|
||||||
|
|
||||||
|
refreshControl.addAction(UIAction { [unowned refreshControl] _ in
|
||||||
|
Task {
|
||||||
|
await action()
|
||||||
|
refreshControl.endRefreshing()
|
||||||
|
}
|
||||||
|
}, for: .primaryActionTriggered)
|
||||||
|
|
||||||
|
collectionView.refreshControl = refreshControl
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public func createSnapsot() -> NSDiffableDataSourceSnapshot<S, E> {
|
||||||
|
.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Templates.Collection {
|
||||||
|
public struct LayoutUpdateContext {
|
||||||
|
weak var source: Templates.Collection<S, E>!
|
||||||
|
|
||||||
|
public func cell<View>(
|
||||||
|
for item: E,
|
||||||
|
dequeuedAs _: View.Type
|
||||||
|
) -> View? where View: UICollectionViewCell {
|
||||||
|
guard let formIndexPath = source.dataSource.indexPath(for: item) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.collectionView.cellForItem(at: formIndexPath) as? View
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Header: UICollectionReusableView {
|
||||||
|
static var Registration: UICollectionView.SupplementaryRegistration<Header> {
|
||||||
|
.init(elementKind: CollectionHeaderElementType) { supplementaryView, _, _ in
|
||||||
|
supplementaryView.subviews.forEach { $0.removeFromSuperview() }
|
||||||
|
supplementaryView.layer.zPosition = 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
weak var border: UIView!
|
||||||
|
weak var visualEffect: UIVisualEffectView!
|
||||||
|
|
||||||
|
weak var keyvalueObx: NSKeyValueObservation?
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
switch traitCollection.userInterfaceStyle {
|
||||||
|
case .dark:
|
||||||
|
_ = visualEffect
|
||||||
|
.contentView
|
||||||
|
.backgroundColor(.clear)
|
||||||
|
default:
|
||||||
|
_ = visualEffect
|
||||||
|
.contentView
|
||||||
|
.backgroundColor(.white.withAlphaComponent(0.65))
|
||||||
|
}
|
||||||
|
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setContainingView(_ content: UIView) -> Self {
|
||||||
|
addSubview(
|
||||||
|
view: UIVisualEffectView(
|
||||||
|
effect: UIBlurEffect(style: .regular)
|
||||||
|
)
|
||||||
|
.assign(to: &visualEffect)
|
||||||
|
.hidden()
|
||||||
|
) { make in
|
||||||
|
make.leading
|
||||||
|
.bottom
|
||||||
|
.trailing
|
||||||
|
.equalToSuperview()
|
||||||
|
make.top
|
||||||
|
.equalToSuperview()
|
||||||
|
.inset(-300)
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubview(view: content) { make in
|
||||||
|
make.edges
|
||||||
|
.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubview(
|
||||||
|
view: UIView()
|
||||||
|
.backgroundColor(.separator)
|
||||||
|
.hidden()
|
||||||
|
.assign(to: &border)
|
||||||
|
) { make in
|
||||||
|
make.horizontalEdges
|
||||||
|
.bottom
|
||||||
|
.equalToSuperview()
|
||||||
|
|
||||||
|
make.height
|
||||||
|
.equalTo(1 / UIScreen.main.scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
traitCollectionDidChange(nil)
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayUpdate(_ showBorder: Bool) {
|
||||||
|
layer.shadowOffset = .init(width: 0, height: 1 / UIScreen.main.scale)
|
||||||
|
layer.shadowColor = UIColor.black.cgColor
|
||||||
|
|
||||||
|
border.isHidden = !showBorder
|
||||||
|
visualEffect.isHidden = !showBorder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Footer: UICollectionReusableView {
|
||||||
|
static var Registration: UICollectionView.SupplementaryRegistration<Footer> {
|
||||||
|
.init(elementKind: CollectionFooterElementType) { supplementaryView, _, _ in
|
||||||
|
supplementaryView.subviews.forEach { $0.removeFromSuperview() }
|
||||||
|
supplementaryView.layer.zPosition = 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
weak var visualEffect: UIVisualEffectView!
|
||||||
|
|
||||||
|
var keyvalueObx: NSKeyValueObservation?
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
switch traitCollection.userInterfaceStyle {
|
||||||
|
case .dark:
|
||||||
|
_ = visualEffect
|
||||||
|
.contentView
|
||||||
|
.backgroundColor(.clear)
|
||||||
|
default:
|
||||||
|
_ = visualEffect
|
||||||
|
.contentView
|
||||||
|
.backgroundColor(.white.withAlphaComponent(0.65))
|
||||||
|
}
|
||||||
|
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setContainingView(_ content: UIView) -> Self {
|
||||||
|
addSubview(
|
||||||
|
view: UIVisualEffectView(
|
||||||
|
effect: UIBlurEffect(style: .regular)
|
||||||
|
)
|
||||||
|
.assign(to: &visualEffect)
|
||||||
|
.hidden()
|
||||||
|
) { make in
|
||||||
|
make.leading
|
||||||
|
.top
|
||||||
|
.trailing
|
||||||
|
.equalToSuperview()
|
||||||
|
make.bottom
|
||||||
|
.equalToSuperview()
|
||||||
|
.inset(-300)
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubview(view: content) { make in
|
||||||
|
make.edges
|
||||||
|
.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
traitCollectionDidChange(nil)
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Templates.Collection {
|
||||||
|
class DelegateProxy<E: Hashable>: NSObject, UICollectionViewDelegate {
|
||||||
|
typealias DataSource<Section: Hashable> = UICollectionViewDiffableDataSource<Section, E>
|
||||||
|
|
||||||
|
var itemSelected: (IndexPath) -> Void = { _ in }
|
||||||
|
|
||||||
|
func setupDelegate<D: CollectionDelegate>(
|
||||||
|
delegate: D,
|
||||||
|
dataSource: DataSource<some Hashable>
|
||||||
|
) where E == D.DelegateItemType {
|
||||||
|
itemSelected = { [unowned delegate, unowned dataSource] ip in
|
||||||
|
Task(priority: .high) { @MainActor [unowned delegate, unowned dataSource] () in
|
||||||
|
await delegate.collectionItemSelected(at: ip, dataSource.itemIdentifier(for: ip)!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||||
|
itemSelected(indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
let ypos = scrollView.contentOffset.y + scrollView.safeAreaInsets.top
|
||||||
|
|
||||||
|
if let collectionView = scrollView as? UICollectionView {
|
||||||
|
for view in collectionView.visibleSupplementaryViews(ofKind: CollectionHeaderElementType) {
|
||||||
|
guard let header = view as? Header else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
header.displayUpdate(ypos >= 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let CollectionHeaderElementType = "guni__header"
|
||||||
|
let CollectionFooterElementType = "guni__footer"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public extension Templates {
|
||||||
|
final class Form: UIScrollView {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum Templates {}
|
||||||
19
Sources/user-interface/controller/controller-templated.swift
Normal file
19
Sources/user-interface/controller/controller-templated.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// controller-templated.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
open class ControllerTemplated<Template: UIView, Hook>: Controller<Hook> {
|
||||||
|
public lazy var screenTemplate: Template = loadTemplate()
|
||||||
|
|
||||||
|
open func loadTemplate() -> Template {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override open func loadView() {
|
||||||
|
view = screenTemplate
|
||||||
|
}
|
||||||
|
}
|
||||||
37
Sources/user-interface/controller/controller.swift
Normal file
37
Sources/user-interface/controller/controller.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// controller.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import makataInteraction
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
open class Controller<Hook>: UIViewController, Hookable {
|
||||||
|
public var client: Hook
|
||||||
|
|
||||||
|
public required init(hook: Hook) {
|
||||||
|
client = hook
|
||||||
|
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
|
||||||
|
configureController()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
public required init?(coder _: NSCoder) {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
|
||||||
|
override public func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
if let loadable = client as? (any Loadable) {
|
||||||
|
Task {
|
||||||
|
await loadable.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open func configureController() {}
|
||||||
|
}
|
||||||
16
Sources/user-interface/protocol/protocol-attributable.swift
Normal file
16
Sources/user-interface/protocol/protocol-attributable.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// protocol-attributable.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol Attributable: AnyObject {}
|
||||||
|
|
||||||
|
public extension Attributable {
|
||||||
|
func attribute<Value>(on: ReferenceWritableKeyPath<Self, Value>, _ value: Value) -> Self {
|
||||||
|
self[keyPath: on] = value
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol ButtonLoadable: AnyObject {
|
||||||
|
var isLoading: Bool { get set }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol FailureCallable: AnyObject {
|
||||||
|
func showFailure(_ error: Error)
|
||||||
|
}
|
||||||
9
Sources/user-interface/user-interface.swift
Normal file
9
Sources/user-interface/user-interface.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// user-interface.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public typealias VoidCallbackAsync = () async throws -> Void
|
||||||
|
public typealias ErrorCallback = (Error) -> Void
|
||||||
59
Sources/user-interface/view/view+uicontrol.swift
Normal file
59
Sources/user-interface/view/view+uicontrol.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
public extension UIControl {
|
||||||
|
private var touchUpInsideIdentifier: UIAction.Identifier {
|
||||||
|
.init(rawValue: "guni_action_pressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func pressed(
|
||||||
|
_ action: @escaping VoidCallbackAsync,
|
||||||
|
showFailureOn failure: FailureCallable? = nil,
|
||||||
|
showLoading: Bool = false,
|
||||||
|
resetLoading: Bool = true
|
||||||
|
) -> Self {
|
||||||
|
removeAction(identifiedBy: touchUpInsideIdentifier, for: .touchUpInside)
|
||||||
|
|
||||||
|
addAction(UIAction(identifier: touchUpInsideIdentifier) { [unowned self, weak failure] _ in
|
||||||
|
if showLoading {
|
||||||
|
if let loadable = self as? ButtonLoadable {
|
||||||
|
loadable.isLoading = true
|
||||||
|
} else {
|
||||||
|
isEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
try await action()
|
||||||
|
} catch {
|
||||||
|
failure?.showFailure(error)
|
||||||
|
|
||||||
|
if let loadable = self as? ButtonLoadable {
|
||||||
|
loadable.isLoading = false
|
||||||
|
} else {
|
||||||
|
isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resetLoading {
|
||||||
|
if let loadable = self as? ButtonLoadable {
|
||||||
|
loadable.isLoading = false
|
||||||
|
} else {
|
||||||
|
isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, for: .touchUpInside)
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Sources/user-interface/view/view+uitextfield.swift
Normal file
55
Sources/user-interface/view/view+uitextfield.swift
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import makataForm
|
||||||
|
import makataInteraction
|
||||||
|
|
||||||
|
public extension UITextField {
|
||||||
|
func textChanges(_ action: Binding<some AnyObject, String>) -> Lifetimeable {
|
||||||
|
text = action.initialValue
|
||||||
|
|
||||||
|
return TextChangesProxy(source: self, action: action.action)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func textChanges(_ action: Binding<some AnyObject, String>, lifetime: inout Lifetimeable?) -> Self {
|
||||||
|
lifetime = textChanges(action)
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UITextField {
|
||||||
|
private class TextChangesProxy: Lifetimeable {
|
||||||
|
let identifier = UIAction.Identifier("textfield.changes")
|
||||||
|
|
||||||
|
weak var source: UITextField!
|
||||||
|
|
||||||
|
init(source: UITextField, action: @escaping (String) throws -> String) {
|
||||||
|
self.source = source
|
||||||
|
|
||||||
|
source.addAction(UIAction(identifier: identifier) { [unowned source] _ in
|
||||||
|
do {
|
||||||
|
source.text = try action(source.text ?? "")
|
||||||
|
} catch {
|
||||||
|
print("TextChangesProxy: Unhandled failure in setting value to text field.")
|
||||||
|
print("If you intend to catch this error, change your target type to be boxed in a `PartialValue`.")
|
||||||
|
}
|
||||||
|
}, for: .allEditingEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
source?.removeAction(identifiedBy: identifier, for: .allEditingEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Sources/user-interface/view/view-builder.swift
Normal file
42
Sources/user-interface/view/view-builder.swift
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// view-builder.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Michael Ong on 2/3/23.
|
||||||
|
//
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@resultBuilder
|
||||||
|
public enum ComponentBuilder {
|
||||||
|
public typealias Component = [UIView]
|
||||||
|
|
||||||
|
public static func buildExpression(_ expression: some UIView) -> Component {
|
||||||
|
[expression]
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildEither(first component: ComponentBuilder.Component) -> ComponentBuilder.Component {
|
||||||
|
component
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildEither(second component: ComponentBuilder.Component) -> ComponentBuilder.Component {
|
||||||
|
component
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildOptional(_ component: ComponentBuilder.Component?) -> ComponentBuilder.Component {
|
||||||
|
component ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildArray(_ components: [ComponentBuilder.Component]) -> ComponentBuilder.Component {
|
||||||
|
components.flatMap { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildBlock(_ components: Component...) -> Component {
|
||||||
|
components.flatMap { $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
163
Sources/user-interface/view/view.swift
Normal file
163
Sources/user-interface/view/view.swift
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// view.swift
|
||||||
|
//
|
||||||
|
// Code Copyright Buslo Collective
|
||||||
|
// Created 2/3/23
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import makataInteraction
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
extension UIView: Assignable, Attributable, Withable {}
|
||||||
|
|
||||||
|
public extension UIView {
|
||||||
|
static func stackFiller() -> UIView {
|
||||||
|
let view = UIView()
|
||||||
|
view.setContentHuggingPriority(.init(rawValue: 0), for: .horizontal)
|
||||||
|
view.setContentHuggingPriority(.init(rawValue: 0), for: .vertical)
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stackBlock(height: CGFloat = 8) -> UIView {
|
||||||
|
let view = UIView()
|
||||||
|
view.heightAnchor.constraint(equalToConstant: height).isActive = true
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(containing: some UIView, constraints: (ConstraintMaker) -> Void) {
|
||||||
|
self.init(frame: .zero)
|
||||||
|
|
||||||
|
addSubview(view: containing, constraints: constraints)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func autoFocus() -> Self {
|
||||||
|
becomeFirstResponder()
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func sized(_ size: CGSize, rounded: Bool = false) -> Self {
|
||||||
|
translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
layer.cornerRadius = rounded ? size.height / 2 : 0
|
||||||
|
layer.masksToBounds = true
|
||||||
|
|
||||||
|
snp.makeConstraints { make in
|
||||||
|
if !size.width.isNaN {
|
||||||
|
make.width
|
||||||
|
.equalTo(size.width)
|
||||||
|
.priority(.required)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !size.height.isNaN {
|
||||||
|
make.height
|
||||||
|
.equalTo(size.height)
|
||||||
|
.priority(.required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func backgroundColor(_ color: UIColor) -> Self {
|
||||||
|
backgroundColor = color
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func hidden() -> Self {
|
||||||
|
isHidden = true
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func visible() -> Self {
|
||||||
|
isHidden = false
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func addSubview(view: some UIView, constraints: (ConstraintMaker) -> Void) -> Self {
|
||||||
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(view)
|
||||||
|
|
||||||
|
view.snp.makeConstraints(constraints)
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension UIStackView {
|
||||||
|
static func horizontal(
|
||||||
|
spacing: CGFloat = 8,
|
||||||
|
alignment: UIStackView.Alignment = .leading,
|
||||||
|
distribution: UIStackView.Distribution = .fill,
|
||||||
|
@ComponentBuilder components: () -> ComponentBuilder.Component
|
||||||
|
) -> Self {
|
||||||
|
let stack = Self(frame: .zero)
|
||||||
|
stack.axis = .horizontal
|
||||||
|
stack.spacing = spacing
|
||||||
|
stack.alignment = alignment
|
||||||
|
stack.distribution = distribution
|
||||||
|
stack.insetsLayoutMarginsFromSafeArea = false
|
||||||
|
|
||||||
|
stack.setContentHuggingPriority(.required, for: .vertical)
|
||||||
|
|
||||||
|
components().forEach(stack.addArrangedSubview)
|
||||||
|
|
||||||
|
return stack
|
||||||
|
}
|
||||||
|
|
||||||
|
static func vertical(
|
||||||
|
spacing: CGFloat = 8,
|
||||||
|
alignment: UIStackView.Alignment = .leading,
|
||||||
|
distribution: UIStackView.Distribution = .fill,
|
||||||
|
@ComponentBuilder components: () -> ComponentBuilder.Component
|
||||||
|
) -> Self {
|
||||||
|
let stack = Self(frame: .zero)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = spacing
|
||||||
|
stack.alignment = alignment
|
||||||
|
stack.distribution = distribution
|
||||||
|
stack.insetsLayoutMarginsFromSafeArea = false
|
||||||
|
|
||||||
|
stack.setContentHuggingPriority(.required, for: .vertical)
|
||||||
|
|
||||||
|
components().forEach(stack.addArrangedSubview)
|
||||||
|
|
||||||
|
return stack
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(
|
||||||
|
axis: NSLayoutConstraint.Axis,
|
||||||
|
spacing: CGFloat = 8,
|
||||||
|
alignment: UIStackView.Alignment = .leading,
|
||||||
|
distribution: UIStackView.Distribution = .fill,
|
||||||
|
@ComponentBuilder components: () -> ComponentBuilder.Component
|
||||||
|
) {
|
||||||
|
self.init(frame: .zero)
|
||||||
|
|
||||||
|
self.axis = axis
|
||||||
|
self.spacing = spacing
|
||||||
|
self.alignment = alignment
|
||||||
|
self.distribution = distribution
|
||||||
|
insetsLayoutMarginsFromSafeArea = false
|
||||||
|
|
||||||
|
components().forEach(addArrangedSubview)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func margins(_ insets: UIEdgeInsets, relativeToSafeArea: Bool = false) -> Self {
|
||||||
|
isLayoutMarginsRelativeArrangement = true
|
||||||
|
insetsLayoutMarginsFromSafeArea = relativeToSafeArea
|
||||||
|
layoutMargins = insets
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user