diff --git a/0292-cross-platform-pt3/README.md b/0292-cross-platform-pt3/README.md index f8b7baed..f9d4d9c8 100644 --- a/0292-cross-platform-pt3/README.md +++ b/0292-cross-platform-pt3/README.md @@ -1,5 +1,5 @@ ## [Point-Free](https://www.pointfree.co) -> #### This directory contains code from Point-Free Episode: [Cross-Platform Swift: Networkgin](https://www.pointfree.co/episodes/ep292-cross-platform-networking) +> #### This directory contains code from Point-Free Episode: [Cross-Platform Swift: Networking](https://www.pointfree.co/episodes/ep292-cross-platform-networking) > > Let’s dial up the complexity of our Wasm application! We’ll introduce some async logic in the form of a network request. We’ll take steps to not only control this dependency, but we’ll do so across both Apple and Wasm platforms, and we’ll isolate its interface from its live implementation to speed up our builds and reduce our app’s size. diff --git a/0293-cross-platform-pt4/Counter/.gitignore b/0293-cross-platform-pt4/Counter/.gitignore new file mode 100644 index 00000000..0023a534 --- /dev/null +++ b/0293-cross-platform-pt4/Counter/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/0293-cross-platform-pt4/Counter/.swift-version b/0293-cross-platform-pt4/Counter/.swift-version new file mode 100644 index 00000000..2304abaf --- /dev/null +++ b/0293-cross-platform-pt4/Counter/.swift-version @@ -0,0 +1 @@ +wasm-DEVELOPMENT-SNAPSHOT-2024-07-16-a diff --git a/0293-cross-platform-pt4/Counter/Package.swift b/0293-cross-platform-pt4/Counter/Package.swift new file mode 100644 index 00000000..07ac5bff --- /dev/null +++ b/0293-cross-platform-pt4/Counter/Package.swift @@ -0,0 +1,61 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "Counter", + platforms: [.iOS(.v16), .macOS(.v14)], + products: [ + .library( + name: "Counter", + targets: ["Counter"] + ), + .library( + name: "FactClient", + targets: ["FactClient"] + ), + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-navigation", from: "2.0.0"), + .package(url: "https://github.com/pointfreeco/swift-perception", from: "1.0.0"), + .package(url: "https://github.com/swiftwasm/carton", from: "1.0.0"), + .package(url: "https://github.com/swiftwasm/JavaScriptKit", exact: "0.19.2"), + ], + targets: [ + .executableTarget( + name: "WasmApp", + dependencies: [ + "Counter", + "FactClientLive", + .product(name: "SwiftNavigation", package: "swift-navigation"), + .product(name: "JavaScriptEventLoop", package: "JavaScriptKit"), + .product(name: "JavaScriptKit", package: "JavaScriptKit"), + ] + ), + .target( + name: "Counter", + dependencies: [ + "FactClient", + .product(name: "SwiftNavigation", package: "swift-navigation"), + .product(name: "Perception", package: "swift-perception") + ] + ), + .target( + name: "FactClient", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + .product(name: "DependenciesMacros", package: "swift-dependencies"), + ] + ), + .target( + name: "FactClientLive", + dependencies: [ + "FactClient", + .product(name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), + .product(name: "JavaScriptEventLoop", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), + ] + ) + ], + swiftLanguageVersions: [.v6] +) diff --git a/0293-cross-platform-pt4/Counter/Sources/Counter/CounterModel.swift b/0293-cross-platform-pt4/Counter/Sources/Counter/CounterModel.swift new file mode 100644 index 00000000..445e3ee8 --- /dev/null +++ b/0293-cross-platform-pt4/Counter/Sources/Counter/CounterModel.swift @@ -0,0 +1,71 @@ +import Dependencies +import FactClient +import Foundation +import Perception +import SwiftNavigation + +@MainActor +@Perceptible +public class CounterModel: HashableObject { + @PerceptionIgnored + @Dependency(FactClient.self) var factClient + + public var count = 0 { + didSet { + isTextFocused = !count.isMultiple(of: 3) + } + } + public var alert: AlertState? +// public var fact: Fact? { +// didSet { +// print("Fact didSet", fact?.value) +// } +// } + public var factIsLoading = false + public var isTextFocused = false + public var text = "" + + public struct Fact: Identifiable { + public var value: String + public var id: String { value } + } + + public init() {} + + public func incrementButtonTapped() { + count += 1 + alert = nil + } + + public func decrementButtonTapped() { + count -= 1 + alert = nil + } + + public func factButtonTapped() async { + alert = nil + factIsLoading = true + defer { factIsLoading = false } + + do { + try await Task.sleep(for: .seconds(1)) + + var count = count + let fact = try await factClient.fetch(count) + alert = AlertState { + TextState("Fact") + } actions: { + ButtonState { + TextState("OK") + } + ButtonState { + TextState("Save") + } + } message: { + TextState(fact) + } + } catch { + // TODO: error handling + } + } +} diff --git a/0293-cross-platform-pt4/Counter/Sources/FactClient/FactClient.swift b/0293-cross-platform-pt4/Counter/Sources/FactClient/FactClient.swift new file mode 100644 index 00000000..d0d1998f --- /dev/null +++ b/0293-cross-platform-pt4/Counter/Sources/FactClient/FactClient.swift @@ -0,0 +1,11 @@ +import Dependencies +import DependenciesMacros + +@DependencyClient +public struct FactClient: Sendable { + public var fetch: @Sendable (Int) async throws -> String +} + +extension FactClient: TestDependencyKey { + public static let testValue = FactClient() +} diff --git a/0293-cross-platform-pt4/Counter/Sources/FactClientLive/FactClientLive.swift b/0293-cross-platform-pt4/Counter/Sources/FactClientLive/FactClientLive.swift new file mode 100644 index 00000000..e0bffb16 --- /dev/null +++ b/0293-cross-platform-pt4/Counter/Sources/FactClientLive/FactClientLive.swift @@ -0,0 +1,28 @@ +import FactClient +import Dependencies + +#if canImport(JavaScriptKit) + @preconcurrency import JavaScriptKit +#endif +#if canImport(JavaScriptEventLoop) + import JavaScriptEventLoop +#endif + +extension FactClient: DependencyKey { + public static let liveValue = FactClient { number in +#if canImport(JavaScriptKit) && canImport(JavaScriptEventLoop) + let response = try await JSPromise( + JSObject.global.fetch!("http://www.numberapi.com/\(number)").object! + )!.value + return try await JSPromise(response.text().object!)!.value.string! +#else + return try await String( + decoding: URLSession.shared + .data( + from: URL(string: "http://www.numberapi.com/\(number)")! + ).0, + as: UTF8.self + ) +#endif + } +} diff --git a/0293-cross-platform-pt4/Counter/Sources/WasmApp/App.swift b/0293-cross-platform-pt4/Counter/Sources/WasmApp/App.swift new file mode 100644 index 00000000..66f0d1fa --- /dev/null +++ b/0293-cross-platform-pt4/Counter/Sources/WasmApp/App.swift @@ -0,0 +1,93 @@ +import Counter +import IssueReporting +import JavaScriptEventLoop +import JavaScriptKit +import SwiftNavigation + +@main +@MainActor +struct App { + static var tokens: Set = [] + + static func main() { + IssueReporters.current = [JavaScriptConsoleWarning()] + JavaScriptEventLoop.installGlobalExecutor() + + @UIBindable var model = CounterModel() + + let document = JSObject.global.document + + var countLabel = document.createElement("span") + _ = document.body.appendChild(countLabel) + + var decrementButton = document.createElement("button") + decrementButton.innerText = "-" + decrementButton.onclick = .object( + JSClosure { _ in + model.decrementButtonTapped() + return .undefined + } + ) + _ = document.body.appendChild(decrementButton) + + var incrementButton = document.createElement("button") + incrementButton.innerText = "+" + incrementButton.onclick = .object( + JSClosure { _ in + model.incrementButtonTapped() + return .undefined + } + ) + _ = document.body.appendChild(incrementButton) + + var factButton = document.createElement("button") + factButton.innerText = "Get fact" + factButton.onclick = .object( + JSClosure { _ in + Task { await model.factButtonTapped() } + return .undefined + } + ) + _ = document.body.appendChild(factButton) + + var factLabel = document.createElement("div") + _ = document.body.appendChild(factLabel) + + observe { + countLabel.innerText = .string("Count: \(model.count)") + + if model.factIsLoading { + factLabel.innerText = "Fact is loading..." + } else { + factLabel.innerText = "" + } + } + .store(in: &tokens) + +// alertDialog(item: $model.fact) { _ in +// "Fact" +// } message: { fact in +// fact.value +// } +// .store(in: &tokens) + + alertDialog($model.alert) + .store(in: &tokens) + } +} + +struct JavaScriptConsoleWarning: IssueReporter { + func reportIssue( + _ message: @autoclosure () -> String?, + fileID: StaticString, + filePath: StaticString, + line: UInt, + column: UInt + ) { + #if DEBUG + _ = JSObject.global.console.warn(""" + \(fileID):\(line) - \(message() ?? "") + """) + #endif + } +} diff --git a/0293-cross-platform-pt4/Counter/Sources/WasmApp/Navigation.swift b/0293-cross-platform-pt4/Counter/Sources/WasmApp/Navigation.swift new file mode 100644 index 00000000..77ed37e0 --- /dev/null +++ b/0293-cross-platform-pt4/Counter/Sources/WasmApp/Navigation.swift @@ -0,0 +1,123 @@ +import JavaScriptKit +import SwiftNavigation + +func alert( + item: UIBinding, + message: @escaping @Sendable (Item) -> String +) -> ObservationToken { + observe { + if let unwrappedItem = item.wrappedValue { + _ = JSObject.global.window.alert(message(unwrappedItem)) + item.wrappedValue = nil + } + } +} + +@MainActor +func alertDialog( + item: UIBinding, + title titleFromItem: @escaping @Sendable (Item) -> String, + message messageFromItem: @escaping @Sendable (Item) -> String +) -> ObservationToken { + let document = JSObject.global.document + + var dialog = document.createElement("dialog") + var title = document.createElement("h1") + _ = dialog.appendChild(title) + var message = document.createElement("p") + _ = dialog.appendChild(message) + var closeButton = document.createElement("button") + closeButton.innerText = "Close" + closeButton.onclick = .object( + JSClosure { _ in + item.wrappedValue = nil + return .undefined + } + ) + dialog.oncancel = .object( + JSClosure { _ in + item.wrappedValue = nil + return .undefined + } + ) + _ = dialog.appendChild(closeButton) + _ = document.body.appendChild(dialog) + + return observe { + if let unwrappedItem = item.wrappedValue { + title.innerText = .string(titleFromItem(unwrappedItem)) + message.innerText = .string(messageFromItem(unwrappedItem)) + _ = dialog.showModal() + } else { + _ = dialog.close() + } + } +} + +@MainActor +func alertDialog( + _ state: UIBinding?> +) -> ObservationToken { + alertDialog(state) { _ in } +} + +@MainActor +func alertDialog( + _ state: UIBinding?>, + action handler: @escaping @Sendable (Action) -> Void +) -> ObservationToken { + let document = JSObject.global.document + + var dialog = document.createElement("dialog") + var title = document.createElement("h1") + _ = dialog.appendChild(title) + var message = document.createElement("p") + _ = dialog.appendChild(message) + dialog.oncancel = .object( + JSClosure { _ in + state.wrappedValue = nil + return .undefined + } + ) + _ = document.body.appendChild(dialog) + + return observe { + if let alertState = state.wrappedValue { + title.innerText = .string(String(state: alertState.title)) + message.innerText = .string(alertState.message.map { String(state: $0) } ?? "") + message.hidden = .boolean(alertState.message == nil) + _ = dialog.querySelectorAll("button").forEach(JSClosure { arguments in + arguments.first!.remove() + }) + if alertState.buttons.isEmpty { + var closeButton = document.createElement("button") + closeButton.innerText = "OK" + closeButton.onclick = .object( + JSClosure { _ in + state.wrappedValue = nil + return .undefined + } + ) + _ = dialog.appendChild(closeButton) + } + for buttonState in alertState.buttons { + var button = document.createElement("button") + button.innerText = .string(String(state: buttonState.label)) + button.onclick = .object( + JSClosure { _ in + buttonState.withAction { action in + guard let action else { return } + handler(action) + } + state.wrappedValue = nil + return .undefined + } + ) + _ = dialog.appendChild(button) + } + _ = dialog.showModal() + } else { + _ = dialog.close() + } + } +} diff --git a/0293-cross-platform-pt4/ModernUIKit.xcodeproj/project.pbxproj b/0293-cross-platform-pt4/ModernUIKit.xcodeproj/project.pbxproj new file mode 100644 index 00000000..750b1e19 --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit.xcodeproj/project.pbxproj @@ -0,0 +1,454 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 2A154CF02C5D56C7009FC7D9 /* Epoxy in Frameworks */ = {isa = PBXBuildFile; productRef = 2A154CEF2C5D56C7009FC7D9 /* Epoxy */; }; + 2A154CF22C5D56E1009FC7D9 /* CounterFeatureEpoxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A154CF12C5D56E1009FC7D9 /* CounterFeatureEpoxy.swift */; }; + 2A154CF42C5D607D009FC7D9 /* Counter in Resources */ = {isa = PBXBuildFile; fileRef = 2A154CF32C5D607D009FC7D9 /* Counter */; }; + 2A154CF82C5D620A009FC7D9 /* .swift-version in Resources */ = {isa = PBXBuildFile; fileRef = 2A154CF72C5D6206009FC7D9 /* .swift-version */; }; + 2A1CEB272BFD271600753A66 /* Perception in Frameworks */ = {isa = PBXBuildFile; productRef = 2A1CEB262BFD271600753A66 /* Perception */; }; + 2A6230292BFEA5C600930179 /* AppFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A6230282BFEA5C600930179 /* AppFeature.swift */; }; + 4B54C2252BFD0D1900E95174 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B54C2242BFD0D1900E95174 /* App.swift */; }; + 4B54C2292BFD0D1A00E95174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B54C2282BFD0D1A00E95174 /* Assets.xcassets */; }; + 4B54C22C2BFD0D1A00E95174 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B54C22B2BFD0D1A00E95174 /* Preview Assets.xcassets */; }; + 4B54C24F2BFD0D9B00E95174 /* CounterFeatureSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B54C24E2BFD0D9B00E95174 /* CounterFeatureSwiftUI.swift */; }; + 4BB60BE42BFE8098002516B4 /* SettingsFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB60BE32BFE8098002516B4 /* SettingsFeature.swift */; }; + CA5C1D632C5932B500F8882D /* SwiftNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5C1D622C5932B500F8882D /* SwiftNavigation */; }; + CA5C1D652C5932B500F8882D /* SwiftUINavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5C1D642C5932B500F8882D /* SwiftUINavigation */; }; + CA5C1D672C5932B500F8882D /* UIKitNavigation in Frameworks */ = {isa = PBXBuildFile; productRef = CA5C1D662C5932B500F8882D /* UIKitNavigation */; }; + CA5C1D6B2C5939D400F8882D /* CounterFeatureUIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5C1D6A2C5939D400F8882D /* CounterFeatureUIKit.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2A154CF12C5D56E1009FC7D9 /* CounterFeatureEpoxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterFeatureEpoxy.swift; sourceTree = ""; }; + 2A154CF32C5D607D009FC7D9 /* Counter */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Counter; sourceTree = SOURCE_ROOT; }; + 2A154CF72C5D6206009FC7D9 /* .swift-version */ = {isa = PBXFileReference; lastKnownFileType = text; path = ".swift-version"; sourceTree = ""; }; + 2A1CEB242BFD1A3800753A66 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 2A6230282BFEA5C600930179 /* AppFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppFeature.swift; sourceTree = ""; }; + 4B54C2212BFD0D1900E95174 /* ModernUIKit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ModernUIKit.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 4B54C2242BFD0D1900E95174 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + 4B54C2282BFD0D1A00E95174 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 4B54C22B2BFD0D1A00E95174 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 4B54C24E2BFD0D9B00E95174 /* CounterFeatureSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterFeatureSwiftUI.swift; sourceTree = ""; }; + 4BB60BE32BFE8098002516B4 /* SettingsFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFeature.swift; sourceTree = ""; }; + CA5C1D6A2C5939D400F8882D /* CounterFeatureUIKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterFeatureUIKit.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4B54C21E2BFD0D1900E95174 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A154CF02C5D56C7009FC7D9 /* Epoxy in Frameworks */, + CA5C1D632C5932B500F8882D /* SwiftNavigation in Frameworks */, + CA5C1D672C5932B500F8882D /* UIKitNavigation in Frameworks */, + 2A1CEB272BFD271600753A66 /* Perception in Frameworks */, + CA5C1D652C5932B500F8882D /* SwiftUINavigation in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4B54C2182BFD0D1900E95174 = { + isa = PBXGroup; + children = ( + 4B54C2232BFD0D1900E95174 /* ModernUIKit */, + 4B54C2222BFD0D1900E95174 /* Products */, + ); + sourceTree = ""; + }; + 4B54C2222BFD0D1900E95174 /* Products */ = { + isa = PBXGroup; + children = ( + 4B54C2212BFD0D1900E95174 /* ModernUIKit.app */, + ); + name = Products; + sourceTree = ""; + }; + 4B54C2232BFD0D1900E95174 /* ModernUIKit */ = { + isa = PBXGroup; + children = ( + 2A154CF72C5D6206009FC7D9 /* .swift-version */, + 2A154CF32C5D607D009FC7D9 /* Counter */, + 2A1CEB242BFD1A3800753A66 /* Info.plist */, + 4B54C2242BFD0D1900E95174 /* App.swift */, + 2A6230282BFEA5C600930179 /* AppFeature.swift */, + 2A154CF12C5D56E1009FC7D9 /* CounterFeatureEpoxy.swift */, + 4B54C24E2BFD0D9B00E95174 /* CounterFeatureSwiftUI.swift */, + CA5C1D6A2C5939D400F8882D /* CounterFeatureUIKit.swift */, + 4BB60BE32BFE8098002516B4 /* SettingsFeature.swift */, + 4B54C2282BFD0D1A00E95174 /* Assets.xcassets */, + 4B54C22A2BFD0D1A00E95174 /* Preview Content */, + ); + path = ModernUIKit; + sourceTree = ""; + }; + 4B54C22A2BFD0D1A00E95174 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 4B54C22B2BFD0D1A00E95174 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 4B54C2202BFD0D1900E95174 /* ModernUIKit */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4B54C2452BFD0D1A00E95174 /* Build configuration list for PBXNativeTarget "ModernUIKit" */; + buildPhases = ( + 4B54C21D2BFD0D1900E95174 /* Sources */, + 4B54C21E2BFD0D1900E95174 /* Frameworks */, + 4B54C21F2BFD0D1900E95174 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ModernUIKit; + packageProductDependencies = ( + 2A1CEB262BFD271600753A66 /* Perception */, + CA5C1D622C5932B500F8882D /* SwiftNavigation */, + CA5C1D642C5932B500F8882D /* SwiftUINavigation */, + CA5C1D662C5932B500F8882D /* UIKitNavigation */, + 2A154CEF2C5D56C7009FC7D9 /* Epoxy */, + ); + productName = ModernUIKit; + productReference = 4B54C2212BFD0D1900E95174 /* ModernUIKit.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4B54C2192BFD0D1900E95174 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1540; + TargetAttributes = { + 4B54C2202BFD0D1900E95174 = { + CreatedOnToolsVersion = 15.4; + }; + }; + }; + buildConfigurationList = 4B54C21C2BFD0D1900E95174 /* Build configuration list for PBXProject "ModernUIKit" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 4B54C2182BFD0D1900E95174; + packageReferences = ( + 2A1CEB252BFD271600753A66 /* XCRemoteSwiftPackageReference "swift-perception" */, + CA5C1D612C5932B500F8882D /* XCRemoteSwiftPackageReference "swift-navigation" */, + 2A154CEE2C5D56C7009FC7D9 /* XCRemoteSwiftPackageReference "epoxy-ios" */, + ); + productRefGroup = 4B54C2222BFD0D1900E95174 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 4B54C2202BFD0D1900E95174 /* ModernUIKit */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 4B54C21F2BFD0D1900E95174 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A154CF82C5D620A009FC7D9 /* .swift-version in Resources */, + 2A154CF42C5D607D009FC7D9 /* Counter in Resources */, + 4B54C22C2BFD0D1A00E95174 /* Preview Assets.xcassets in Resources */, + 4B54C2292BFD0D1A00E95174 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 4B54C21D2BFD0D1900E95174 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CA5C1D6B2C5939D400F8882D /* CounterFeatureUIKit.swift in Sources */, + 4B54C2252BFD0D1900E95174 /* App.swift in Sources */, + 4B54C24F2BFD0D9B00E95174 /* CounterFeatureSwiftUI.swift in Sources */, + 4BB60BE42BFE8098002516B4 /* SettingsFeature.swift in Sources */, + 2A154CF22C5D56E1009FC7D9 /* CounterFeatureEpoxy.swift in Sources */, + 2A6230292BFEA5C600930179 /* AppFeature.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 4B54C2432BFD0D1A00E95174 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + }; + name = Debug; + }; + 4B54C2442BFD0D1A00E95174 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 4B54C2462BFD0D1A00E95174 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ModernUIKit/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ModernUIKit/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.ModernUIKit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4B54C2472BFD0D1A00E95174 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"ModernUIKit/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ModernUIKit/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.ModernUIKit; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 4B54C21C2BFD0D1900E95174 /* Build configuration list for PBXProject "ModernUIKit" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B54C2432BFD0D1A00E95174 /* Debug */, + 4B54C2442BFD0D1A00E95174 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4B54C2452BFD0D1A00E95174 /* Build configuration list for PBXNativeTarget "ModernUIKit" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4B54C2462BFD0D1A00E95174 /* Debug */, + 4B54C2472BFD0D1A00E95174 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 2A154CEE2C5D56C7009FC7D9 /* XCRemoteSwiftPackageReference "epoxy-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/airbnb/epoxy-ios"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.10.0; + }; + }; + 2A1CEB252BFD271600753A66 /* XCRemoteSwiftPackageReference "swift-perception" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-perception"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.7; + }; + }; + CA5C1D612C5932B500F8882D /* XCRemoteSwiftPackageReference "swift-navigation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-navigation.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 2A154CEF2C5D56C7009FC7D9 /* Epoxy */ = { + isa = XCSwiftPackageProductDependency; + package = 2A154CEE2C5D56C7009FC7D9 /* XCRemoteSwiftPackageReference "epoxy-ios" */; + productName = Epoxy; + }; + 2A1CEB262BFD271600753A66 /* Perception */ = { + isa = XCSwiftPackageProductDependency; + package = 2A1CEB252BFD271600753A66 /* XCRemoteSwiftPackageReference "swift-perception" */; + productName = Perception; + }; + CA5C1D622C5932B500F8882D /* SwiftNavigation */ = { + isa = XCSwiftPackageProductDependency; + package = CA5C1D612C5932B500F8882D /* XCRemoteSwiftPackageReference "swift-navigation" */; + productName = SwiftNavigation; + }; + CA5C1D642C5932B500F8882D /* SwiftUINavigation */ = { + isa = XCSwiftPackageProductDependency; + package = CA5C1D612C5932B500F8882D /* XCRemoteSwiftPackageReference "swift-navigation" */; + productName = SwiftUINavigation; + }; + CA5C1D662C5932B500F8882D /* UIKitNavigation */ = { + isa = XCSwiftPackageProductDependency; + package = CA5C1D612C5932B500F8882D /* XCRemoteSwiftPackageReference "swift-navigation" */; + productName = UIKitNavigation; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 4B54C2192BFD0D1900E95174 /* Project object */; +} diff --git a/0293-cross-platform-pt4/ModernUIKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/0293-cross-platform-pt4/ModernUIKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/0293-cross-platform-pt4/ModernUIKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/0293-cross-platform-pt4/ModernUIKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/0293-cross-platform-pt4/ModernUIKit/.swift-version b/0293-cross-platform-pt4/ModernUIKit/.swift-version new file mode 100644 index 00000000..2304abaf --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/.swift-version @@ -0,0 +1 @@ +wasm-DEVELOPMENT-SNAPSHOT-2024-07-16-a diff --git a/0293-cross-platform-pt4/ModernUIKit/App.swift b/0293-cross-platform-pt4/ModernUIKit/App.swift new file mode 100644 index 00000000..200192cb --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/App.swift @@ -0,0 +1,15 @@ +import SwiftUI +import UIKitNavigation + +@main +struct ModernUIKitApp: App { + var body: some Scene { + WindowGroup { + UIViewControllerRepresenting { + NavigationStackController( + model: AppModel() + ) + } + } + } +} diff --git a/0293-cross-platform-pt4/ModernUIKit/AppFeature.swift b/0293-cross-platform-pt4/ModernUIKit/AppFeature.swift new file mode 100644 index 00000000..685ccb93 --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/AppFeature.swift @@ -0,0 +1,89 @@ +import Perception +import SwiftUI +import UIKitNavigation + +@Perceptible +class AppModel { + var path: [Path] + init(path: [Path] = []) { + self.path = path + } + + enum Path: Hashable { + case counter(CounterModel) + case settings(SettingsModel) + } +} + +struct AppView: View { + @Perception.Bindable var model: AppModel + + var body: some View { + WithPerceptionTracking { + NavigationStack(path: $model.path) { + Form { + Button("Counter") { + model.path.append(.counter(CounterModel())) + } + Button("Settings") { + model.path.append(.settings(SettingsModel())) + } + + NavigationLink("Counter w/ value", value: AppModel.Path.counter(CounterModel())) + } + .navigationDestination(for: AppModel.Path.self) { path in + switch path { + case let .counter(model): + CounterView(model: model) + case let .settings(model): + SettingsView(model: model) + } + } + } + } + } +} + +extension NavigationStackController { + convenience init(model: AppModel) { + @UIBindable var model = model + self.init(path: $model.path) { + RootViewController() + } + self.navigationDestination(for: AppModel.Path.self) { path in + switch path { + case let .counter(model): + CounterViewController(model: model) + case let .settings(model): + SettingsViewController(model: model) + } + } + } +} + +final class RootViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + + let counterButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + self?.navigationController?.push(value: AppModel.Path.counter(CounterModel())) + }) + counterButton.setTitle("Counter", for: .normal) + let settingsButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + self?.navigationController?.push(value: AppModel.Path.settings(SettingsModel())) + }) + settingsButton.setTitle("Settings", for: .normal) + let stack = UIStackView(arrangedSubviews: [ + counterButton, + settingsButton, + ]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } +} diff --git a/0293-cross-platform-pt4/ModernUIKit/Assets.xcassets/AccentColor.colorset/Contents.json b/0293-cross-platform-pt4/ModernUIKit/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0293-cross-platform-pt4/ModernUIKit/Assets.xcassets/AppIcon.appiconset/Contents.json b/0293-cross-platform-pt4/ModernUIKit/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..13613e3e --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0293-cross-platform-pt4/ModernUIKit/Assets.xcassets/Contents.json b/0293-cross-platform-pt4/ModernUIKit/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0293-cross-platform-pt4/ModernUIKit/CounterFeatureEpoxy.swift b/0293-cross-platform-pt4/ModernUIKit/CounterFeatureEpoxy.swift new file mode 100644 index 00000000..02108c4f --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/CounterFeatureEpoxy.swift @@ -0,0 +1,232 @@ +import Epoxy +import UIKit +import UIKitNavigation + +class EpoxyCounterViewController: CollectionViewController { + @UIBindable var model: CounterModel + + init(model: CounterModel) { + self.model = model + super.init( + layout: UICollectionViewCompositionalLayout.list( + using: UICollectionLayoutListConfiguration( + appearance: .grouped + ) + ) + ) + } + + private enum DataID { + case activity + case count + case decrementButton + case fact + case factButton + case incrementButton + } + + override func viewDidLoad() { + super.viewDidLoad() + + observe { [weak self] in + guard let self else { return } + + setItems(items, animated: false) + } + + present(item: $model.fact) { fact in + let alert = UIAlertController( + title: "Fact", + message: fact.value, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + return alert + } + } + + @ItemModelBuilder + private var items: [ItemModeling] { + Label.itemModel( + dataID: DataID.count, + content: "Count: \(model.count)", + style: .style(with: .title1) + ) + ButtonRow.itemModel( + dataID: DataID.decrementButton, + content: ButtonRow.Content(text: "Decrement"), + behaviors: ButtonRow.Behaviors( + didTap: { [weak self] in + self?.model.decrementButtonTapped() + } + ) + ) + ButtonRow.itemModel( + dataID: DataID.incrementButton, + content: ButtonRow.Content(text: "Increment"), + behaviors: ButtonRow.Behaviors( + didTap: { [weak self] in + self?.model.incrementButtonTapped() + } + ) + ) + ButtonRow.itemModel( + dataID: DataID.factButton, + content: ButtonRow.Content(text: "Get fact"), + behaviors: ButtonRow.Behaviors( + didTap: { [weak self] in + Task { + await self?.model.factButtonTapped() + } + } + ) + ) +// if let fact = model.fact { +// Label.itemModel( +// dataID: DataID.fact, +// content: fact.value, +// style: .style(with: .body) +// ) +// } else { + ActivityIndicatorView.itemModel( + dataID: DataID.activity, + content: model.factIsLoading, + style: .large + ) +// } + } +} + +final class ActivityIndicatorView: UIActivityIndicatorView, EpoxyableView { + func setContent(_ content: Bool, animated: Bool) { + if content { + startAnimating() + } else { + stopAnimating() + } + } +} + +final class Label: UILabel, @preconcurrency EpoxyableView { + + // MARK: Lifecycle + + init(style: Style) { + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + font = style.font + numberOfLines = style.numberOfLines + if style.showLabelBackground { + backgroundColor = .secondarySystemBackground + } + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + struct Style: Hashable { + let font: UIFont + let showLabelBackground: Bool + var numberOfLines = 0 + } + + typealias Content = String + + func setContent(_ content: String, animated _: Bool) { + text = content + } +} + +extension Label.Style { + static func style( + with textStyle: UIFont.TextStyle, + showBackground: Bool = false + ) -> Label.Style { + .init( + font: UIFont.preferredFont(forTextStyle: textStyle), + showLabelBackground: showBackground + ) + } +} + +final class ButtonRow: UIView, @preconcurrency EpoxyableView { + + // MARK: Lifecycle + + init() { + super.init(frame: .zero) + setUp() + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Internal + + struct Behaviors { + var didTap: (() -> Void)? + } + + struct Content: Equatable { + var text: String? + } + + func setContent(_ content: Content, animated _: Bool) { + text = content.text + } + + func setBehaviors(_ behaviors: Behaviors?) { + didTap = behaviors?.didTap + } + + // MARK: Private + + private let button = UIButton(type: .system) + private var didTap: (() -> Void)? + + private var text: String? { + get { button.title(for: .normal) } + set { button.setTitle(newValue, for: .normal) } + } + + private func setUp() { + translatesAutoresizingMaskIntoConstraints = false + layoutMargins = UIEdgeInsets(top: 20, left: 24, bottom: 20, right: 24) + backgroundColor = .quaternarySystemFill + + button.tintColor = .systemBlue + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .title3) + button.translatesAutoresizingMaskIntoConstraints = false + + addSubview(button) + NSLayoutConstraint.activate([ + button.leadingAnchor + .constraint(equalTo: layoutMarginsGuide.leadingAnchor), + button.topAnchor + .constraint(equalTo: layoutMarginsGuide.topAnchor), + button.trailingAnchor + .constraint(equalTo: layoutMarginsGuide.trailingAnchor), + button.bottomAnchor + .constraint(equalTo: layoutMarginsGuide.bottomAnchor), + ]) + + button.addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + + @objc + private func handleTap() { + didTap?() + } +} + +import SwiftUI +// #Preview +struct EpoxyCounterViewControllerPreview: PreviewProvider { + static var previews: some View { + UIViewControllerRepresenting { + EpoxyCounterViewController(model: CounterModel()) + } + } +} diff --git a/0293-cross-platform-pt4/ModernUIKit/CounterFeatureSwiftUI.swift b/0293-cross-platform-pt4/ModernUIKit/CounterFeatureSwiftUI.swift new file mode 100644 index 00000000..350d663d --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/CounterFeatureSwiftUI.swift @@ -0,0 +1,36 @@ +import Perception +import SwiftUI +import SwiftUINavigation + +struct CounterView: View { + @Perception.Bindable var model: CounterModel + var body: some View { + WithPerceptionTracking { + Form { + Stepper( + "\(model.count)", + value: $model.count + ) + if model.factIsLoading { + ProgressView().id(UUID()) + } + + Button("Get fact") { + Task { + await model.factButtonTapped() + } + } + } + .disabled(model.factIsLoading) + .sheet(item: $model.fact) { fact in + Text(fact.value) + } + } + } +} + +#Preview("SwiftUI") { + NavigationStack { + CounterView(model: CounterModel()) + } +} diff --git a/0293-cross-platform-pt4/ModernUIKit/CounterFeatureUIKit.swift b/0293-cross-platform-pt4/ModernUIKit/CounterFeatureUIKit.swift new file mode 100644 index 00000000..c6816f96 --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/CounterFeatureUIKit.swift @@ -0,0 +1,125 @@ +import UIKit +import UIKitNavigation +import SwiftUI + +final class CounterViewController: UIViewController { + @UIBindable var model: CounterModel + + init(model: CounterModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + let countLabel = UILabel() + countLabel.textAlignment = .center + + let counter = UIStepper(frame: .zero, value: $model.count.toDouble) + + let textField = UITextField(frame: .zero, text: $model.text) + textField.bind(focus: $model.isTextFocused) + textField.placeholder = "Some text" + textField.borderStyle = .bezel + + let factLabel = UILabel() + factLabel.numberOfLines = 0 + let activityIndicator = UIActivityIndicatorView() + activityIndicator.startAnimating() + let factButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + Task { await self.model.factButtonTapped() } + }) + factButton.setTitle("Get fact", for: .normal) + let resetButton = UIButton(type: .system, primaryAction: UIAction { [weak self] _ in + guard let self else { return } + model.count = 0 + }) + resetButton.setTitle("Reset", for: .normal) + + let counterStack = UIStackView(arrangedSubviews: [ + countLabel, + counter, + resetButton, + textField, + factLabel, + activityIndicator, + factButton, + ]) + counterStack.axis = .vertical + counterStack.spacing = 12 + counterStack.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(counterStack) + NSLayoutConstraint.activate([ + counterStack.centerXAnchor.constraint(equalTo: view.centerXAnchor), + counterStack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + counterStack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + counterStack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + countLabel.text = "\(model.count)" + activityIndicator.isHidden = !model.factIsLoading + counter.isEnabled = !model.factIsLoading + factButton.isEnabled = !model.factIsLoading + } + + present(item: $model.fact) { fact in + FactViewController(fact: fact.value) + } + } +} + +extension Int { + fileprivate var toDouble: Double { + get { + Double(self) + } + set { + self = Int(newValue) + } + } +} + +class FactViewController: UIViewController { + let fact: String + init(fact: String) { + self.fact = fact + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .white + let factLabel = UILabel() + factLabel.text = fact + factLabel.numberOfLines = 0 + factLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(factLabel) + NSLayoutConstraint.activate([ + factLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + factLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + factLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + factLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + ]) + } +} + +#Preview("UIKit") { + UIViewControllerRepresenting { + UINavigationController( + rootViewController: CounterViewController(model: CounterModel()) + ) + } +} diff --git a/0293-cross-platform-pt4/ModernUIKit/Info.plist b/0293-cross-platform-pt4/ModernUIKit/Info.plist new file mode 100644 index 00000000..6a6654d9 --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/Info.plist @@ -0,0 +1,11 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/0293-cross-platform-pt4/ModernUIKit/Preview Content/Preview Assets.xcassets/Contents.json b/0293-cross-platform-pt4/ModernUIKit/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/0293-cross-platform-pt4/ModernUIKit/SettingsFeature.swift b/0293-cross-platform-pt4/ModernUIKit/SettingsFeature.swift new file mode 100644 index 00000000..05898d4d --- /dev/null +++ b/0293-cross-platform-pt4/ModernUIKit/SettingsFeature.swift @@ -0,0 +1,71 @@ +import Perception +import SwiftUI +import SwiftUINavigation + +@MainActor +@Perceptible +class SettingsModel: HashableObject { + var isOn = false +} + +struct SettingsView: View { + @Perception.Bindable var model: SettingsModel + + var body: some View { + WithPerceptionTracking { + Form { + Toggle(isOn: $model.isOn) { + Text("Is on?") + } + } + } + } +} + +class SettingsViewController: UIViewController { + let model: SettingsModel + + init(model: SettingsModel) { + self.model = model + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + + let isOnSwitch = UISwitch() + isOnSwitch.addAction( + UIAction { [weak model = self.model, weak isOnSwitch] _ in + guard let model, let isOnSwitch + else { return } + model.isOn = isOnSwitch.isOn + }, + for: .valueChanged + ) + isOnSwitch.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(isOnSwitch) + + NSLayoutConstraint.activate([ + isOnSwitch.centerXAnchor.constraint(equalTo: view.centerXAnchor), + isOnSwitch.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + observe { [weak self] in + guard let self else { return } + isOnSwitch.setOn(model.isOn, animated: true) + } + } +} + +#Preview("SwiftUI") { + SettingsView(model: SettingsModel()) +} +#Preview("UIKit") { + UIViewControllerRepresenting { + SettingsViewController(model: SettingsModel()) + } +} diff --git a/0293-cross-platform-pt4/README.md b/0293-cross-platform-pt4/README.md new file mode 100644 index 00000000..a83c640c --- /dev/null +++ b/0293-cross-platform-pt4/README.md @@ -0,0 +1,5 @@ +## [Point-Free](https://www.pointfree.co) + +> #### This directory contains code from Point-Free Episode: [Cross-Platform Swift: Navigation](https://www.pointfree.co/episodes/ep293-cross-platform-navigation) +> +> We will introduce navigation APIs to our Wasm application, starting simply with an alert before ramping things up with a `dialog` tag that can be fully configurable from a value type that represents its state and actions. diff --git a/README.md b/README.md index cc106430..5d781572 100644 --- a/README.md +++ b/README.md @@ -294,3 +294,4 @@ This repository is the home of code written on episodes of [Point-Free](https:// 1. [Cross-Platform Swift: View Paradigms](0290-cross-platform-pt1) 1. [Cross-Platform Swift: WebAssembly](0291-cross-platform-pt2) 1. [Cross-Platform Swift: Networking](0292-cross-platform-pt3) +1. [Cross-Platform Swift: Navigation](0293-cross-platform-pt4)