diff --git a/MyLibrary/Package.swift b/MyLibrary/Package.swift index a97a05e..0d1b107 100644 --- a/MyLibrary/Package.swift +++ b/MyLibrary/Package.swift @@ -17,6 +17,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "1.9.1"), + .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.2.0"), .package(url: "https://github.com/zunda-pixel/LicenseProvider", from: "1.1.1"), ], targets: [ @@ -39,11 +40,17 @@ let package = Package( .process("Resources") ] ), + .target( + name: "DependencyExtra", + dependencies: [ + .product(name: "Dependencies", package: "swift-dependencies"), + ] + ), .target( name: "GuidanceFeature", dependencies: [ + "DependencyExtra", "MapKitClient", - "Safari", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), @@ -54,17 +61,11 @@ let package = Package( .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), - .target( - name: "Safari", - dependencies: [ - .product(name: "ComposableArchitecture", package: "swift-composable-architecture") - ] - ), .target( name: "ScheduleFeature", dependencies: [ "DataClient", - "Safari", + "DependencyExtra", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), @@ -73,7 +74,7 @@ let package = Package( name: "SponsorFeature", dependencies: [ "DataClient", - "Safari", + "DependencyExtra", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ] ), @@ -81,7 +82,7 @@ let package = Package( name: "trySwiftFeature", dependencies: [ "DataClient", - "Safari", + "DependencyExtra", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ], plugins: [ diff --git a/MyLibrary/Sources/DependencyExtra/Safari.swift b/MyLibrary/Sources/DependencyExtra/Safari.swift new file mode 100644 index 0000000..e916a8e --- /dev/null +++ b/MyLibrary/Sources/DependencyExtra/Safari.swift @@ -0,0 +1,82 @@ +import Dependencies + +#if canImport(SafariServices) && canImport(SwiftUI) +import SafariServices +import SwiftUI + +extension DependencyValues { + /// A dependency that opens a URL in SFSafariViewController. + /// + /// In iOS, use SFSafariViewController in UIKit context. Otherwise use openURL in environment values + /// + /// - SeeAlso: https://sarunw.com/posts/sfsafariviewcontroller-in-swiftui/ + @available(iOS 15, macOS 11, tvOS 14, watchOS 7, *) + public var safari: SafariEffect { + get { self[SafariKey.self] } + set { self[SafariKey.self] = newValue } + } +} + +@available(iOS 15, macOS 11, tvOS 14, watchOS 7, *) +private enum SafariKey: DependencyKey { + static let liveValue = SafariEffect { url in + let stream = AsyncStream { continuation in + let task = Task { @MainActor in +#if os(iOS) + let vc = SFSafariViewController(url: url) + UIApplication.shared.firstKeyWindow?.rootViewController?.present(vc, animated: true) + continuation.yield(true) + continuation.finish() +#else + EnvironmentValues().openURL(url) + continuation.yield(true) + continuation.finish() +#endif + } + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + return await stream.first(where: { _ in true }) ?? false + } + static let testValue = SafariEffect { _ in + XCTFail(#"Unimplemented: @Dependency(\.safari)"#) + return false + } +} + +public struct SafariEffect: Sendable { + private let handler: @Sendable (URL) async -> Bool + + public init(handler: @escaping @Sendable (URL) async -> Bool) { + self.handler = handler + } + + @available(watchOS, unavailable) + @discardableResult + public func callAsFunction(_ url: URL) async -> Bool { + await self.handler(url) + } + + @_disfavoredOverload + public func callAsFunction(_ url: URL) async { + _ = await self.handler(url) + } +} + +#endif + +#if canImport(UIKit) +import UIKit + +extension UIApplication { + @available(iOS 15.0, *) + var firstKeyWindow: UIWindow? { + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .first?.keyWindow + } +} + +#endif diff --git a/MyLibrary/Sources/GuidanceFeature/Guidance.swift b/MyLibrary/Sources/GuidanceFeature/Guidance.swift index 53ad13e..2ae9f79 100644 --- a/MyLibrary/Sources/GuidanceFeature/Guidance.swift +++ b/MyLibrary/Sources/GuidanceFeature/Guidance.swift @@ -3,7 +3,7 @@ import CoreLocation import Foundation import MapKit import MapKitClient -import Safari +import DependencyExtra import SwiftUI @Reducer @@ -49,10 +49,10 @@ public struct Guidance { @Reducer(state: .equatable) public enum Destination { - case safari(Safari) } @Dependency(MapKitClient.self) var mapKitClient + @Dependency(\.safari) var safari public init() {} diff --git a/MyLibrary/Sources/Safari/Safari.swift b/MyLibrary/Sources/Safari/Safari.swift deleted file mode 100644 index fdec1e2..0000000 --- a/MyLibrary/Sources/Safari/Safari.swift +++ /dev/null @@ -1,53 +0,0 @@ -import ComposableArchitecture -import SafariServices -import SwiftUI - -@Reducer -public struct Safari { - @ObservableState - public struct State: Equatable { - public var url: URL - - public init(url: URL) { - self.url = url - } - } - - public enum Action {} - - public init() {} -} - -public struct SafariView: View { - - public var store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - SafariViewRepresentation(url: store.url) - } -} - -public struct SafariViewRepresentation: UIViewControllerRepresentable { - - public var url: URL - - public init(url: URL) { - self.url = url - } - - public func makeUIViewController(context: Context) -> SFSafariViewController { - let viewController = SFSafariViewController(url: url) - return viewController - } - - public func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) { - - } - - public func makeCoordinator() { - } -} diff --git a/MyLibrary/Sources/ScheduleFeature/Detail.swift b/MyLibrary/Sources/ScheduleFeature/Detail.swift index 74af7aa..74270e2 100644 --- a/MyLibrary/Sources/ScheduleFeature/Detail.swift +++ b/MyLibrary/Sources/ScheduleFeature/Detail.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import Foundation -import Safari +import DependencyExtra import SharedModels import SwiftUI @@ -13,7 +13,6 @@ public struct ScheduleDetail { var description: String var requirements: String? var speakers: [Speaker] - @Presents var destination: Destination.State? public init( title: String, description: String, requirements: String? = nil, speakers: [Speaker] @@ -27,7 +26,6 @@ public struct ScheduleDetail { public enum Action: ViewAction, BindableAction { case binding(BindingAction) - case destination(PresentationAction) case view(View) public enum View { @@ -35,12 +33,7 @@ public struct ScheduleDetail { } } - @Reducer(state: .equatable) - public enum Destination { - case safari(Safari) - } - - @Dependency(\.openURL) var openURL + @Dependency(\.safari) var safari public init() {} @@ -49,19 +42,11 @@ public struct ScheduleDetail { Reduce { state, action in switch action { case let .view(.snsTapped(url)): - #if os(iOS) || os(macOS) - state.destination = .safari(.init(url: url)) - return .none - #elseif os(visionOS) - return .run { _ in await openURL(url) } - #endif - case .destination: - return .none + return .run { _ in await safari(url) } case .binding: return .none } } - .ifLet(\.$destination, action: \.destination) } } @@ -103,11 +88,6 @@ public struct ScheduleDetailView: View { speakers .frame(maxWidth: 700) // Readable content width for iPad } - .sheet(item: $store.scope(state: \.destination?.safari, action: \.destination.safari)) { - sheetStore in - SafariViewRepresentation(url: sheetStore.url) - .ignoresSafeArea() - } } @ViewBuilder diff --git a/MyLibrary/Sources/ScheduleFeature/Schedule.swift b/MyLibrary/Sources/ScheduleFeature/Schedule.swift index d110b80..5138b62 100644 --- a/MyLibrary/Sources/ScheduleFeature/Schedule.swift +++ b/MyLibrary/Sources/ScheduleFeature/Schedule.swift @@ -1,7 +1,6 @@ import ComposableArchitecture import DataClient import Foundation -import Safari import SharedModels import SwiftUI import TipKit @@ -59,7 +58,6 @@ public struct Schedule { public enum Destination {} @Dependency(DataClient.self) var dataClient - @Dependency(\.openURL) var openURL public init() {} diff --git a/MyLibrary/Sources/SponsorFeature/Sponsors.swift b/MyLibrary/Sources/SponsorFeature/Sponsors.swift index c26932f..8ce0b72 100644 --- a/MyLibrary/Sources/SponsorFeature/Sponsors.swift +++ b/MyLibrary/Sources/SponsorFeature/Sponsors.swift @@ -1,7 +1,7 @@ import ComposableArchitecture import DataClient import Foundation -import Safari +import DependencyExtra import SharedModels import SwiftUI @@ -32,7 +32,7 @@ public struct SponsorsList { public init() {} @Dependency(DataClient.self) var dataClient - @Dependency(\.openURL) var openURL + @Dependency(\.safari) var safari public var body: some ReducerOf { BindingReducer() @@ -44,12 +44,7 @@ public struct SponsorsList { case let .view(.sponsorTapped(sponsor)): guard let url = sponsor.link else { return .none } - #if os(iOS) || os(macOS) - state.destination = .safari(.init(url: url)) - return .none - #elseif os(visionOS) - return .run { _ in await openURL(url) } - #endif + return .run { _ in await safari(url) } case .binding: return .none case .destination: @@ -61,7 +56,6 @@ public struct SponsorsList { @Reducer(state: .equatable) public enum Destination { - case safari(Safari) } } @@ -76,12 +70,6 @@ public struct SponsorsListView: View { public var body: some View { NavigationView { root - .fullScreenCover( - item: $store.scope(state: \.destination?.safari, action: \.destination.safari) - ) { sheetStore in - SafariViewRepresentation(url: sheetStore.url) - .ignoresSafeArea() - } .onAppear { send(.onAppear) } diff --git a/MyLibrary/Sources/trySwiftFeature/Acknowledgements.swift b/MyLibrary/Sources/trySwiftFeature/Acknowledgements.swift index bf41a50..2b92ec1 100644 --- a/MyLibrary/Sources/trySwiftFeature/Acknowledgements.swift +++ b/MyLibrary/Sources/trySwiftFeature/Acknowledgements.swift @@ -1,5 +1,5 @@ import ComposableArchitecture -import Safari +import DependencyExtra import SwiftUI @Reducer @@ -7,35 +7,23 @@ public struct Acknowledgements { @ObservableState public struct State: Equatable { var packages = LicenseProvider.packages - @Presents var safari: Safari.State? public init() {} } public enum Action { case urlTapped(URL) - case safari(PresentationAction) } - @Dependency(\.openURL) var openURL + @Dependency(\.safari) var safari public var body: some ReducerOf { Reduce { state, action in switch action { case let .urlTapped(url): - #if os(iOS) || os(macOS) - state.safari = .init(url: url) - return .none - #elseif os(visionOS) - return .run { _ in await openURL(url) } - #endif - case .safari: - return .none + return .run { _ in await safari(url) } } } - .ifLet(\.$safari, action: \.safari) { - Safari() - } } } @@ -44,15 +32,6 @@ public struct AcknowledgementsView: View { @Bindable public var store: StoreOf public var body: some View { - list - .sheet(item: $store.scope(state: \.safari, action: \.safari)) { sheetStore in - SafariViewRepresentation(url: sheetStore.url) - .ignoresSafeArea() - } - } - - @ViewBuilder - var list: some View { List { ForEach(store.packages, id: \.self) { package in NavigationLink(package.name) { diff --git a/MyLibrary/Sources/trySwiftFeature/Profile.swift b/MyLibrary/Sources/trySwiftFeature/Profile.swift index 723a9c5..49fe8db 100644 --- a/MyLibrary/Sources/trySwiftFeature/Profile.swift +++ b/MyLibrary/Sources/trySwiftFeature/Profile.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import Foundation -import Safari +import DependencyExtra import SharedModels import SwiftUI @@ -9,7 +9,6 @@ public struct Profile { @ObservableState public struct State: Equatable { var organizer: Organizer - @Presents var destination: Destination.State? public init(organizer: Organizer) { self.organizer = organizer @@ -18,7 +17,6 @@ public struct Profile { public enum Action: ViewAction, BindableAction { case binding(BindingAction) - case destination(PresentationAction) case view(View) public enum View { @@ -26,12 +24,7 @@ public struct Profile { } } - @Reducer(state: .equatable) - public enum Destination { - case safari(Safari) - } - - @Dependency(\.openURL) var openURL + @Dependency(\.safari) var safari public init() {} @@ -40,19 +33,11 @@ public struct Profile { Reduce { state, action in switch action { case let .view(.snsTapped(url)): - #if os(iOS) || os(macOS) - state.destination = .safari(.init(url: url)) - return .none - #elseif os(visionOS) - return .run { _ in await openURL(url) } - #endif - case .destination: - return .none + return .run { _ in await safari(url) } case .binding: return .none } } - .ifLet(\.$destination, action: \.destination) } } @@ -94,10 +79,5 @@ public struct ProfileView: View { } .navigationTitle(Text(LocalizedStringKey(store.organizer.name), bundle: .module)) } - .sheet(item: $store.scope(state: \.destination?.safari, action: \.destination.safari)) { - sheetStore in - SafariViewRepresentation(url: sheetStore.url) - .ignoresSafeArea() - } } } diff --git a/MyLibrary/Sources/trySwiftFeature/trySwift.swift b/MyLibrary/Sources/trySwiftFeature/trySwift.swift index 19e7f40..71ddcea 100644 --- a/MyLibrary/Sources/trySwiftFeature/trySwift.swift +++ b/MyLibrary/Sources/trySwiftFeature/trySwift.swift @@ -1,6 +1,6 @@ import ComposableArchitecture import DataClient -import Safari +import DependencyExtra import SharedModels import SwiftUI @@ -9,13 +9,11 @@ public struct TrySwift { @ObservableState public struct State: Equatable { var path = StackState() - @Presents var destination: Destination.State? public init() {} } public enum Action: BindableAction, ViewAction { case path(StackAction) - case destination(PresentationAction) case binding(BindingAction) case view(View) @@ -36,12 +34,7 @@ public struct TrySwift { case acknowledgements(Acknowledgements) } - @Reducer(state: .equatable) - public enum Destination { - case safari(Safari) - } - - @Dependency(\.openURL) var openURL + @Dependency(\.safari) var safari public init() {} @@ -54,39 +47,19 @@ public struct TrySwift { return .none case .view(.codeOfConductTapped): let url = URL(string: String(localized: "Code of Conduct URL", bundle: .module))! - #if os(iOS) || os(macOS) - state.destination = .safari(.init(url: url)) - return .none - #elseif os(visionOS) - return .run { _ in await openURL(url) } - #endif + return .run { _ in await safari(url) } case .view(.privacyPolicyTapped): let url = URL(string: String(localized: "Privacy Policy URL", bundle: .module))! - #if os(iOS) || os(macOS) - state.destination = .safari(.init(url: url)) - return .none - #elseif os(visionOS) - return .run { _ in await openURL(url) } - #endif + return .run { _ in await safari(url) } case .view(.acknowledgementsTapped): state.path.append(.acknowledgements(.init())) return .none case .view(.eventbriteTapped): let url = URL(string: String(localized: "Eventbrite URL", bundle: .module))! - #if os(iOS) || os(macOS) - state.destination = .safari(.init(url: url)) - return .none - #elseif os(visionOS) - return .run { _ in await openURL(url) } - #endif + return .run { _ in await safari(url) } case .view(.websiteTapped): let url = URL(string: String(localized: "Website URL", bundle: .module))! - #if os(iOS) || os(macOS) - state.destination = .safari(.init(url: url)) - return .none - #elseif os(visionOS) - return .run { _ in await openURL(url) } - #endif + return .run { _ in await safari(url) } case let .path(.element(_, .organizers(.delegate(.organizerTapped(organizer))))): state.path.append(.profile(.init(organizer: organizer))) return .none @@ -94,12 +67,9 @@ public struct TrySwift { return .none case .path: return .none - case .destination: - return .none } } .forEach(\.path, action: \.path) - .ifLet(\.$destination, action: \.destination) } } @@ -185,11 +155,5 @@ public struct TrySwiftView: View { } } .navigationTitle(Text("try! Swift", bundle: .module)) - .sheet( - item: $store.scope(state: \.destination?.safari, action: \.destination.safari) - ) { sheetStore in - SafariViewRepresentation(url: sheetStore.url) - .ignoresSafeArea() - } } }