Last active
November 4, 2022 05:22
-
-
Save cjnevin/b10022e54279bd9e93ab9208ffca3af3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
struct FormEmptyError: Error {} | |
struct FormCompleteError: Error {} | |
protocol FormProtocol { | |
var isValid: Bool { get } | |
var elements: [any FormElementProtocol] { get } | |
mutating func update() | |
} | |
protocol FormElementProtocol { | |
associatedtype WrappedValue | |
var id: String { get } | |
var wrappedValue: WrappedValue { get set } | |
var isValid: Bool { get } | |
var isVisible: Bool { get set } | |
func setValue(_ value: Any?) | |
} | |
@propertyWrapper | |
struct FormElement<T>: FormElementProtocol { | |
class Storage { | |
var value: T | |
init(value: T) { | |
self.value = value | |
} | |
} | |
private let storage: Storage | |
let id: String | |
var wrappedValue: T { | |
get { storage.value } | |
set { storage.value = newValue } | |
} | |
var isVisible: Bool | |
let requirement: (T) -> Bool | |
init( | |
_ value: T, | |
id: String, | |
isVisible: Bool = true, | |
requirement: @escaping (T) -> Bool = { _ in true } | |
) { | |
self.id = id | |
self.storage = .init(value: value) | |
self.isVisible = isVisible | |
self.requirement = requirement | |
} | |
var isValid: Bool { | |
isVisible ? requirement(wrappedValue) : true | |
} | |
func setValue(_ value: Any?) { | |
if let newValue = value as? T { | |
storage.value = newValue | |
} else { | |
assertionFailure("Value was incorrect type: \(value), expected: \(T.self)") | |
} | |
} | |
} | |
struct FormWizard<Form: FormProtocol> { | |
var form: Form | |
var currentElement: any FormElementProtocol | |
init(_ form: Form) { | |
var mutable = form | |
mutable.update() | |
self.form = mutable | |
currentElement = mutable.elements[0] | |
} | |
var currentIndex: Int? { | |
form.elements.firstIndex { $0.id == currentElement.id } | |
} | |
mutating func next() throws { | |
guard let index = currentIndex else { throw FormEmptyError() } | |
let nextIndex = form.elements.index(after: index) | |
guard form.elements.indices.contains(nextIndex) else { | |
throw FormCompleteError() | |
} | |
guard form.elements[nextIndex].isVisible else { | |
currentElement = form.elements[nextIndex] | |
print("skipped \(currentElement.id)") | |
return try next() | |
} | |
currentElement = form.elements[nextIndex] | |
} | |
mutating func setValue(_ newValue: Any?) { | |
currentElement.setValue(newValue) | |
form.update() | |
} | |
} | |
struct SmartForm: FormProtocol { | |
@FormElement(nil, id: "choice", requirement: notEmpty) | |
var choice: String? | |
var choiceId: String { _choice.id } | |
@FormElement([], id: "choices", requirement: count(1...3)) | |
var choices: [String] | |
var choicesId: String { _choices.id } | |
@FormElement(nil, id: "text", requirement: notEmpty) | |
var text: String? | |
var textId: String { _text.id } | |
mutating func update() { | |
_text.isVisible = choices.contains("Other") | |
if !_text.isVisible { | |
text = nil | |
} | |
} | |
var elements: [any FormElementProtocol] { | |
[_choice, _choices, _text] | |
} | |
var isValid: Bool { | |
_choice.isValid && _choices.isValid && _text.isValid | |
} | |
} | |
private func count(_ range: ClosedRange<Int>) -> ([String]) -> Bool { | |
{ elements in range ~= elements.count } | |
} | |
private func notEmpty(_ string: String?) -> Bool { | |
string != nil && string?.isEmpty == false | |
} | |
protocol FormWorkerDelegate: AnyObject { | |
associatedtype Form: FormProtocol | |
func setAssociatedValues(for id: String, values: Any) | |
func setFormValidity(_ isValid: Bool) | |
func showElement(_ element: any FormElementProtocol) | |
func didFinish(form: Form) | |
} | |
class FormWorker<Form: FormProtocol, Delegate: FormWorkerDelegate> where Delegate.Form == Form { | |
weak var delegate: Delegate? | |
private var wizard: FormWizard<Form> | |
init(_ form: Form) { | |
wizard = .init(form) | |
} | |
func start(at index: Int) { | |
wizard.currentElement = wizard.form.elements[index] | |
start() | |
} | |
func start() { | |
showCurrentElement() | |
} | |
func next() { | |
do { | |
print(wizard.currentElement.id, "->", "next") | |
try wizard.next() | |
showCurrentElement() | |
} catch { | |
delegate?.didFinish(form: wizard.form) | |
} | |
} | |
func setCurrentValue(_ value: Any?) { | |
wizard.setValue(value) | |
print(wizard.currentElement.id, "->", "set value", value, "[ valid = \(wizard.currentElement.isValid) ]") | |
delegate?.setFormValidity(wizard.form.isValid) | |
} | |
func fetchChoiceItems() { | |
delegate?.setAssociatedValues(for: "choice", values: ["Single", "One"]) | |
} | |
func fetchChoicesItems() { | |
delegate?.setAssociatedValues(for: "choices", values: ["Multi", "Many", "Other"]) | |
} | |
private func showCurrentElement() { | |
delegate?.showElement(wizard.currentElement) | |
switch wizard.currentElement.id { | |
case "choice": fetchChoiceItems() | |
case "choices": fetchChoicesItems() | |
default: break | |
} | |
} | |
} | |
let worker = FormWorker<SmartForm, SmartFormInteractor>(SmartForm()) | |
let interactor = SmartFormInteractor() | |
worker.delegate = interactor | |
protocol SmartFormInteractorDelegate: AnyObject { | |
func setFormIsIncomplete(_ isIncomplete: Bool) | |
func showChoices(_ choices: Choices) | |
func showText(_ text: String?) | |
func didFinishForm() | |
} | |
class SmartFormPresenter: SmartFormInteractorDelegate { | |
func setFormIsIncomplete(_ isIncomplete: Bool) { | |
print("[presenter] form \(isIncomplete ? "incomplete" : "complete")") | |
} | |
func showChoices(_ choices: Choices) { | |
print("[presenter] show choices") | |
} | |
func showText(_ text: String?) { | |
print("[presenter] show text") | |
} | |
func didFinishForm() { | |
print("[presenter] did finish") | |
} | |
} | |
let presenter = SmartFormPresenter() | |
interactor.delegate = presenter | |
class SmartFormInteractor: FormWorkerDelegate { | |
weak var delegate: SmartFormInteractorDelegate? | |
var associatedValues: [String: Any] = [:] | |
var currentElement: (any FormElementProtocol)? | |
func setAssociatedValues(for id: String, values: Any) { | |
associatedValues[id] = values | |
if let element = currentElement, id == element.id { | |
showElement(element) | |
} | |
} | |
func setFormValidity(_ isValid: Bool) { | |
print("form [ valid = \(isValid) ]") | |
delegate?.setFormIsIncomplete(!isValid) | |
} | |
func showElement(_ element: any FormElementProtocol) { | |
switch ( | |
element.id, | |
element.wrappedValue, | |
associatedValues[element.id] | |
) { | |
case ( | |
"choice", | |
let value as String?, | |
let choices as [String]? | |
): | |
if let choices = choices { | |
delegate?.showChoices(Choices( | |
selected: value, | |
all: choices | |
)) | |
} | |
print("choice", "<-", value, "from", choices ?? "loading...", "[ valid = \(element.isValid) ]") | |
case ( | |
"choices", | |
let values as [String], | |
let choices as [String]? | |
): | |
if let choices = choices { | |
delegate?.showChoices(Choices( | |
selected: values, | |
all: choices | |
)) | |
} | |
print("choices", "<-", values, "from", choices ?? "loading...", "[ valid = \(element.isValid) ]") | |
case ( | |
"text", | |
let value as String?, | |
.none | |
): | |
print("text", "<-", value, "[ valid = \(element.isValid) ]") | |
delegate?.showText(value) | |
default: | |
break | |
} | |
currentElement = element | |
} | |
func didFinish(form: SmartForm) { | |
print("finished") | |
dump(form) | |
delegate?.didFinishForm() | |
} | |
} | |
worker.start() | |
worker.setCurrentValue("Single") | |
worker.next() | |
worker.setCurrentValue(["Many", "Other"]) | |
worker.next() | |
worker.setCurrentValue("Description") | |
worker.next() | |
worker.start(at: 0) | |
worker.setCurrentValue("") | |
worker.setCurrentValue("One") | |
worker.start(at: 1) | |
worker.setCurrentValue(["Many"]) | |
worker.next() | |
struct Choices { | |
struct Choice { | |
let text: String | |
let selected: Bool | |
} | |
let multiSelect: Bool | |
let items: [Choice] | |
init(selected: String?, all: [String]) { | |
multiSelect = false | |
items = all.map { | |
.init(text: $0, selected: $0 == selected) | |
} | |
} | |
init(selected: [String], all: [String]) { | |
multiSelect = true | |
items = all.map { | |
.init(text: $0, selected: selected.contains($0)) | |
} | |
} | |
} |
Author
cjnevin
commented
Nov 3, 2022
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment