diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a1ab51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +.swiftpm +.vscode diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..0918bc5 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,5 @@ +--swiftversion 5.7 +--indent 4 +--maxwidth 120 +--wrapparameters before-first +--header "{file}\n\nCode Copyright Buslo Collective\nCreated {created}" diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..341cc1b --- /dev/null +++ b/Package.resolved @@ -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 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..225bea7 --- /dev/null +++ b/Package.swift @@ -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"), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..5fd30cd --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# makata + +Foundations for writing iOS apps in the least amount of time diff --git a/Sources/form/field/field-error.swift b/Sources/form/field/field-error.swift new file mode 100644 index 0000000..ca5b8cf --- /dev/null +++ b/Sources/form/field/field-error.swift @@ -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 + } + } +} diff --git a/Sources/form/field/field-partial-value.swift b/Sources/form/field/field-partial-value.swift new file mode 100644 index 0000000..dd1da7b --- /dev/null +++ b/Sources/form/field/field-partial-value.swift @@ -0,0 +1,41 @@ +// +// File.swift +// +// +// Created by Michael Ong on 2/3/23. +// + +import Foundation + +public enum FieldPartialValue { + case complete(Complete) + case partial(Incomplete, Error?) +} + +@dynamicMemberLookup +public struct EnsureCompleteFields { + var shape: Shape + + public init(checking shape: Shape) { + self.shape = shape + } + + public subscript(dynamicMember member: KeyPath>) -> 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(dynamicMember member: KeyPath) -> Value { + shape[keyPath: member] + } +} diff --git a/Sources/form/field/field-validator.swift b/Sources/form/field/field-validator.swift new file mode 100644 index 0000000..7889eeb --- /dev/null +++ b/Sources/form/field/field-validator.swift @@ -0,0 +1,16 @@ +// +// File.swift +// +// +// Created by Michael Ong on 2/3/23. +// + +import Foundation + +public struct FieldValidator { + public let validate: (Value) throws -> Void + + public init(validate: @escaping (Value) throws -> Void) { + self.validate = validate + } +} diff --git a/Sources/form/form-binding.swift b/Sources/form/form-binding.swift new file mode 100644 index 0000000..973d767 --- /dev/null +++ b/Sources/form/form-binding.swift @@ -0,0 +1,153 @@ +// +// File.swift +// +// +// Created by Michael Ong on 2/3/23. +// + +import Foundation + +public struct Binding { + public let action: (Value) throws -> Value + public let initialValue: Value? + + public init( + source: Source, + to path: ReferenceWritableKeyPath + ) { + action = { [unowned source] value in + source[keyPath: path] = value + + return value + } + + initialValue = source[keyPath: path] + } + + public init( + source: Source, + to path: ReferenceWritableKeyPath + ) { + action = { [unowned source] value in + source[keyPath: path] = value + + return value + } + + initialValue = source[keyPath: path] + } + + public init( + source: Source, + to path: ReferenceWritableKeyPath, + 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( + source: Source, + to path: ReferenceWritableKeyPath, + 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( + source: Source, + to path: ReferenceWritableKeyPath, + 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( + source: Source, + to path: FieldPartialValueKeyPath, + 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( + source: Source, + to path: FieldPartialValueKeyPath, + 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 + + 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 = ReferenceWritableKeyPath< + Source, FieldPartialValue +> diff --git a/Sources/form/form-validation.swift b/Sources/form/form-validation.swift new file mode 100644 index 0000000..3849f35 --- /dev/null +++ b/Sources/form/form-validation.swift @@ -0,0 +1,146 @@ +// +// File.swift +// +// +// Created by Michael Ong on 2/3/23. +// + +import Foundation + +public struct FormValidation { + public struct Result { + public static var noErrors: Result { Result(fields: [:]) } + + public let fields: [PartialKeyPath: [Error]] + } + + let validationsDict: [PartialKeyPath: (Shape) -> [Error]] + + public init() { + validationsDict = [:] + } + + init(_ newDict: [PartialKeyPath: (Shape) -> [Error]]) { + validationsDict = newDict + } + + public func validations( + for path: KeyPath, + are fields: FieldValidator... + ) -> 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( + for path: KeyPath, + are fields: FieldValidator... + ) -> 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( + for path: KeyPath>, + are fields: FieldValidator... + ) -> 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( + for path: KeyPath?>, + are fields: FieldValidator... + ) -> 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)) + } +} diff --git a/Sources/form/form.swift b/Sources/form/form.swift new file mode 100644 index 0000000..18b054d --- /dev/null +++ b/Sources/form/form.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Michael Ong on 2/3/23. +// + +import Foundation diff --git a/Sources/form/protocol/protocol-field-formattable.swift b/Sources/form/protocol/protocol-field-formattable.swift new file mode 100644 index 0000000..a153643 --- /dev/null +++ b/Sources/form/protocol/protocol-field-formattable.swift @@ -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 +} diff --git a/Sources/form/protocol/protocol-field-transformable.swift b/Sources/form/protocol/protocol-field-transformable.swift new file mode 100644 index 0000000..8232533 --- /dev/null +++ b/Sources/form/protocol/protocol-field-transformable.swift @@ -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 +} diff --git a/Sources/form/protocol/protocol-formable.swift b/Sources/form/protocol/protocol-formable.swift new file mode 100644 index 0000000..fa5ae97 --- /dev/null +++ b/Sources/form/protocol/protocol-formable.swift @@ -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 { 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 { + public enum Errors: Error { + case invalid(FormValidation.Result) + } + + public struct State { + public let isValid: Bool + public let validationResult: FormValidation.Result + } + + public typealias UpdatesHandler = (Shape, State) async -> Void + + public var current: Shape + + var updateHandler: UpdatesHandler = { _, _ async in } + + var validations: FormValidation? + + 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?) -> Self { + validations = handler + + pushUpdates() + + return self + } +} + +extension FormHandler { + public subscript(dynamicMember member: WritableKeyPath) -> Value { + get { + current[keyPath: member] + } + set { + current[keyPath: member] = newValue + pushUpdates() + } + } +} diff --git a/Sources/interaction/interaction.swift b/Sources/interaction/interaction.swift new file mode 100644 index 0000000..9acb7c8 --- /dev/null +++ b/Sources/interaction/interaction.swift @@ -0,0 +1,6 @@ +// interaction.swift +// +// Code Copyright Buslo Collective +// Created 2/3/23 + +import Foundation diff --git a/Sources/interaction/protocol/protocol-assignable.swift b/Sources/interaction/protocol/protocol-assignable.swift new file mode 100644 index 0000000..5896f53 --- /dev/null +++ b/Sources/interaction/protocol/protocol-assignable.swift @@ -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 + } +} diff --git a/Sources/interaction/protocol/protocol-hookable+routeable.swift b/Sources/interaction/protocol/protocol-hookable+routeable.swift new file mode 100644 index 0000000..3519dea --- /dev/null +++ b/Sources/interaction/protocol/protocol-hookable+routeable.swift @@ -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) + } + } +} diff --git a/Sources/interaction/protocol/protocol-hookable.swift b/Sources/interaction/protocol/protocol-hookable.swift new file mode 100644 index 0000000..54127bc --- /dev/null +++ b/Sources/interaction/protocol/protocol-hookable.swift @@ -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) +} diff --git a/Sources/interaction/protocol/protocol-lifetimeable.swift b/Sources/interaction/protocol/protocol-lifetimeable.swift new file mode 100644 index 0000000..b931981 --- /dev/null +++ b/Sources/interaction/protocol/protocol-lifetimeable.swift @@ -0,0 +1,10 @@ +// protocol-lifetimeable.swift +// +// Code Copyright Buslo Collective +// Created 2/3/23 + +import Foundation + +public protocol Lifetimeable: AnyObject { + func cancel() +} diff --git a/Sources/interaction/protocol/protocol-loadable.swift b/Sources/interaction/protocol/protocol-loadable.swift new file mode 100644 index 0000000..369cebd --- /dev/null +++ b/Sources/interaction/protocol/protocol-loadable.swift @@ -0,0 +1,42 @@ +// protocol-loadable.swift +// +// Code Copyright Buslo Collective +// Created 2/3/23 + +import Foundation + +public protocol Loadable: Stateable where State == LoadableDataState { + 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 { + case success(Data) + case pending(Data?) + case failure(Data?, Error) +} diff --git a/Sources/interaction/protocol/protocol-observable.swift b/Sources/interaction/protocol/protocol-observable.swift new file mode 100644 index 0000000..4dc1bd8 --- /dev/null +++ b/Sources/interaction/protocol/protocol-observable.swift @@ -0,0 +1,90 @@ +// protocol-observable.swift +// +// Code Copyright Buslo Collective +// Created 2/3/23 + +import Foundation + +@propertyWrapper +public final class Observable { + 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! + + 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! + + 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() + } + } + } + } +} diff --git a/Sources/interaction/protocol/protocol-routeable.swift b/Sources/interaction/protocol/protocol-routeable.swift new file mode 100644 index 0000000..56fe801 --- /dev/null +++ b/Sources/interaction/protocol/protocol-routeable.swift @@ -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 { get set } +} + +public extension Routeable { + func updateRoute(to route: Route) async { + await routeHandler.provider(route) + } +} + +public class RouteCallback { + public typealias Provider = @MainActor (Route) async -> Void + + var provider: Provider = { _ in } + + public init() {} + + public func callAsFunction(_ callback: @escaping Provider) { + provider = callback + } +} diff --git a/Sources/interaction/protocol/protocol-stateable.swift b/Sources/interaction/protocol/protocol-stateable.swift new file mode 100644 index 0000000..8918fb9 --- /dev/null +++ b/Sources/interaction/protocol/protocol-stateable.swift @@ -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 { get set } +} + +public extension Stateable { + func updateState(to state: State) async { + await stateHandler.provider(state) + } +} + +public class StateHandler { + 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) + } + } +} diff --git a/Sources/interaction/protocol/protocol-withable.swift b/Sources/interaction/protocol/protocol-withable.swift new file mode 100644 index 0000000..b1891b6 --- /dev/null +++ b/Sources/interaction/protocol/protocol-withable.swift @@ -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(path: KeyPath, _ closure: (Value) -> Void) -> Self { + closure(self[keyPath: path]) + + return self + } +} diff --git a/Sources/user-interface/controller-template/controller-template-collection.swift b/Sources/user-interface/controller-template/controller-template-collection.swift new file mode 100644 index 0000000..d07c7a6 --- /dev/null +++ b/Sources/user-interface/controller-template/controller-template-collection.swift @@ -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: UIView { + public typealias SectionLayout = (DataSource, Int, S) -> NSCollectionLayoutSection + + public class DataSource: UICollectionViewDiffableDataSource {} + + public let dataSource: DataSource + + public private(set) weak var collectionView: UICollectionView! + + let delegate = DelegateProxy() + + 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(_ 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 { + .init() + } + } +} + +extension Templates.Collection { + public struct LayoutUpdateContext { + weak var source: Templates.Collection! + + public func cell( + 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
{ + .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