diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..cbdd8551 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:native.hotwired.dev)", + "Bash(swift build:*)", + "Bash(xcodebuild:*)", + "WebSearch" + ] + } +} diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index d4a347ad..1d91d037 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -19,6 +19,17 @@ 844BA7B92B9A488A008F48DB /* path-configuration.json in Resources */ = {isa = PBXBuildFile; fileRef = 844BA7AE2B9A488A008F48DB /* path-configuration.json */; }; 848E12212BD813CA009BB0A4 /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 848E12202BD813CA009BB0A4 /* HotwireNative */; }; 84A617FF2D5AA88B00694AEE /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A617FE2D5AA88B00694AEE /* Tabs.swift */; }; + 84SWUI0012D5BB00100000001 /* SwiftUIDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000005 /* SwiftUIDemoApp.swift */; }; + 84SWUI0012D5BB00100000002 /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000006 /* Tabs.swift */; }; + 84SWUI0012D5BB00100000003 /* AppNavigatorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000007 /* AppNavigatorDelegate.swift */; }; + 84SWUI0012D5BB00100000004 /* path-configuration.json in Resources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000008 /* path-configuration.json */; }; + 84SWUI0012D5BB0010000000A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 844BA7932B9A4844008F48DB /* Assets.xcassets */; }; + 84SWUI0012D5BB0010000000B /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 84SWUI0012D5BB0010000000C /* HotwireNative */; }; + 84SWUI0012D5BB0010000000D /* Demo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB0010000000E /* Demo.swift */; }; + 84SWUI0012D5BB0010000000F /* NumbersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000011 /* NumbersView.swift */; }; + 84SWUI0012D5BB00100000012 /* FormComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000013 /* FormComponent.swift */; }; + 84SWUI0012D5BB00100000014 /* MenuComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000015 /* MenuComponent.swift */; }; + 84SWUI0012D5BB00100000016 /* OverflowMenuComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000017 /* OverflowMenuComponent.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -36,6 +47,16 @@ 844BA7AE2B9A488A008F48DB /* path-configuration.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "path-configuration.json"; sourceTree = ""; }; 8458F3EF2BD8139400867B66 /* hotwire-native-ios */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "hotwire-native-ios"; path = ..; sourceTree = ""; }; 84A617FE2D5AA88B00694AEE /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000005 /* SwiftUIDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIDemoApp.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000006 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000007 /* AppNavigatorDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigatorDelegate.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000008 /* path-configuration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "path-configuration.json"; sourceTree = ""; }; + 84SWUI0012D5BB00100000009 /* SwiftUIDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUIDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 84SWUI0012D5BB0010000000E /* Demo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Demo.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000011 /* NumbersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumbersView.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000013 /* FormComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormComponent.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000015 /* MenuComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuComponent.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000017 /* OverflowMenuComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverflowMenuComponent.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -47,6 +68,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 84SWUI0012D5BB00100000020 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 84SWUI0012D5BB0010000000B /* HotwireNative in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -54,6 +83,7 @@ isa = PBXGroup; children = ( 844BA7892B9A4843008F48DB /* Demo */, + 84SWUI0012D5BB00100000010 /* SwiftUIDemo */, 844BA7882B9A4843008F48DB /* Products */, 8458F3EC2BD8135600867B66 /* Frameworks */, ); @@ -63,6 +93,7 @@ isa = PBXGroup; children = ( 844BA7872B9A4843008F48DB /* Demo.app */, + 84SWUI0012D5BB00100000009 /* SwiftUIDemo.app */, ); name = Products; sourceTree = ""; @@ -102,6 +133,30 @@ name = Frameworks; sourceTree = ""; }; + 84SWUI0012D5BB00100000010 /* SwiftUIDemo */ = { + isa = PBXGroup; + children = ( + 84SWUI0012D5BB00100000018 /* Bridge */, + 84SWUI0012D5BB00100000005 /* SwiftUIDemoApp.swift */, + 84SWUI0012D5BB0010000000E /* Demo.swift */, + 84SWUI0012D5BB00100000006 /* Tabs.swift */, + 84SWUI0012D5BB00100000007 /* AppNavigatorDelegate.swift */, + 84SWUI0012D5BB00100000011 /* NumbersView.swift */, + 84SWUI0012D5BB00100000008 /* path-configuration.json */, + ); + path = SwiftUIDemo; + sourceTree = ""; + }; + 84SWUI0012D5BB00100000018 /* Bridge */ = { + isa = PBXGroup; + children = ( + 84SWUI0012D5BB00100000013 /* FormComponent.swift */, + 84SWUI0012D5BB00100000015 /* MenuComponent.swift */, + 84SWUI0012D5BB00100000017 /* OverflowMenuComponent.swift */, + ); + path = Bridge; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -125,6 +180,26 @@ productReference = 844BA7872B9A4843008F48DB /* Demo.app */; productType = "com.apple.product-type.application"; }; + 84SWUI0012D5BB00100000030 /* SwiftUIDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = 84SWUI0012D5BB00100000040 /* Build configuration list for PBXNativeTarget "SwiftUIDemo" */; + buildPhases = ( + 84SWUI0012D5BB00100000021 /* Sources */, + 84SWUI0012D5BB00100000020 /* Frameworks */, + 84SWUI0012D5BB00100000022 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SwiftUIDemo; + packageProductDependencies = ( + 84SWUI0012D5BB0010000000C /* HotwireNative */, + ); + productName = SwiftUIDemo; + productReference = 84SWUI0012D5BB00100000009 /* SwiftUIDemo.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -138,6 +213,9 @@ 844BA7862B9A4843008F48DB = { CreatedOnToolsVersion = 15.3; }; + 84SWUI0012D5BB00100000030 = { + CreatedOnToolsVersion = 15.3; + }; }; }; buildConfigurationList = 844BA7822B9A4843008F48DB /* Build configuration list for PBXProject "Demo" */; @@ -156,6 +234,7 @@ projectRoot = ""; targets = ( 844BA7862B9A4843008F48DB /* Demo */, + 84SWUI0012D5BB00100000030 /* SwiftUIDemo */, ); }; /* End PBXProject section */ @@ -171,6 +250,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 84SWUI0012D5BB00100000022 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 84SWUI0012D5BB0010000000A /* Assets.xcassets in Resources */, + 84SWUI0012D5BB00100000004 /* path-configuration.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -189,6 +277,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 84SWUI0012D5BB00100000021 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 84SWUI0012D5BB00100000001 /* SwiftUIDemoApp.swift in Sources */, + 84SWUI0012D5BB0010000000D /* Demo.swift in Sources */, + 84SWUI0012D5BB00100000002 /* Tabs.swift in Sources */, + 84SWUI0012D5BB00100000003 /* AppNavigatorDelegate.swift in Sources */, + 84SWUI0012D5BB0010000000F /* NumbersView.swift in Sources */, + 84SWUI0012D5BB00100000012 /* FormComponent.swift in Sources */, + 84SWUI0012D5BB00100000014 /* MenuComponent.swift in Sources */, + 84SWUI0012D5BB00100000016 /* OverflowMenuComponent.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ @@ -376,6 +479,60 @@ }; name = Release; }; + 84SWUI0012D5BB00100000050 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + 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"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.SwiftUIDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 84SWUI0012D5BB00100000051 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + 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"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.SwiftUIDemo; + 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 */ @@ -397,6 +554,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 84SWUI0012D5BB00100000040 /* Build configuration list for PBXNativeTarget "SwiftUIDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 84SWUI0012D5BB00100000050 /* Debug */, + 84SWUI0012D5BB00100000051 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ @@ -404,6 +570,10 @@ isa = XCSwiftPackageProductDependency; productName = HotwireNative; }; + 84SWUI0012D5BB0010000000C /* HotwireNative */ = { + isa = XCSwiftPackageProductDependency; + productName = HotwireNative; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 844BA77F2B9A4843008F48DB /* Project object */; diff --git a/Demo/SwiftUIDemo/AppNavigatorDelegate.swift b/Demo/SwiftUIDemo/AppNavigatorDelegate.swift new file mode 100644 index 00000000..7112dffb --- /dev/null +++ b/Demo/SwiftUIDemo/AppNavigatorDelegate.swift @@ -0,0 +1,50 @@ +import HotwireNative +import SwiftUI + +final class AppNavigatorDelegate: NavigatorDelegate, HotwireTabBarControllerDelegate { + weak var tabBarController: HotwireTabBarController? + + func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { + switch proposal.viewController { + case NumbersView.pathConfigurationIdentifier: + return .accept(NumbersView(url: proposal.url, navigator: navigator)) + + default: + return .accept + } + } + + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { + if let turboError = error as? TurboError, case let .http(statusCode) = turboError, statusCode == 401 { + promptForAuthentication() + } else if let errorPresenter = visitable as? ErrorPresenter { + errorPresenter.presentError(error, retryHandler: retryHandler) + } else { + showErrorAlert(error: error) + } + } + + // MARK: - Private + + private func promptForAuthentication() { + guard let tabBarController else { return } + + // Clean up empty screen from 401 response. + tabBarController.activeNavigator.pop(animated: false) + + let authURL = Demo.current.appendingPathComponent("/session/new") + tabBarController.activeNavigator.route(authURL) + } + + private func showErrorAlert(error: Error) { + guard let tabBarController else { return } + + let alert = UIAlertController( + title: "Visit failed!", + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + tabBarController.activeNavigator.rootViewController.present(alert, animated: true) + } +} diff --git a/Demo/SwiftUIDemo/Bridge/FormComponent.swift b/Demo/SwiftUIDemo/Bridge/FormComponent.swift new file mode 100644 index 00000000..5a800f48 --- /dev/null +++ b/Demo/SwiftUIDemo/Bridge/FormComponent.swift @@ -0,0 +1,74 @@ +import Foundation +import HotwireNative +import UIKit + +/// Bridge component to display a submit button in the native toolbar, +/// which will submit the form on the page when tapped. +final class FormComponent: BridgeComponent { + override class var name: String { "form" } + + override func onReceive(message: Message) { + guard let event = Event(rawValue: message.event) else { + return + } + + switch event { + case .connect: + handleConnectEvent(message: message) + case .submitEnabled: + handleSubmitEnabled() + case .submitDisabled: + handleSubmitDisabled() + } + } + + // MARK: Private + + private weak var submitBarButtonItem: UIBarButtonItem? + private var viewController: UIViewController? { + delegate?.destination as? UIViewController + } + + private func handleConnectEvent(message: Message) { + guard let data: MessageData = message.data() else { return } + configureBarButton(with: data.submitTitle) + } + + private func handleSubmitEnabled() { + submitBarButtonItem?.isEnabled = true + } + + private func handleSubmitDisabled() { + submitBarButtonItem?.isEnabled = false + } + + private func configureBarButton(with title: String) { + guard let viewController else { return } + + let action = UIAction { [unowned self] _ in + reply(to: Event.connect.rawValue) + } + + let item = UIBarButtonItem(title: title, primaryAction: action) + viewController.navigationItem.rightBarButtonItem = item + submitBarButtonItem = item + } +} + +// MARK: Events + +private extension FormComponent { + enum Event: String { + case connect + case submitEnabled + case submitDisabled + } +} + +// MARK: Message data + +private extension FormComponent { + struct MessageData: Decodable { + let submitTitle: String + } +} diff --git a/Demo/SwiftUIDemo/Bridge/MenuComponent.swift b/Demo/SwiftUIDemo/Bridge/MenuComponent.swift new file mode 100644 index 00000000..e7a1e1c2 --- /dev/null +++ b/Demo/SwiftUIDemo/Bridge/MenuComponent.swift @@ -0,0 +1,95 @@ +import Foundation +import HotwireNative +import UIKit + +/// Bridge component to display a native bottom sheet menu, +/// which will send the selected index of the tapped menu item back to the web. +final class MenuComponent: BridgeComponent { + override class var name: String { "menu" } + + override func onReceive(message: Message) { + guard let event = Event(rawValue: message.event) else { + return + } + + switch event { + case .display: + handleDisplayEvent(message: message) + } + } + + // MARK: Private + + private var viewController: UIViewController? { + delegate?.destination as? UIViewController + } + + private func handleDisplayEvent(message: Message) { + guard let data: MessageData = message.data() else { return } + showAlertSheet(with: data.title, items: data.items) + } + + private func showAlertSheet(with title: String, items: [Item]) { + let alertController = UIAlertController( + title: title, + message: nil, + preferredStyle: .actionSheet + ) + + for item in items { + let action = UIAlertAction(title: item.title, style: .default) { [unowned self] _ in + onItemSelected(item: item) + } + alertController.addAction(action) + } + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) + alertController.addAction(cancelAction) + + // Set popoverController for iPads + if let popoverController = alertController.popoverPresentationController { + if let barButtonItem = viewController?.navigationItem.rightBarButtonItem { + popoverController.barButtonItem = barButtonItem + } else { + popoverController.sourceView = viewController?.view + popoverController.sourceRect = viewController?.view.bounds ?? .zero + popoverController.permittedArrowDirections = [] + } + } + + viewController?.present(alertController, animated: true) + } + + private func onItemSelected(item: Item) { + reply( + to: Event.display.rawValue, + with: SelectionMessageData(selectedIndex: item.index) + ) + } +} + +// MARK: Events + +private extension MenuComponent { + enum Event: String { + case display + } +} + +// MARK: Message data + +private extension MenuComponent { + struct MessageData: Decodable { + let title: String + let items: [Item] + } + + struct Item: Decodable { + let title: String + let index: Int + } + + struct SelectionMessageData: Encodable { + let selectedIndex: Int + } +} diff --git a/Demo/SwiftUIDemo/Bridge/OverflowMenuComponent.swift b/Demo/SwiftUIDemo/Bridge/OverflowMenuComponent.swift new file mode 100644 index 00000000..f82dfda2 --- /dev/null +++ b/Demo/SwiftUIDemo/Bridge/OverflowMenuComponent.swift @@ -0,0 +1,65 @@ +import Foundation +import HotwireNative +import UIKit + +/// Bridge component to display a native 3-dot menu in the toolbar, +/// which will notify the web when it has been tapped. +final class OverflowMenuComponent: BridgeComponent { + override class var name: String { "overflow-menu" } + + override func onReceive(message: Message) { + guard let event = Event(rawValue: message.event) else { + return + } + + switch event { + case .connect: + handleConnectEvent(message: message) + } + } + + // MARK: Private + + private var viewController: UIViewController? { + delegate?.destination as? UIViewController + } + + private func handleConnectEvent(message: Message) { + guard let data: MessageData = message.data() else { return } + showOverflowMenuItem(data) + } + + private func showOverflowMenuItem(_ data: MessageData) { + guard let viewController else { return } + + let action = UIAction { [unowned self] _ in + overflowAction() + } + + viewController.navigationItem.rightBarButtonItem = UIBarButtonItem( + title: data.label, + image: .init(systemName: "ellipsis.circle"), + primaryAction: action + ) + } + + private func overflowAction() { + reply(to: Event.connect.rawValue) + } +} + +// MARK: Events + +private extension OverflowMenuComponent { + enum Event: String { + case connect + } +} + +// MARK: Message data + +private extension OverflowMenuComponent { + struct MessageData: Decodable { + let label: String + } +} diff --git a/Demo/SwiftUIDemo/Demo.swift b/Demo/SwiftUIDemo/Demo.swift new file mode 100644 index 00000000..1a1f8358 --- /dev/null +++ b/Demo/SwiftUIDemo/Demo.swift @@ -0,0 +1,11 @@ +import Foundation + +struct Demo { + static let remote = URL(string: "https://hotwire-native-demo.dev")! + static let local = URL(string: "http://localhost:3000")! + + /// Update this to choose which demo is run + static var current: URL { + remote + } +} diff --git a/Demo/SwiftUIDemo/NumbersView.swift b/Demo/SwiftUIDemo/NumbersView.swift new file mode 100644 index 00000000..cbff944d --- /dev/null +++ b/Demo/SwiftUIDemo/NumbersView.swift @@ -0,0 +1,35 @@ +import HotwireNative +import SwiftUI + +/// A simple native view to demonstrate loading non-Turbo screens +/// for a visit proposal. +struct NumbersView: View, PathConfigurationIdentifiable { + static var pathConfigurationIdentifier: String { "numbers" } + + let url: URL + var navigator: NavigationHandler? + + var body: some View { + List(1...100, id: \.self) { number in + Button { + let detailURL = url.appendingPathComponent("\(number)") + navigator?.route(detailURL) + } label: { + HStack { + Text("Row \(number)") + .foregroundStyle(.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + } + .listStyle(.plain) + .navigationTitle("Numbers") + } +} + +#Preview { + NumbersView(url: URL(string: "https://example.com")!) +} diff --git a/Demo/SwiftUIDemo/SwiftUIDemoApp.swift b/Demo/SwiftUIDemo/SwiftUIDemoApp.swift new file mode 100644 index 00000000..ff477061 --- /dev/null +++ b/Demo/SwiftUIDemo/SwiftUIDemoApp.swift @@ -0,0 +1,45 @@ +import HotwireNative +import SwiftUI + +@main +struct SwiftUIDemoApp: App { + private let navigatorDelegate = AppNavigatorDelegate() + + init() { + configureHotwire() + } + + var body: some Scene { + WindowGroup { + HotwireRootView(tabs: HotwireTab.all) + .navigatorDelegate(navigatorDelegate) + .ignoresSafeArea() + } + } + + private func configureHotwire() { + // Load path configuration from local file and remote server. + Hotwire.loadPathConfiguration(from: [ + .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!), + .server(Demo.current.appendingPathComponent("configurations/ios_v1.json")) + ]) + + // Set an optional custom user agent prefix. + Hotwire.config.applicationUserAgentPrefix = "Hotwire SwiftUI Demo;" + + // Register bridge components. + Hotwire.registerBridgeComponents([ + FormComponent.self, + MenuComponent.self, + OverflowMenuComponent.self, + ]) + + // Configure UI options. + Hotwire.config.backButtonDisplayMode = .minimal + Hotwire.config.showDoneButtonOnModals = true + Hotwire.config.animateReplaceActions = true + #if DEBUG + Hotwire.config.debugLoggingEnabled = true + #endif + } +} diff --git a/Demo/SwiftUIDemo/Tabs.swift b/Demo/SwiftUIDemo/Tabs.swift new file mode 100644 index 00000000..03bb3fb9 --- /dev/null +++ b/Demo/SwiftUIDemo/Tabs.swift @@ -0,0 +1,54 @@ +import HotwireNative +import UIKit + +extension HotwireTab { + static let all: [HotwireTab] = { + var tabs: [HotwireTab] = [ + .navigation, + .bridgeComponents, + .resources + ] + + if Demo.current == Demo.local { + tabs.append(.bugsAndFixes) + } + + return tabs + }() + + static let navigation = HotwireTab( + title: "Navigation", + image: UIImage(systemName: "arrow.left.arrow.right")!, + url: Demo.current + ) + + static let bridgeComponents = HotwireTab( + title: "Bridge Components", + image: { + if #available(iOS 17.4, *) { + return UIImage(systemName: "widget.small")! + } else { + return UIImage(systemName: "square.grid.2x2")! + } + }(), + url: Demo.current.appendingPathComponent("components") + ) + + static let resources = HotwireTab( + title: "Resources", + image: { + if #available(iOS 17.4, *) { + return UIImage(systemName: "questionmark.text.page")! + } else { + return UIImage(systemName: "book.closed")! + } + }(), + url: Demo.current.appendingPathComponent("resources") + ) + + static let bugsAndFixes = HotwireTab( + title: "Bugs & Fixes", + image: UIImage(systemName: "ladybug")!, + url: Demo.current.appendingPathComponent("bugs") + ) +} diff --git a/Demo/SwiftUIDemo/path-configuration.json b/Demo/SwiftUIDemo/path-configuration.json new file mode 100644 index 00000000..8be5fefb --- /dev/null +++ b/Demo/SwiftUIDemo/path-configuration.json @@ -0,0 +1,37 @@ +{ + "settings": { + "enable_feature_x": true + }, + "rules": [ + { + "patterns": [ + "/new$", + "/edit$", + "/modal" + ], + "properties": { + "context": "modal", + "pull_to_refresh_enabled": false + }, + "comment": "Present forms and custom modal path as modals." + }, + { + "patterns": [ + "/numbers$" + ], + "properties": { + "view_controller": "numbers" + }, + "comment": "Intercept with a native view." + }, + { + "patterns": [ + "^/$" + ], + "properties": { + "presentation": "clear_all" + }, + "comment": "Reset navigation stacks when visiting root page." + } + ] +} diff --git a/Source/SwiftUI/HotwireNavigationView.swift b/Source/SwiftUI/HotwireNavigationView.swift new file mode 100644 index 00000000..92307a7f --- /dev/null +++ b/Source/SwiftUI/HotwireNavigationView.swift @@ -0,0 +1,124 @@ +import SwiftUI +import UIKit + +/// A SwiftUI view that wraps a single `Navigator` for stack-based navigation. +/// +/// Use this as your root view in a SwiftUI app when you don't need tabs. +/// +/// ```swift +/// @main +/// struct MyApp: App { +/// init() { +/// Hotwire.loadPathConfiguration(from: ...) +/// } +/// +/// var body: some Scene { +/// WindowGroup { +/// HotwireNavigationView( +/// name: "main", +/// startURL: URL(string: "https://example.com")! +/// ) +/// } +/// } +/// } +/// ``` +public struct HotwireNavigationView: UIViewControllerRepresentable { + private let name: String + private let startURL: URL + private var navigatorDelegate: NavigatorDelegate? + private var onNavigatorReady: ((Navigator) -> Void)? + + /// Creates a new Hotwire navigation view with a single navigator. + /// - Parameters: + /// - name: A name for the navigator configuration (used internally). + /// - startURL: The initial URL to load. + public init(name: String = "main", startURL: URL) { + self.name = name + self.startURL = startURL + } + + public func makeUIViewController(context: Context) -> UINavigationController { + let configuration = Navigator.Configuration(name: name, startLocation: startURL) + let navigator = Navigator(configuration: configuration, delegate: context.coordinator) + context.coordinator.navigator = navigator + navigator.start() + + // Notify that navigator is ready + onNavigatorReady?(navigator) + + return navigator.rootViewController + } + + public func updateUIViewController(_ controller: UINavigationController, context: Context) { + // Update delegate reference if it changed + context.coordinator.customDelegate = navigatorDelegate + context.coordinator.onNavigatorReady = onNavigatorReady + } + + public func makeCoordinator() -> Coordinator { + Coordinator(customDelegate: navigatorDelegate, onNavigatorReady: onNavigatorReady) + } + + // MARK: - Modifiers + + /// Sets a custom navigator delegate for handling visit proposals and errors. + /// - Parameter delegate: The delegate to handle navigation events. + /// - Returns: A modified view with the delegate set. + public func navigatorDelegate(_ delegate: NavigatorDelegate) -> HotwireNavigationView { + var copy = self + copy.navigatorDelegate = delegate + return copy + } + + /// Provides access to the navigator when it becomes ready. + /// - Parameter action: A closure called with the navigator. + /// - Returns: A modified view that reports when the navigator is ready. + public func onNavigatorReady(_ action: @escaping (Navigator) -> Void) -> HotwireNavigationView { + var copy = self + copy.onNavigatorReady = action + return copy + } + + // MARK: - Coordinator + + public class Coordinator: NSObject, NavigatorDelegate { + var customDelegate: NavigatorDelegate? + var onNavigatorReady: ((Navigator) -> Void)? + var navigator: Navigator? + + init(customDelegate: NavigatorDelegate?, onNavigatorReady: ((Navigator) -> Void)?) { + self.customDelegate = customDelegate + self.onNavigatorReady = onNavigatorReady + } + + // MARK: - NavigatorDelegate + + public func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { + customDelegate?.handle(proposal: proposal, from: navigator) ?? .accept + } + + public func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { + customDelegate?.visitableDidFailRequest(visitable, error: error, retryHandler: retryHandler) + } + + public func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + if let customDelegate { + customDelegate.didReceiveAuthenticationChallenge(challenge, completionHandler: completionHandler) + } else { + completionHandler(.performDefaultHandling, nil) + } + } + + public func formSubmissionDidStart(to url: URL) { + customDelegate?.formSubmissionDidStart(to: url) + } + + public func formSubmissionDidFinish(at url: URL) { + customDelegate?.formSubmissionDidFinish(at: url) + } + + public func requestDidFinish(at url: URL) { + customDelegate?.requestDidFinish(at: url) + } + } +} diff --git a/Source/SwiftUI/HotwireRootView.swift b/Source/SwiftUI/HotwireRootView.swift new file mode 100644 index 00000000..75cda2b0 --- /dev/null +++ b/Source/SwiftUI/HotwireRootView.swift @@ -0,0 +1,134 @@ +import SwiftUI +import UIKit + +/// A protocol for navigator delegates that need access to the tab bar controller. +/// +/// Implement this protocol in your `NavigatorDelegate` if you need to access +/// the `HotwireTabBarController` for operations like authentication redirects. +public protocol HotwireTabBarControllerDelegate: AnyObject { + var tabBarController: HotwireTabBarController? { get set } +} + +/// A SwiftUI view that wraps `HotwireTabBarController` for tab-based navigation. +/// +/// Use this as your root view in a SwiftUI app to get full Hotwire Native functionality +/// with multiple tabs. +/// +/// ```swift +/// @main +/// struct MyApp: App { +/// init() { +/// Hotwire.loadPathConfiguration(from: ...) +/// } +/// +/// var body: some Scene { +/// WindowGroup { +/// HotwireRootView(tabs: [ +/// HotwireTab(title: "Home", url: URL(string: "https://example.com")!) +/// ]) +/// } +/// } +/// } +/// ``` +public struct HotwireRootView: UIViewControllerRepresentable { + private let tabs: [HotwireTab] + private var navigatorDelegate: NavigatorDelegate? + private var onActiveNavigatorChange: ((Navigator) -> Void)? + + /// Creates a new Hotwire root view with the specified tabs. + /// - Parameter tabs: The tabs to display in the tab bar. + public init(tabs: [HotwireTab]) { + self.tabs = tabs + } + + public func makeUIViewController(context: Context) -> HotwireTabBarController { + let controller = HotwireTabBarController(navigatorDelegate: context.coordinator) + context.coordinator.tabBarController = controller + + // Provide tabBarController to delegates that need it + if let tabBarDelegate = navigatorDelegate as? HotwireTabBarControllerDelegate { + tabBarDelegate.tabBarController = controller + } + + controller.load(tabs) + return controller + } + + public func updateUIViewController(_ controller: HotwireTabBarController, context: Context) { + // Update delegate reference if it changed + context.coordinator.customDelegate = navigatorDelegate + context.coordinator.onActiveNavigatorChange = onActiveNavigatorChange + + // Ensure tabBarController reference is up to date + if let tabBarDelegate = navigatorDelegate as? HotwireTabBarControllerDelegate { + tabBarDelegate.tabBarController = controller + } + } + + public func makeCoordinator() -> Coordinator { + Coordinator(customDelegate: navigatorDelegate, onActiveNavigatorChange: onActiveNavigatorChange) + } + + // MARK: - Modifiers + + /// Sets a custom navigator delegate for handling visit proposals and errors. + /// - Parameter delegate: The delegate to handle navigation events. + /// - Returns: A modified view with the delegate set. + public func navigatorDelegate(_ delegate: NavigatorDelegate) -> HotwireRootView { + var copy = self + copy.navigatorDelegate = delegate + return copy + } + + /// Provides access to the active navigator when it changes. + /// - Parameter action: A closure called with the active navigator. + /// - Returns: A modified view that reports navigator changes. + public func onActiveNavigatorChange(_ action: @escaping (Navigator) -> Void) -> HotwireRootView { + var copy = self + copy.onActiveNavigatorChange = action + return copy + } + + // MARK: - Coordinator + + public class Coordinator: NSObject, NavigatorDelegate { + var customDelegate: NavigatorDelegate? + var onActiveNavigatorChange: ((Navigator) -> Void)? + weak var tabBarController: HotwireTabBarController? + + init(customDelegate: NavigatorDelegate?, onActiveNavigatorChange: ((Navigator) -> Void)?) { + self.customDelegate = customDelegate + self.onActiveNavigatorChange = onActiveNavigatorChange + } + + // MARK: - NavigatorDelegate + + public func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { + customDelegate?.handle(proposal: proposal, from: navigator) ?? .accept + } + + public func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { + customDelegate?.visitableDidFailRequest(visitable, error: error, retryHandler: retryHandler) + } + + public func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + if let customDelegate { + customDelegate.didReceiveAuthenticationChallenge(challenge, completionHandler: completionHandler) + } else { + completionHandler(.performDefaultHandling, nil) + } + } + + public func formSubmissionDidStart(to url: URL) { + customDelegate?.formSubmissionDidStart(to: url) + } + + public func formSubmissionDidFinish(at url: URL) { + customDelegate?.formSubmissionDidFinish(at: url) + } + + public func requestDidFinish(at url: URL) { + customDelegate?.requestDidFinish(at: url) + } + } +} diff --git a/Source/Turbo/Navigator/Helpers/PathConfigurationIdentifiable.swift b/Source/Turbo/Navigator/Helpers/PathConfigurationIdentifiable.swift index fc1208c7..3daf58fa 100644 --- a/Source/Turbo/Navigator/Helpers/PathConfigurationIdentifiable.swift +++ b/Source/Turbo/Navigator/Helpers/PathConfigurationIdentifiable.swift @@ -1,20 +1,22 @@ -import UIKit +import Foundation -/// As a convenience, a view controller may conform to `PathConfigurationIdentifiable`. +/// As a convenience, a view controller or SwiftUI view may conform to `PathConfigurationIdentifiable`. /// -/// Use a view controller's `pathConfigurationIdentifier` property instead of `proposal.url` when deciding how to handle a proposal. +/// Use a type's `pathConfigurationIdentifier` property instead of `proposal.url` when deciding how to handle a proposal. /// /// ```swift /// func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { /// switch proposal.viewController { /// case RecipeViewController.pathConfigurationIdentifier: /// return .acceptCustom(RecipeViewController()) +/// case NumbersView.pathConfigurationIdentifier: +/// return .accept(NumbersView()) /// default: /// return .accept /// } /// } /// ``` /// - Note: See `VisitProposal.viewController` on how to use this in your configuration file. -public protocol PathConfigurationIdentifiable: UIViewController { +public protocol PathConfigurationIdentifiable { static var pathConfigurationIdentifier: String { get } } diff --git a/Source/Turbo/Navigator/Helpers/ProposalResult.swift b/Source/Turbo/Navigator/Helpers/ProposalResult.swift index 2c52c7ed..9b2a650b 100644 --- a/Source/Turbo/Navigator/Helpers/ProposalResult.swift +++ b/Source/Turbo/Navigator/Helpers/ProposalResult.swift @@ -1,13 +1,18 @@ -import UIKit +import SwiftUI /// Return from `NavigatorDelegate.handle(proposal:from:)` to route a custom controller. public enum ProposalResult: Equatable { /// Route a `VisitableViewController`. case accept - /// Route a custom `UIViewController` or subclass + /// Route a custom `UIViewController` or subclass. case acceptCustom(UIViewController) /// Do not route. Navigation is not modified. case reject + + /// Route a SwiftUI view wrapped in a `UIHostingController`. + public static func accept(_ view: V) -> ProposalResult { + .acceptCustom(UIHostingController(rootView: view)) + } }