mirror of
https://github.com/tappollo/makata.git
synced 2026-01-12 08:44:29 +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