From 3e64926ef6c563bd60a1d4f2fedaa0adf4e9d5ef Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 12 Nov 2023 04:10:51 +0100 Subject: [PATCH 01/43] feat: NavigationStacks - Major API improvements - Replace `configureRoutes` with `navigationDestination` - Implement `navigationStack` - Get rid of `RouteConfiguration` API - Draft README.md update --- Package.swift | 103 +++--- README.md | 102 +++++- .../CombineNavigation/AssociatedRoutes.swift | 25 -- .../CocoaViewController+.swift | 320 +++++++++--------- .../Extensions/AnyCancellable+.swift | 16 + .../Extensions/NSObject+.swift | 5 + .../CombineNavigation/NavigationRoute.swift | 29 ++ .../RouteConfiguration.swift | 29 -- .../UINavigationController+.swift | 64 ++++ 9 files changed, 429 insertions(+), 264 deletions(-) delete mode 100644 Sources/CombineNavigation/AssociatedRoutes.swift create mode 100644 Sources/CombineNavigation/Extensions/AnyCancellable+.swift create mode 100644 Sources/CombineNavigation/Extensions/NSObject+.swift create mode 100644 Sources/CombineNavigation/NavigationRoute.swift delete mode 100644 Sources/CombineNavigation/RouteConfiguration.swift create mode 100644 Sources/CombineNavigation/UINavigationController+.swift diff --git a/Package.swift b/Package.swift index 192c316..84b17fa 100644 --- a/Package.swift +++ b/Package.swift @@ -1,52 +1,61 @@ -// swift-tools-version:5.8 +// swift-tools-version: 5.9 import PackageDescription let package = Package( - name: "combine-cocoa-navigation", - platforms: [ - .iOS(.v13), - .macOS(.v11), - .tvOS(.v13), - .watchOS(.v6), - ], - products: [ - .library( - name: "CombineNavigation", - targets: ["CombineNavigation"] - ), - ], - dependencies: [ - .package( - url: "https://github.com/capturecontext/cocoa-aliases.git", - .upToNextMajor(from: "2.0.5") - ), - .package( - url: "https://github.com/capturecontext/swift-foundation-extensions.git", - .upToNextMinor(from: "0.2.0") - ), - .package( - url: "https://github.com/capturecontext/combine-extensions.git", - .upToNextMinor(from: "0.1.0") - ), - ], - targets: [ - .target( - name: "CombineNavigation", - dependencies: [ - .product( - name: "CocoaAliases", - package: "cocoa-aliases" - ), - .product( - name: "FoundationExtensions", - package: "swift-foundation-extensions" - ), - .product( - name: "CombineExtensions", - package: "combine-extensions" - ), - ] - ) - ] + name: "combine-cocoa-navigation", + platforms: [ + .iOS(.v13), + .macOS(.v11), + .tvOS(.v13), + .watchOS(.v6), + .macCatalyst(.v13) + ], + products: [ + .library( + name: "CombineNavigation", + targets: ["CombineNavigation"] + ), + ], + dependencies: [ + .package( + url: "https://github.com/capturecontext/swift-capture.git", + .upToNextMajor(from: "3.0.1") + ), + .package( + url: "https://github.com/capturecontext/cocoa-aliases.git", + .upToNextMajor(from: "2.0.5") + ), + .package( + url: "https://github.com/capturecontext/combine-extensions.git", + .upToNextMinor(from: "0.1.0") + ), + .package( + url: "https://github.com/capturecontext/swift-foundation-extensions.git", + .upToNextMinor(from: "0.3.3") + ), + ], + targets: [ + .target( + name: "CombineNavigation", + dependencies: [ + .product( + name: "Capture", + package: "swift-capture" + ), + .product( + name: "CocoaAliases", + package: "cocoa-aliases" + ), + .product( + name: "CombineExtensions", + package: "combine-extensions" + ), + .product( + name: "FoundationExtensions", + package: "swift-foundation-extensions" + ), + ] + ) + ] ) diff --git a/README.md b/README.md index 1219767..8e4422c 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,34 @@ # combine-cocoa-navigation -[![SwiftPM 5.8](https://img.shields.io/badge/swiftpm-5.8-ED523F.svg?style=flat)](https://swift.org/download/) ! [![@maximkrouk](https://img.shields.io/badge/contact-@capturecontext-1DA1F2.svg?style=flat&logo=twitter)](https://twitter.com/capture_context) +[![SwiftPM 5.9](https://img.shields.io/badge/swiftpm-5.9-ED523F.svg?style=flat)](https://github.com/CaptureContext/swift-declarative-configuration/actions/workflows/Test.yml) ![Platforms](https://img.shields.io/badge/platforms-iOS_13_|_macOS_11_|_tvOS_13_|_watchOS_6_|_Catalyst_13-ED523F.svg?style=flat) [![@capture_context](https://img.shields.io/badge/contact-@capture__context-1DA1F2.svg?style=flat&logo=twitter)](https://twitter.com/capture_context) + +> This readme is draft and the branch is still an `alpha` version ## Usage -Basically all you need is to call `configureRoutes` method of the viewController, it accepts routing publisher and routeConfigurations, your code may look somewhat like this: +This library was primarely created for [TCA](https://github.com/pointfreeco/swift-composable-archtiecture) navigation with Cocoa. However it's geneic enough to use with pure combine. But to dive more into general understanding of stack-based and tree based navigation take a look at TCA docs. + +### Tree-based navigation + +Basically all you need is to call `navigationDestination` method of the viewController, it accepts routing publisher and mapping of the route to the destination controller. Your code may look somewhat like this: ```swift +enum MyFeatureRoute { + case details +} + final class MyViewController: UIViewController { // ... func bindViewModel() { - configureRoutes( + navigationDestination( for viewModel.publisher(for: \.state.route), - routes: [ - // Provide mapping from route to controller - .associate(makeDetailsController, with: .details) - ], + switch: { [unowned self] route in + switch route { + case .details: + makeDetailsController() + } + }, onDismiss: { // Update state on dismiss viewModel.send(.dismiss) @@ -26,6 +38,78 @@ final class MyViewController: UIViewController { } ``` +or + +```swift +enum MyFeatureState { + // ... + var details: DetailsState? +} + +final class MyViewController: UIViewController { + // ... + + func bindViewModel() { + navigationDestination( + "my_feature_details" + isPresented: viewModel.publisher(for: \.state.detais.isNotNil), + controller: { [unowned self] in makeDetailsController() }, + onDismiss: capture { $0.viewModel.send(.dismiss) } + ).store(in: &cancellables) + } +} +``` + +### Stack-based navigation + +Basically all you need is to call `navigationStack` method of the viewController, it accepts routing publisher and mapping of the route to the destination controller. Your code may look somewhat like this: + +```swift +enum MyFeatureState { + enum DestinationState { + case featureA(FeatureAState) + case featureB(FeatureBState) + } + // ... + var navigationStack: [DestinationState] +} + +final class MyViewController: UIViewController { + // ... + + func bindViewModel() { + navigationStack( + for viewModel.publisher(for: \.state.navigationStack), + switch: { [unowned self] route in + switch route { + case .featureA: + makeFeatureAController() + case .featureB: + makeFeatureBController() + } + }, + onDismiss: capture { _self, _ in + _self.viewModel.send(.dismiss) + } + ).store(in: &cancellables) + } +} +``` + +### Notes and good practices + +- One controller should not manage navigation stack and navigation tree +- Routing controller's parent should be navigationController +- Tips above will prevent you from making logical errors + +## Coming soon + +- Rich example +- Readme update +- Docc documentation +- Presentation helpers +- Tests (maybe 🤡) + ## Installation ### Basic @@ -43,7 +127,7 @@ If you use SwiftPM for your project, you can add CombineNavigation to your packa ```swift .package( url: "https://github.com/capturecontext/combine-cocoa-navigation.git", - .upToNextMinor(from: "0.2.0") + branch: "navigation-stacks" ) ``` @@ -54,7 +138,9 @@ Do not forget about target dependencies: name: "CombineNavigation", package: "combine-cocoa-navigation" ) +``` ## License This package is released under the MIT license. See [LICENSE](./LICENSE) for details. + diff --git a/Sources/CombineNavigation/AssociatedRoutes.swift b/Sources/CombineNavigation/AssociatedRoutes.swift deleted file mode 100644 index 37a3e7c..0000000 --- a/Sources/CombineNavigation/AssociatedRoutes.swift +++ /dev/null @@ -1,25 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import CocoaAliases -import FoundationExtensions - -extension CocoaViewController { - var __erasedRouteConfigurations: Set> { - set { setAssociatedObject(newValue, forKey: #function) } - get { getAssociatedObject(forKey: #function).or([]) } - } - - var erasedRouteConfigurations: Set> { - return __erasedRouteConfigurations.union( - parent - .map(\.__erasedRouteConfigurations) - .or([]) - ) - } - - public func addRoute( - _ configuration: RouteConfiguration - ) { - __erasedRouteConfigurations.insert(configuration) - } -} -#endif diff --git a/Sources/CombineNavigation/CocoaViewController+.swift b/Sources/CombineNavigation/CocoaViewController+.swift index 07eda7b..e351cf2 100644 --- a/Sources/CombineNavigation/CocoaViewController+.swift +++ b/Sources/CombineNavigation/CocoaViewController+.swift @@ -1,165 +1,175 @@ #if canImport(UIKit) && !os(watchOS) +import Capture import CocoaAliases -import Combine import CombineExtensions import FoundationExtensions -fileprivate extension Cancellable { - func store(in cancellable: inout Cancellable?) { - cancellable = self - } +// MARK: - Public API + +// MARK: navigationStack + +extension CocoaViewController { + public func navigationStack< + P: Publisher, + C: Collection & Equatable, + Route: Hashable + >( + for publisher: P, + switch controller: @escaping (Route) -> UIViewController, + onDismiss: @escaping (Route) -> Void + ) -> Cancellable where + P.Output == C, + P.Failure == Never, + C.Element == Route + { + publisher + .sinkValues(capture { _self, routes in + _self.__navigationStack = routes.map { route in + .init( + id: route, + controller: { controller(route) }, + onDismiss: { onDismiss(route) } + ) + } + }) + } + + public func navigationStack< + P: Publisher, + Stack, + IDs: Collection & Equatable, + Route + >( + for publisher: P, + ids: @escaping (Stack) -> IDs, + route: @escaping (Stack, IDs.Element) -> Route?, + switch controller: @escaping (Route) -> UIViewController, + onDismiss: @escaping (IDs.Element) -> Void + ) -> Cancellable where + P.Output == Stack, + P.Failure == Never, + IDs.Element: Hashable + { + publisher + .sinkValues(capture { _self, stack in + _self.__navigationStack = ids(stack).compactMap { id in + route(stack, id).map { route in + .init( + id: id, + controller: { controller(route) }, + onDismiss: { onDismiss(id) } + ) + } + } + }) + } +} + +// MARK: navigationDestination + +extension CocoaViewController { + public func navigationDestination( + _ id: AnyHashable, + isPresented publisher: P, + controller: @escaping () -> CocoaViewController, + onDismiss: @escaping () -> Void + ) -> AnyCancellable where + P.Output == Bool, + P.Failure == Never + { + publisher + .sinkValues(capture { _self, isPresented in + _self.__navigationStack = isPresented ? [ + .init( + id: id, + controller: controller, + onDismiss: onDismiss + ) + ] : [] + }) + } + + public func navigationDestination( + _ publisher: P, + switch controller: @escaping (P.Output) -> CocoaViewController, + onDismiss: @escaping () -> Void + ) -> AnyCancellable where + P.Output: Hashable & ExpressibleByNilLiteral, + P.Failure == Never + { + publisher + .sinkValues(capture { _self, route in + _self.__navigationStack = route == nil ? [] : [ + .init( + id: route, + controller: { controller(route) }, + onDismiss: onDismiss + ) + ] + }) + } +} + +// MARK: - Internal API + +extension CocoaViewController { + @AssociatedObject + internal var __navigationStack: [NavigationRoute] = [] { + didSet { requestNavigationStackSync() } + } + + internal func navigationStackControllers( + for navigation: UINavigationController + ) -> [CocoaViewController] { + __navigationStack.flatMap { route in + let controller = controller(for: route, in: navigation) + return [controller] + controller.navigationStackControllers(for: navigation) + } + } } +// MARK: Configure managed route + extension CocoaViewController { - var __dismissCancellables: Set { - set { setAssociatedObject(newValue, forKey: #function) } - get { getAssociatedObject(forKey: #function).or([]) } - } - - public func configureRoute< - P: Publisher, - Route: ExpressibleByNilLiteral & Hashable - >( - for publisher: P, - onDismiss: @escaping () -> Void = {} - ) -> Cancellable where P.Output == Route, P.Failure == Never { - publisher - .removeDuplicates() - .receive(on: UIScheduler.shared) - .sink { [weak self] route in - guard let self = self else { return } - - let destination = self - .erasedRouteConfigurations - .first { $0.target == AnyHashable(route) } - .map { $0.getController } - - self.navigate( - to: destination, - beforePush: { controller in - self.configureNavigationDismiss(onDismiss) - .store(in: &controller.__dismissCancellables) - } - ) - } - } - - public func configureRoutes< - P: Publisher, - Route: ExpressibleByNilLiteral & Hashable - >( - for publisher: P, - routes: [RouteConfiguration], - onDismiss: @escaping () -> Void = {} - ) -> Cancellable where P.Output == Route, P.Failure == Never { - publisher - .removeDuplicates() - .receive(on: UIScheduler.shared) - .sink { [weak self] route in - guard let self = self else { return } - let destination = routes - .first { $0.target == route } - .map { $0.getController } - .or( - self - .erasedRouteConfigurations - .first { $0.target == AnyHashable(route) } - .map { $0.getController } - ) - - self.navigate( - to: destination, - beforePush: { controller in - self.configureNavigationDismiss(onDismiss) - .store(in: &controller.__dismissCancellables) - } - ) - } - } - - private func navigate( - to destination: (() -> CocoaViewController)?, - beforePush: (CocoaViewController) -> Void - ) { - guard let navigationController = self.navigationController - else { return } - - let isDismiss = destination == nil - && navigationController.visibleViewController !== self - - if isDismiss { - guard navigationController.viewControllers.contains(self) else { - navigationController.popToRootViewController(animated: true) - return - } - navigationController.popToViewController(self, animated: true) - } else if let destination = destination { - let controller = destination() - - if navigationController.viewControllers.contains(self) { - if navigationController.viewControllers.last !== self { - navigationController.popToViewController(self, animated: false) - } - } - - beforePush(controller) - navigationController.pushViewController(controller, animated: true) - } - } - - private func configureNavigationDismiss( - _ action: @escaping () -> Void - ) -> Cancellable { - let localRoot = navigationController?.topViewController - - let first = navigationController? - .publisher(for: #selector(UINavigationController.popViewController)) - .receive(on: UIScheduler.shared) - .sink { [weak self, weak localRoot] in - guard - let self = self, - let localRoot = localRoot, - self.navigationController?.visibleViewController === localRoot - else { return } - if let coordinator = self.navigationController?.transitionCoordinator { - coordinator.animate(alongsideTransition: nil) { context in - if !context.isCancelled { action() } - } - } else { - action() - } - } - - let second: Cancellable? = navigationController? - .publisher(for: #selector(UINavigationController.popToViewController)) - .receive(on: UIScheduler.shared) - .sink { [weak self] in - guard - let self = self, - let navigationController = self.navigationController, - !navigationController.viewControllers.contains(self) - else { return } - if let coordinator = self.navigationController?.transitionCoordinator { - coordinator.animate(alongsideTransition: nil) { context in - if !context.isCancelled { action() } - } - } else { - action() - } - } - - let third = navigationController? - .publisher(for: #selector(UINavigationController.popToRootViewController)) - .receive(on: UIScheduler.shared) - .sink { action() } - - let cancellable = AnyCancellable { - first?.cancel() - second?.cancel() - third?.cancel() - } - - return cancellable - } + @AssociatedObject + fileprivate var __dismissCancellables: [AnyHashable: AnyCancellable] = [:] + + fileprivate func controller( + for route: NavigationRoute, + in navigation: UINavigationController + ) -> CocoaViewController { + let controller = route.controller() + navigation + .dismissPublisher(for: controller) + .sinkValues(capture { _self in + _self.__dismissCancellables.removeValue(forKey: route.id) + return route.onDismiss() + }) + .store(for: route.id, in: &__dismissCancellables) + return controller + } +} + +// MARK: Sync navigation stack + +extension CocoaViewController { + @AssociatedObject + fileprivate var __navigationControllerCancellable: AnyCancellable? + + fileprivate func requestNavigationStackSync() { + if let navigationController { + syncNavigationStack(using: navigationController) + } else { + publisher(for: \.navigationController) + .compactMap { $0 } + .sinkValues(capture { $0.syncNavigationStack(using: $1) }) + .store(in: &__navigationControllerCancellable) + } + } + + fileprivate func syncNavigationStack(using navigation: UINavigationController) { + __navigationControllerCancellable = nil + navigation.syncNavigationStack(for: self) + } } #endif diff --git a/Sources/CombineNavigation/Extensions/AnyCancellable+.swift b/Sources/CombineNavigation/Extensions/AnyCancellable+.swift new file mode 100644 index 0000000..b0d3fa7 --- /dev/null +++ b/Sources/CombineNavigation/Extensions/AnyCancellable+.swift @@ -0,0 +1,16 @@ +import Combine + +extension AnyCancellable { + internal func store( + for key: Key, + in cancellables: inout [Key: AnyCancellable] + ) { + cancellables[key] = self + } + + internal func store( + in cancellable: inout AnyCancellable? + ) { + cancellable = self + } +} diff --git a/Sources/CombineNavigation/Extensions/NSObject+.swift b/Sources/CombineNavigation/Extensions/NSObject+.swift new file mode 100644 index 0000000..c57f113 --- /dev/null +++ b/Sources/CombineNavigation/Extensions/NSObject+.swift @@ -0,0 +1,5 @@ +import Foundation + +extension NSObject { + var objectID: ObjectIdentifier { .init(self) } +} diff --git a/Sources/CombineNavigation/NavigationRoute.swift b/Sources/CombineNavigation/NavigationRoute.swift new file mode 100644 index 0000000..77a9aa8 --- /dev/null +++ b/Sources/CombineNavigation/NavigationRoute.swift @@ -0,0 +1,29 @@ +#if canImport(UIKit) && !os(watchOS) +import CocoaAliases +import FoundationExtensions +import Combine + +struct NavigationRoute: Hashable, Equatable, Identifiable { + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + let id: AnyHashable + let controller: () -> CocoaViewController + let onDismiss: () -> Void + + init( + id: AnyHashable, + controller: @escaping () -> CocoaViewController, + onDismiss: @escaping () -> Void + ) { + self.id = id + self.controller = controller + self.onDismiss = onDismiss + } +} +#endif diff --git a/Sources/CombineNavigation/RouteConfiguration.swift b/Sources/CombineNavigation/RouteConfiguration.swift deleted file mode 100644 index 5621429..0000000 --- a/Sources/CombineNavigation/RouteConfiguration.swift +++ /dev/null @@ -1,29 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import CocoaAliases - -public struct RouteConfiguration: Hashable { - public static func associate( - _ controller: @escaping () -> CocoaViewController, - with target: Target - ) -> RouteConfiguration { .init(for: controller, target: target) } - - public init( - for controller: @escaping () -> CocoaViewController, - target: Target - ) { - self.getController = controller - self.target = target - } - - public let getController: () -> CocoaViewController - public let target: Target - - public static func ==(lhs: Self, rhs: Self) -> Bool { - return lhs.target == rhs.target - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(target) - } -} -#endif diff --git a/Sources/CombineNavigation/UINavigationController+.swift b/Sources/CombineNavigation/UINavigationController+.swift new file mode 100644 index 0000000..523de22 --- /dev/null +++ b/Sources/CombineNavigation/UINavigationController+.swift @@ -0,0 +1,64 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +extension UINavigationController { + internal func dismissPublisher(for controller: CocoaViewController) -> some Publisher { + let controllerID = controller.objectID + return Publishers.Merge3( + publisher(for: #selector(UINavigationController.popViewController)), + publisher(for: #selector(UINavigationController.popToViewController)), + publisher(for: #selector(UINavigationController.popToRootViewController)) + ) + .flatMap { [weak self] in Future { promise in + guard let self else { return promise(.success(false)) } + + func controllerIsNotInStack() -> Bool { + !self.viewControllers.contains { $0.objectID == controllerID } + } + + guard let transitionCoordinator = self.transitionCoordinator + else { return promise(.success(controllerIsNotInStack())) } // Handle programmatic pop + + // Handle interactive pop if not cancelled + transitionCoordinator.animate(alongsideTransition: nil) { context in + promise(.success(!context.isCancelled && controllerIsNotInStack())) + } + }} + .filter { $0 } + .replaceOutput(with: ()) + } + + internal func syncNavigationStack(for controller: CocoaViewController) { + // controller, that manages navigation stack + // or it's parent, the parent of the pointer + // must be current navigation controller + let navigationStackPointer: UIViewController = { + var pointer = controller + while pointer.parent !== self { + guard let parent = pointer.parent else { + fatalError("Attempt to sync navigationStack from unrelated viewController") + } + pointer = parent + } + return pointer + }() + + // controllers before navigation stack managing controller + let prefix = viewControllers.prefix(while: { $0 !== navigationStackPointer }) + + // managed navigation stack + let suffix = controller.navigationStackControllers(for: self) + + // setViewControllers updates navigation stack with + // valid push/pop animation, unmanaged controllers are thrown away + setViewControllers( + prefix + [navigationStackPointer] + suffix, + animated: true + ) + } +} + +#endif From 9efe414ffb730a68f51f396e6e9995c349c91883 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Mon, 13 Nov 2023 02:36:43 +0100 Subject: [PATCH 02/43] feat(wip): API improvements & Tests - Add navigationAnimation API - Improve routes mapping API - Add Destination wrapper for child controllers - Add RoutingController macro and RoutingControllerProtocol - Make mapping optional for navigationDestinations for state-based dismissal - Generalize configuration API by removing labels for publishers - Add tests - Destination Wrapper - RoutingController macro - RoutingControllerProtocol - Add todo warnings --- .../xcschemes/CombineNavigation.xcscheme | 66 +++++ .../combine-cocoa-navigation.xcscheme | 116 +++++++++ Package.swift | 45 +++- Sources/CombineNavigation/Animation.swift | 19 ++ .../CocoaViewController+.swift | 34 +-- Sources/CombineNavigation/Destination.swift | 59 +++++ .../CombineNavigation/NavigationRoute.swift | 4 +- .../CombineNavigation/RoutingController.swift | 47 ++++ .../UINavigationController+.swift | 6 +- .../CompilerPlugin.swift | 9 + .../Helpers/Diagnostics+.swift | 9 + .../Helpers/Operators.swift | 43 +++ .../RoutingControllerMacro.swift | 136 ++++++++++ .../RoutingControllerMacroTests.swift | 151 +++++++++++ .../DestinationTests.swift | 85 ++++++ .../CombineNavigationTests/Helpers/wait.swift | 15 ++ .../RoutingControllerTests.swift | 245 ++++++++++++++++++ 17 files changed, 1068 insertions(+), 21 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/CombineNavigation.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/combine-cocoa-navigation.xcscheme create mode 100644 Sources/CombineNavigation/Animation.swift create mode 100644 Sources/CombineNavigation/Destination.swift create mode 100644 Sources/CombineNavigation/RoutingController.swift create mode 100644 Sources/CombineNavigationMacros/CompilerPlugin.swift create mode 100644 Sources/CombineNavigationMacros/Helpers/Diagnostics+.swift create mode 100644 Sources/CombineNavigationMacros/Helpers/Operators.swift create mode 100644 Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift create mode 100644 Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift create mode 100644 Tests/CombineNavigationTests/DestinationTests.swift create mode 100644 Tests/CombineNavigationTests/Helpers/wait.swift create mode 100644 Tests/CombineNavigationTests/RoutingControllerTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigation.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigation.xcscheme new file mode 100644 index 0000000..728cc3f --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigation.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/combine-cocoa-navigation.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/combine-cocoa-navigation.xcscheme new file mode 100644 index 0000000..2462544 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/combine-cocoa-navigation.xcscheme @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.swift b/Package.swift index 84b17fa..ea7f9c5 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,16 @@ // swift-tools-version: 5.9 import PackageDescription +import CompilerPluginSupport + +#warning("TODO: Add rich example") +// The example is WIP, it's a simple twitter-like app +// but already has examples for Tree-based and recursive Tree-based +// navigation. Stack-based navigation is planned +// +// Do not forget to add it to repo before publishing a release ^^ + +#warning("TODO: Add docc and publish it on SPI") let package = Package( name: "combine-cocoa-navigation", @@ -32,13 +42,22 @@ let package = Package( ), .package( url: "https://github.com/capturecontext/swift-foundation-extensions.git", - .upToNextMinor(from: "0.3.3") + .upToNextMinor(from: "0.3.4") ), + .package( + url: "https://github.com/stackotter/swift-macro-toolkit.git", + .upToNextMinor(from: "0.3.0") + ), + .package( + url: "https://github.com/pointfreeco/swift-macro-testing.git", + .upToNextMinor(from: "0.2.0") + ) ], targets: [ .target( name: "CombineNavigation", dependencies: [ + .target(name: "CombineNavigationMacros"), .product( name: "Capture", package: "swift-capture" @@ -56,6 +75,28 @@ let package = Package( package: "swift-foundation-extensions" ), ] - ) + ), + .macro( + name: "CombineNavigationMacros", + dependencies: [ + .product( + name: "MacroToolkit", + package: "swift-macro-toolkit" + ) + ] + ), + .testTarget( + name: "CombineNavigationTests", + dependencies: [ + .target(name: "CombineNavigation") + ] + ), + .testTarget( + name: "CombineNavigationMacrosTests", + dependencies: [ + .target(name: "CombineNavigationMacros"), + .product(name: "MacroTesting", package: "swift-macro-testing"), + ] + ), ] ) diff --git a/Sources/CombineNavigation/Animation.swift b/Sources/CombineNavigation/Animation.swift new file mode 100644 index 0000000..846bcf1 --- /dev/null +++ b/Sources/CombineNavigation/Animation.swift @@ -0,0 +1,19 @@ +#if canImport(UIKit) && !os(watchOS) +public func withNavigationAnimation( + enabled: Bool = true, + perform operation: () throws -> R +) rethrows -> R { + try NavigationAnimation.$enabled.withValue(enabled, operation: operation) +} + +public func withNavigationAnimation( + enabled: Bool = true, + perform operation: () async throws -> R +) async rethrows -> R { + try await NavigationAnimation.$enabled.withValue(enabled, operation: operation) +} + +internal enum NavigationAnimation { + @TaskLocal static var enabled: Bool = true +} +#endif diff --git a/Sources/CombineNavigation/CocoaViewController+.swift b/Sources/CombineNavigation/CocoaViewController+.swift index e351cf2..c961452 100644 --- a/Sources/CombineNavigation/CocoaViewController+.swift +++ b/Sources/CombineNavigation/CocoaViewController+.swift @@ -14,7 +14,7 @@ extension CocoaViewController { C: Collection & Equatable, Route: Hashable >( - for publisher: P, + _ publisher: P, switch controller: @escaping (Route) -> UIViewController, onDismiss: @escaping (Route) -> Void ) -> Cancellable where @@ -40,7 +40,7 @@ extension CocoaViewController { IDs: Collection & Equatable, Route >( - for publisher: P, + _ publisher: P, ids: @escaping (Stack) -> IDs, route: @escaping (Stack, IDs.Element) -> Route?, switch controller: @escaping (Route) -> UIViewController, @@ -91,7 +91,7 @@ extension CocoaViewController { public func navigationDestination( _ publisher: P, - switch controller: @escaping (P.Output) -> CocoaViewController, + switch controller: @escaping (P.Output) -> CocoaViewController?, onDismiss: @escaping () -> Void ) -> AnyCancellable where P.Output: Hashable & ExpressibleByNilLiteral, @@ -122,8 +122,9 @@ extension CocoaViewController { for navigation: UINavigationController ) -> [CocoaViewController] { __navigationStack.flatMap { route in - let controller = controller(for: route, in: navigation) - return [controller] + controller.navigationStackControllers(for: navigation) + controller(for: route, in: navigation).map { controller in + [controller] + controller.navigationStackControllers(for: navigation) + }.or([]) } } } @@ -137,16 +138,19 @@ extension CocoaViewController { fileprivate func controller( for route: NavigationRoute, in navigation: UINavigationController - ) -> CocoaViewController { - let controller = route.controller() - navigation - .dismissPublisher(for: controller) - .sinkValues(capture { _self in - _self.__dismissCancellables.removeValue(forKey: route.id) - return route.onDismiss() - }) - .store(for: route.id, in: &__dismissCancellables) - return controller + ) -> CocoaViewController? { + return route.controller().map { controller in + navigation + .dismissPublisher(for: controller) + .sinkValues(capture { _self in + _self.__dismissCancellables + .removeValue(forKey: route.id)? + .cancel() + return route.onDismiss() + }) + .store(for: route.id, in: &__dismissCancellables) + return controller + } } } diff --git a/Sources/CombineNavigation/Destination.swift b/Sources/CombineNavigation/Destination.swift new file mode 100644 index 0000000..e21c150 --- /dev/null +++ b/Sources/CombineNavigation/Destination.swift @@ -0,0 +1,59 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +@propertyWrapper +open class Destination: Weakifiable { + private weak var _controller: Controller? + public var wrappedValue: Controller? { _controller } + public var projectedValue: Destination { self } + + private var _initControllerOverride: (() -> Controller)? + + /// Sets instance-specific override for creating a new controller + /// + /// This override has the highest priority when creating a new controller + /// + /// To disable isntance-specific override pass `nil` to this method + public func overrideInitController( + with closure: (() -> Controller)? + ) { + _initControllerOverride = closure + } + + @_spi(Internals) public class func initController() -> Controller { + return Controller() + } + + @_spi(Internals) open func configureController(_ controller: Controller) {} + + /// Creates a new instance + public init() {} + + /// Creates a new instance with instance-specific override for creating a new controller + /// + /// This override has the highest priority when creating a new controller, default one is just `Controller()` + /// **which can lead to crashes if controller doesn't have an empty init** + /// + /// Default implementation is suitable for most controllers, however if you have a controller which + /// doesn't have a custom init you'll have to use this method or if you have a base controller that + /// requires custom init it'll be beneficial for you to create a custom subclass of Destination + /// and override it's `initController` class method, you can find an example in tests. + convenience init(_ initControllerOverride: @escaping () -> Controller) { + self.init() + self._initControllerOverride = initControllerOverride + } + + /// Returns wrappedValue if present, intializes and configures a new instance otherwise + public func callAsFunction() -> Controller { + wrappedValue ?? { + let controller = _initControllerOverride?() ?? Self.initController() + configureController(controller) + _controller = controller + return controller + }() + } +} +#endif diff --git a/Sources/CombineNavigation/NavigationRoute.swift b/Sources/CombineNavigation/NavigationRoute.swift index 77a9aa8..4d13704 100644 --- a/Sources/CombineNavigation/NavigationRoute.swift +++ b/Sources/CombineNavigation/NavigationRoute.swift @@ -13,12 +13,12 @@ struct NavigationRoute: Hashable, Equatable, Identifiable { } let id: AnyHashable - let controller: () -> CocoaViewController + let controller: () -> CocoaViewController? let onDismiss: () -> Void init( id: AnyHashable, - controller: @escaping () -> CocoaViewController, + controller: @escaping () -> CocoaViewController?, onDismiss: @escaping () -> Void ) { self.id = id diff --git a/Sources/CombineNavigation/RoutingController.swift b/Sources/CombineNavigation/RoutingController.swift new file mode 100644 index 0000000..bac0ecd --- /dev/null +++ b/Sources/CombineNavigation/RoutingController.swift @@ -0,0 +1,47 @@ +import CocoaAliases +import CombineNavigationMacros + +@attached( + extension, + conformances: RoutingControllerProtocol, + names: named(Destinations), named(_makeDestinations()) +) +public macro RoutingController() = #externalMacro( + module: "CombineNavigationMacros", + type: "RoutingControllerMacro" +) + +public protocol RoutingControllerProtocol: CocoaViewController { + associatedtype Destinations + func _makeDestinations() -> Destinations +} + +extension RoutingControllerProtocol { + private static func _mapNavigationDestinations( + _ destinations: Destinations, + _ mapping: @escaping (Destinations, Route) -> Output + ) -> (Route) -> Output { + return { route in + mapping(destinations, route) + } + } + + public func destinations( + _ mapping: @escaping (Destinations, Route) -> CocoaViewController + ) -> (Route) -> CocoaViewController { + Self._mapNavigationDestinations( + _makeDestinations(), + mapping + ) + } + + public func destinations( + _ mapping: @escaping (Destinations, Route) -> CocoaViewController? + ) -> (Route) -> CocoaViewController? + where Route: ExpressibleByNilLiteral { + Self._mapNavigationDestinations( + _makeDestinations(), + mapping + ) + } +} diff --git a/Sources/CombineNavigation/UINavigationController+.swift b/Sources/CombineNavigation/UINavigationController+.swift index 523de22..b1c79bf 100644 --- a/Sources/CombineNavigation/UINavigationController+.swift +++ b/Sources/CombineNavigation/UINavigationController+.swift @@ -52,11 +52,13 @@ extension UINavigationController { // managed navigation stack let suffix = controller.navigationStackControllers(for: self) + let navigationStack = prefix + [navigationStackPointer] + suffix + // setViewControllers updates navigation stack with // valid push/pop animation, unmanaged controllers are thrown away setViewControllers( - prefix + [navigationStackPointer] + suffix, - animated: true + navigationStack, + animated: NavigationAnimation.$enabled.get() ) } } diff --git a/Sources/CombineNavigationMacros/CompilerPlugin.swift b/Sources/CombineNavigationMacros/CompilerPlugin.swift new file mode 100644 index 0000000..c1de802 --- /dev/null +++ b/Sources/CombineNavigationMacros/CompilerPlugin.swift @@ -0,0 +1,9 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct CombineNavigationPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + RoutingControllerMacro.self + ] +} diff --git a/Sources/CombineNavigationMacros/Helpers/Diagnostics+.swift b/Sources/CombineNavigationMacros/Helpers/Diagnostics+.swift new file mode 100644 index 0000000..1ef3781 --- /dev/null +++ b/Sources/CombineNavigationMacros/Helpers/Diagnostics+.swift @@ -0,0 +1,9 @@ +import SwiftDiagnostics +import SwiftSyntaxMacros + +extension MacroExpansionContext { + func diagnose(_ diagnostic: Diagnostic, return value: T) -> T { + self.diagnose(diagnostic) + return value + } +} diff --git a/Sources/CombineNavigationMacros/Helpers/Operators.swift b/Sources/CombineNavigationMacros/Helpers/Operators.swift new file mode 100644 index 0000000..5623103 --- /dev/null +++ b/Sources/CombineNavigationMacros/Helpers/Operators.swift @@ -0,0 +1,43 @@ +func ==( + lhs: KeyPath, + rhs: B +) -> (A) -> Bool { + return { a in + a[keyPath: lhs] == rhs + } +} + +func !=( + lhs: KeyPath, + rhs: B +) -> (A) -> Bool { + return { a in + a[keyPath: lhs] != rhs + } +} + +func ||( + lhs: @escaping (A) -> Bool, + rhs: @escaping (A) -> Bool +) -> (A) -> Bool { + return { a in + lhs(a) || rhs(a) + } +} + +func &&( + lhs: @escaping (A) -> Bool, + rhs: @escaping (A) -> Bool +) -> (A) -> Bool { + return { a in + lhs(a) && rhs(a) + } +} + +prefix func !( + f: @escaping (A) -> Bool +) -> (A) -> Bool { + return { a in + !f(a) + } +} diff --git a/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift new file mode 100644 index 0000000..d4d66c4 --- /dev/null +++ b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift @@ -0,0 +1,136 @@ +import MacroToolkit +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +public struct RoutingControllerMacro { + static let moduleName = "CombineNavigation" + static let conformanceName = "RoutingControllerProtocol" + static var qualifiedConformanceName: String { "\(Self.moduleName).\(Self.conformanceName)" } + static var conformanceNames: [String] { [Self.conformanceName, Self.qualifiedConformanceName] } +} + +extension RoutingControllerMacro: ExtensionMacro { + public static func expansion< + Declaration: DeclGroupSyntax, + Type: TypeSyntaxProtocol, + Context: MacroExpansionContext + >( + of node: AttributeSyntax, + attachedTo declaration: Declaration, + providingExtensionsOf type: Type, + conformingTo protocols: [TypeSyntax], + in context: Context + ) throws -> [ExtensionDeclSyntax] { + guard let decl = ClassDeclSyntax(declaration) else { + return context.diagnose(.requiresClassDeclaration(declaration), return: []) + } + + let alreadyConforms = decl.inheritanceClause?.inheritedTypes.contains(where: { + Self.conformanceNames.contains($0.type.trimmedDescription) + }) == true + + if alreadyConforms { return [] } + + var extensionDecl: ExtensionDeclSyntax = { + let decl: DeclSyntax = """ + extension \(type.trimmed): \(raw: Self.qualifiedConformanceName) {} + """ + return decl.cast(ExtensionDeclSyntax.self) + }() + + extensionDecl.memberBlock = MemberBlockSyntax( + members: MemberBlockItemListSyntax( + makeExtensionMembers( + from: navigationChildren(for: decl) + ) + ) + ) + + return [extensionDecl] + } + + static func navigationChildren(for declaration: ClassDeclSyntax) -> [MemberBlockItemSyntax] { + declaration.memberBlock.members.compactMap { member in + guard let variable = member.decl.as(VariableDeclSyntax.self) + else { return nil } + + let isNavigationChild = variable.attributes.contains { attribute in + guard let attribute = attribute.as(AttributeSyntax.self) + else { return false } + return attribute.attributeName.description.hasSuffix("Destination") + } + + return isNavigationChild ? member : nil + } + } + + static func makeExtensionMembers( + from navigationChildren: [MemberBlockItemSyntax] + ) -> [MemberBlockItemSyntax] { + let destinationStructDocComment: String = """ + /// Container for captured destinations without referring to self + /// + /// > Generated by `CombineNavigation.RoutingController` macro + /// + /// Use in `navigationDestination`/`navigationStack` methods to map + /// routes to specific destinations using `destinations` method + """ + + let destinationsStructDecl = StructDeclSyntax( + leadingTrivia: Trivia.init( + pieces: destinationStructDocComment + .components(separatedBy: .newlines) + .flatMap { [.docLineComment($0), .newlines(1)] } + ), + modifiers: DeclModifierListSyntax { + DeclModifierSyntax(name: "public") + }, + name: "Destinations", + memberBlock: MemberBlockSyntax(members: MemberBlockItemListSyntax(navigationChildren)), + trailingTrivia: .newlines(2) + ).as(DeclSyntax.self) + + let destinationsInitParams = navigationChildren.compactMap { member in + Variable(member.decl)?.bindings.first?.identifier + }.map { identifier in + "\(identifier): $\(identifier)" + }.joined(separator: ",\n") + + let makeDestinationsFuncDecl: DeclSyntax = """ + public func _makeDestinations() -> Destinations { + Destinations( + \(raw: destinationsInitParams) + ) + } + """ + + return [ + destinationsStructDecl, + makeDestinationsFuncDecl + ] + .compactMap { $0 } + .map { MemberBlockItemSyntax(decl: $0) } + } +} + +fileprivate extension Diagnostic { + static func requiresClassDeclaration(_ node: some DeclGroupSyntax) -> Self { + func _requiresClassDeclaration(_ node: some SyntaxProtocol) -> Self { + DiagnosticBuilder(for: node) + .messageID(domain: "RoutingController", id: "requires_class_declaration") + .message("`@RoutingController` must be attached to a class declaration.") + .build() + } + + return node.as(EnumDeclSyntax.self).map { decl in + _requiresClassDeclaration(decl.enumKeyword) + } ?? node.as(StructDeclSyntax.self).map { decl in + _requiresClassDeclaration(decl.structKeyword) + } ?? node.as(ActorDeclSyntax.self).map { decl in + _requiresClassDeclaration(decl.actorKeyword) + } ?? { + _requiresClassDeclaration(node) + }() + } +} diff --git a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift new file mode 100644 index 0000000..f8a94a2 --- /dev/null +++ b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift @@ -0,0 +1,151 @@ +import XCTest +import MacroTesting +import CombineNavigationMacros + +final class RoutingControllerTests: XCTestCase { + override func invokeTest() { + withMacroTesting( + isRecording: false, + macros: [ + "RoutingController": RoutingControllerMacro.self + ] + ) { + super.invokeTest() + } + } + + func testAttachmentToStruct() { + assertMacro { + """ + @RoutingController + struct CustomController {} + """ + } diagnostics: { + """ + @RoutingController + struct CustomController {} + ╰─ 🛑 `@RoutingController` must be attached to a class declaration. + """ + } + } + + func testAttachmentToEnum() { + assertMacro { + """ + @RoutingController + enum CustomController {} + """ + } diagnostics: { + """ + @RoutingController + enum CustomController {} + ╰─ 🛑 `@RoutingController` must be attached to a class declaration. + """ + } + } + + func testAttachmentToActor() { + assertMacro { + """ + @RoutingController + actor CustomController {} + """ + } diagnostics: { + """ + @RoutingController + actor CustomController {} + ╰─ 🛑 `@RoutingController` must be attached to a class declaration. + """ + } + } + + func testAttachmentToClass() { + assertMacro { + """ + @RoutingController + final class CustomController { + @Destination + var firstDetailController: UIViewController? + @Destination + var secondDetailController: UIViewController? + } + """ + } expansion: { + """ + final class CustomController { + @Destination + var firstDetailController: UIViewController? + @Destination + var secondDetailController: UIViewController? + } + + extension CustomController: CombineNavigation.RoutingControllerProtocol { + /// Container for captured destinations without referring to self + /// + /// > Generated by `CombineNavigation.RoutingController` macro + /// + /// Use in `navigationDestination`/`navigationStack` methods to map + /// routes to specific destinations using `destinations` method + public struct Destinations { + @Destination + var firstDetailController: UIViewController? + @Destination + var secondDetailController: UIViewController? + } + + public func _makeDestinations() -> Destinations { + Destinations( + firstDetailController: $firstDetailController, + secondDetailController: $secondDetailController + ) + } + } + """ + } + } + + func testCustomDeclaration() { + assertMacro { + """ + @RoutingController + final class CustomController { + @CustomDestination + var firstDetailController: UIViewController? + @CustomDestination + var secondDetailController: UIViewController? + } + """ + } expansion: { + """ + final class CustomController { + @CustomDestination + var firstDetailController: UIViewController? + @CustomDestination + var secondDetailController: UIViewController? + } + + extension CustomController: CombineNavigation.RoutingControllerProtocol { + /// Container for captured destinations without referring to self + /// + /// > Generated by `CombineNavigation.RoutingController` macro + /// + /// Use in `navigationDestination`/`navigationStack` methods to map + /// routes to specific destinations using `destinations` method + public struct Destinations { + @CustomDestination + var firstDetailController: UIViewController? + @CustomDestination + var secondDetailController: UIViewController? + } + + public func _makeDestinations() -> Destinations { + Destinations( + firstDetailController: $firstDetailController, + secondDetailController: $secondDetailController + ) + } + } + """ + } + } +} diff --git a/Tests/CombineNavigationTests/DestinationTests.swift b/Tests/CombineNavigationTests/DestinationTests.swift new file mode 100644 index 0000000..c4d88f2 --- /dev/null +++ b/Tests/CombineNavigationTests/DestinationTests.swift @@ -0,0 +1,85 @@ +import XCTest +import CocoaAliases +@_spi(Internals) @testable import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) +final class DestinationTests: XCTestCase { + func testMain() { + @Destination + var basic: CustomViewController? + + @Destination({ .init(value: 1) }) + var configuredBasic: CustomViewController? + + XCTAssertEqual(_basic().value, 0) + XCTAssertEqual(_configuredBasic().value, 1) + + XCTAssertEqual(_basic().isConfiguredByCustomNavigationChild, false) + XCTAssertEqual(_configuredBasic().isConfiguredByCustomNavigationChild, false) + } + + func testInheritance() { + @CustomDestination + var custom: CustomViewController? + + @CustomDestination({ .init(value: 2) }) + var configuredCustom: CustomViewController? + + XCTAssertEqual(_custom().value, 1) + XCTAssertEqual(_configuredCustom().value, 2) + + XCTAssertEqual(_custom().isConfiguredByCustomNavigationChild, true) + XCTAssertEqual(_configuredCustom().isConfiguredByCustomNavigationChild, true) + + // Should compile to pass the test + _custom.customNavigationChildSpecificMethod() + + // Should compile to pass the test + $custom.customNavigationChildSpecificMethod() + } +} + +fileprivate class CustomViewController: CocoaViewController { + var value: Int = 0 + var isConfiguredByCustomNavigationChild: Bool = false + + convenience init() { + self.init(value: 0) + } + + required init(value: Int) { + super.init(nibName: nil, bundle: nil) + self.value = value + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } +} + +@propertyWrapper +fileprivate final class CustomDestination: Destination { + override var wrappedValue: Controller? { super.wrappedValue } + override var projectedValue: CustomDestination { super.projectedValue as! Self } + + func customNavigationChildSpecificMethod() { } + + /// Override this method to apply initial configuration to the controller + /// + /// `CombineNavigation` should be imported as `@_spi(Internal) import` + /// to override this declaration + override func configureController(_ controller: Controller) { + controller.isConfiguredByCustomNavigationChild = true + } + + /// This wrapper is binded to a custom controller type + /// so you can override wrapper's `initController` method + /// to call some specific initializer + /// + /// `CombineNavigation` should be imported as `@_spi(Internal) import` + /// to override this declaration + override class func initController() -> Controller { + .init(value: 1) + } +} +#endif diff --git a/Tests/CombineNavigationTests/Helpers/wait.swift b/Tests/CombineNavigationTests/Helpers/wait.swift new file mode 100644 index 0000000..efc3d9b --- /dev/null +++ b/Tests/CombineNavigationTests/Helpers/wait.swift @@ -0,0 +1,15 @@ +import XCTest +import FoundationExtensions + +#warning("TODO: Remove before release if not used") +extension XCTestCase { + func wait(for interval: TimeInterval) { + let expectation = XCTestExpectation() + + DispatchQueue.main.asyncAfter(deadline: .interval(interval)) { + expectation.fulfill() + } + + wait(for: [expectation], timeout: interval + 0.5) + } +} diff --git a/Tests/CombineNavigationTests/RoutingControllerTests.swift b/Tests/CombineNavigationTests/RoutingControllerTests.swift new file mode 100644 index 0000000..67eae26 --- /dev/null +++ b/Tests/CombineNavigationTests/RoutingControllerTests.swift @@ -0,0 +1,245 @@ +import XCTest +import CocoaAliases +import Capture +import Combine +@testable import CombineNavigation +#if canImport(UIKit) && !os(watchOS) + +#warning("TODO: Add test for `navigationStack(_:ids:route:switch:onDismiss:)`") +#warning("TODO: Add test for `navigationDestination(_:isPresented:controller:onDismiss:)`") +final class RoutingControllerTests: XCTestCase { + func testNavigationTree() { + let viewModel = TreeViewModel() + let controller = TreeViewController() + let navigationController = UINavigationController(rootViewController: controller) + controller.viewModel = viewModel + + // Disable navigation animation for tests + withNavigationAnimation(enabled: false) { + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + + viewModel.state.value.destination = .feedback() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.feedbackController) + + viewModel.state.value.destination = .orderDetail() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.orderDetailController) + + navigationController.popViewController(animated: false) + XCTAssertEqual(viewModel.state.value.destination, .none) + + viewModel.state.value.destination = .feedback() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.feedbackController) + + viewModel.state.value.destination = .none + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + } + } + + func testNavigationStack() { + let viewModel = StackViewModel() + let controller = StackViewController() + let navigationController = UINavigationController(rootViewController: controller) + controller.viewModel = viewModel + + // Disable navigation animation for tests + withNavigationAnimation(enabled: false) { + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + + viewModel.state.value.path.append(.feedback()) + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.feedbackController) + + viewModel.state.value.path.append(.orderDetail()) + XCTAssertEqual(navigationController.viewControllers.count, 3) + XCTAssert(navigationController.topViewController === controller.orderDetailController) + + #warning("TODO: Improve Destination for stacks") + // The problem: + // + // In this test we don't push same destinations + // because there is only one controller for destination available + // + // Probably we need to introduce StackDestination type + // to keep an array of controllers + + viewModel.state.value.path.removeAll() + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + + viewModel.state.value.path.append(.feedback()) + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.feedbackController) + + viewModel.state.value.path.append(.orderDetail()) + XCTAssertEqual(navigationController.viewControllers.count, 3) + XCTAssert(navigationController.topViewController === controller.orderDetailController) + + _ = viewModel.state.value.path.popLast() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.feedbackController) + + viewModel.state.value.path.append(.orderDetail()) + XCTAssertEqual(navigationController.viewControllers.count, 3) + XCTAssert(navigationController.topViewController === controller.orderDetailController) + + navigationController.popViewController(animated: false) + XCTAssertEqual(viewModel.state.value.path.count, 1) + + viewModel.state.value.path.append(.orderDetail()) + XCTAssertEqual(navigationController.viewControllers.count, 3) + XCTAssert(navigationController.topViewController === controller.orderDetailController) + + #warning("TODO: Improve dismiss") + // Fails, probably because: + // + // - Navigation pops multiple controllers + // -> onDismiss removes them from state one-by-one + // --> when the first one is removed state is updated + // ---> when state is updated it sends an update to routing controller + // ----> routing controller updates navigation stack with the state + // + // To fix it we probably need to find a way to batch dismiss controllers + navigationController.popToViewController(controller, animated: false) + XCTAssertEqual(viewModel.state.value.path.count, 0) + XCTAssertEqual(navigationController.viewControllers.count, 1) + } + } +} + +fileprivate let testDestinationID = UUID() + +fileprivate class OrderDetailsController: CocoaViewController {} +fileprivate class FeedbackController: CocoaViewController {} + +// MARK: - Tree + +fileprivate class TreeViewModel { + struct State { + enum Destination: Equatable { + /// UUID represents some state + case orderDetail(UUID = testDestinationID) + case feedback(UUID = testDestinationID) + + enum Tag: Hashable { + case orderDetail + case feedback + } + + var tag: Tag { + switch self { + case .orderDetail: return .orderDetail + case .feedback: return .feedback + } + } + } + + var destination: Destination? + } + + let state = CurrentValueSubject(.init()) +} + +@RoutingController +fileprivate class TreeViewController: CocoaViewController { + private var cancellables: Set = [] + + var viewModel: TreeViewModel! { + didSet { bind(viewModel.state) } + } + + @Destination + var orderDetailController: OrderDetailsController? + + @Destination + var feedbackController: FeedbackController? + + func bind>(_ publisher: P) { + navigationDestination( + publisher.map(\.destination?.tag).removeDuplicates(), + switch: destinations { destinations, route in + switch route { + case .orderDetail: + destinations.$orderDetailController() + case .feedback: + destinations.$feedbackController() + case .none: + nil + } + }, + onDismiss: capture { _self in + _self.viewModel.state.value.destination = .none + } + ) + .store(in: &cancellables) + } +} + +// MARK: - Stack + +fileprivate class StackViewModel { + struct State { + enum Destination { + /// UUID represents some state + case orderDetail(UUID = testDestinationID) + case feedback(UUID = testDestinationID) + + enum Tag: Hashable { + case orderDetail + case feedback + } + + var tag: Tag { + switch self { + case .orderDetail: return .orderDetail + case .feedback: return .feedback + } + } + } + + var path: [Destination] = [] + } + + let state = CurrentValueSubject(.init()) +} + +@RoutingController +fileprivate class StackViewController: CocoaViewController { + private var cancellables: Set = [] + + var viewModel: StackViewModel! { + didSet { bind(viewModel.state) } + } + + @Destination + var orderDetailController: OrderDetailsController? + + @Destination + var feedbackController: FeedbackController? + + func bind>(_ publisher: P) { + navigationStack( + publisher.map(\.path).map { $0.map(\.tag) }.removeDuplicates(), + switch: destinations { destinations, route in + switch route { + case .orderDetail: + destinations.$orderDetailController() + case .feedback: + destinations.$feedbackController() + } + }, + onDismiss: capture { _self, route in + guard let index = _self.viewModel.state.value.path.lastIndex(where: { $0.tag == route }) + else { return } + _self.viewModel.state.value.path.remove(at: index) + } + ) + .store(in: &cancellables) + } +} +#endif From 7f75684e16bb42dae6292dedd763ebbfd964c376 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Wed, 15 Nov 2023 02:51:58 +0100 Subject: [PATCH 03/43] feat(wip): API Improvements & Tests - Add Publisher.withNavigationAnimation() - Fix navigationStack todo - Add StackDestination wrapper - Add destinations mapper for stacks - API Improvements: - Add withoutNavigationAnimation functions - Rename RoutingControllerProtocol to RoutingController - Tests - Add StackDestinationTests - Update TreeDestinationTests - Add RoutingControllerTests.testAnimation - Add RoutingControllerTests.testAnimationPublisher - Add RoutingControllerTests.testNavigationStackDestinations - Update RoutingControllerTests - Update RoutingControllerMacroTests - Infrastructure improvements - Setup CI - Add workflow - Add Makefile - Add SPI docs - Cleanup project structure - Minor Readme update - Remove resolved todos and unused code [ci skip] --- .github/workflows/Test.yml | 22 ++ .spi.yml | 5 + Makefile | 2 + Package.swift | 10 +- README.md | 54 +++-- Sources/CombineNavigation/Animation.swift | 19 -- .../CocoaViewController+.swift | 179 ---------------- .../CocoaViewController+API.swift | 128 +++++++++++ .../Destinations/StackDestination.swift | 67 ++++++ .../TreeDestination.swift} | 21 +- .../Internal/CocoaViewController+.swift | 104 +++++++++ .../Helpers}/AnyCancellable+.swift | 0 .../Helpers}/NSObject+.swift | 0 .../{ => Internal}/NavigationRoute.swift | 14 +- .../UINavigationController+.swift | 19 +- .../NavigationAnimation.swift | 157 ++++++++++++++ Sources/CombineNavigation/Plugin.swift | 14 ++ .../CombineNavigation/RoutingController.swift | 36 ++-- .../Helpers/Operators.swift | 43 ---- .../RoutingControllerMacro.swift | 170 ++++++++++++--- .../RoutingControllerMacroTests.swift | 167 ++++++++++++--- .../Destinations/StackDestinationTests.swift | 140 +++++++++++++ .../TreeDestinationTests.swift} | 42 ++-- .../CombineNavigationTests/Helpers/wait.swift | 15 -- .../RoutingControllerTests.swift | 198 ++++++++++++++---- 25 files changed, 1180 insertions(+), 446 deletions(-) create mode 100644 .github/workflows/Test.yml create mode 100644 .spi.yml create mode 100644 Makefile delete mode 100644 Sources/CombineNavigation/Animation.swift delete mode 100644 Sources/CombineNavigation/CocoaViewController+.swift create mode 100644 Sources/CombineNavigation/CocoaViewController+API.swift create mode 100644 Sources/CombineNavigation/Destinations/StackDestination.swift rename Sources/CombineNavigation/{Destination.swift => Destinations/TreeDestination.swift} (87%) create mode 100644 Sources/CombineNavigation/Internal/CocoaViewController+.swift rename Sources/CombineNavigation/{Extensions => Internal/Helpers}/AnyCancellable+.swift (100%) rename Sources/CombineNavigation/{Extensions => Internal/Helpers}/NSObject+.swift (100%) rename Sources/CombineNavigation/{ => Internal}/NavigationRoute.swift (68%) rename Sources/CombineNavigation/{ => Internal}/UINavigationController+.swift (77%) create mode 100644 Sources/CombineNavigation/NavigationAnimation.swift create mode 100644 Sources/CombineNavigation/Plugin.swift delete mode 100644 Sources/CombineNavigationMacros/Helpers/Operators.swift create mode 100644 Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift rename Tests/CombineNavigationTests/{DestinationTests.swift => Destinations/TreeDestinationTests.swift} (56%) delete mode 100644 Tests/CombineNavigationTests/Helpers/wait.swift diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml new file mode 100644 index 0000000..42eb7a9 --- /dev/null +++ b/.github/workflows/Test.yml @@ -0,0 +1,22 @@ +name: test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test_macos: + if: | + !contains(github.event.head_commit.message, '[ci skip]') && + !contains(github.event.head_commit.message, '[ci skip test]') && + !contains(github.event.head_commit.message, '[ci skip test_macos]') + runs-on: macOS-12 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v3 + - name: Select Xcode 15.0.0 + run: sudo xcode-select -s /Applications/Xcode_15.0.0.app + - name: Run tests + run: make test diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..1601220 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +builder: + - platform: ios + documentation_targets: [CombineNavigation] + swift_version: 5.9 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..781fc7e --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +test: + @swift test diff --git a/Package.swift b/Package.swift index ea7f9c5..d32a8c8 100644 --- a/Package.swift +++ b/Package.swift @@ -6,12 +6,10 @@ import CompilerPluginSupport #warning("TODO: Add rich example") // The example is WIP, it's a simple twitter-like app // but already has examples for Tree-based and recursive Tree-based -// navigation. Stack-based navigation is planned +// navigation. Stack-based navigation and basic deeplinking is planned // // Do not forget to add it to repo before publishing a release ^^ -#warning("TODO: Add docc and publish it on SPI") - let package = Package( name: "combine-cocoa-navigation", platforms: [ @@ -51,7 +49,11 @@ let package = Package( .package( url: "https://github.com/pointfreeco/swift-macro-testing.git", .upToNextMinor(from: "0.2.0") - ) + ), + .package( + url: "https://github.com/apple/swift-docc-plugin.git", + .upToNextMajor(from: "1.3.0") + ), ], targets: [ .target( diff --git a/README.md b/README.md index 8e4422c..0c4a62a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![SwiftPM 5.9](https://img.shields.io/badge/swiftpm-5.9-ED523F.svg?style=flat)](https://github.com/CaptureContext/swift-declarative-configuration/actions/workflows/Test.yml) ![Platforms](https://img.shields.io/badge/platforms-iOS_13_|_macOS_11_|_tvOS_13_|_watchOS_6_|_Catalyst_13-ED523F.svg?style=flat) [![@capture_context](https://img.shields.io/badge/contact-@capture__context-1DA1F2.svg?style=flat&logo=twitter)](https://twitter.com/capture_context) -> This readme is draft and the branch is still an `alpha` version +> This readme is draft and the branch is still an `beta` version untill all [todos](#Coming soon) are resolved. ## Usage @@ -17,21 +17,22 @@ enum MyFeatureRoute { case details } +@RoutingController final class MyViewController: UIViewController { - // ... - + @TreeDestination + var detailsController: DetailsViewController? + func bindViewModel() { navigationDestination( - for viewModel.publisher(for: \.state.route), - switch: { [unowned self] route in + viewModel.publisher(for: \.state.route), + switch: destinations { destinations, route in switch route { case .details: - makeDetailsController() + destinations.$detailsController() } }, - onDismiss: { - // Update state on dismiss - viewModel.send(.dismiss) + onDismiss: capture { _self in + _self.viewModel.send(.dismiss) } ).store(in: &cancellables) } @@ -47,13 +48,14 @@ enum MyFeatureState { } final class MyViewController: UIViewController { - // ... + @TreeDestination + var detailsController: DetailsViewController? func bindViewModel() { navigationDestination( "my_feature_details" isPresented: viewModel.publisher(for: \.state.detais.isNotNil), - controller: { [unowned self] in makeDetailsController() }, + controller: destinations { $0.$detailsController() }, onDismiss: capture { $0.viewModel.send(.dismiss) } ).store(in: &cancellables) } @@ -71,25 +73,31 @@ enum MyFeatureState { case featureB(FeatureBState) } // ... - var navigationStack: [DestinationState] + var path: [DestinationState] } final class MyViewController: UIViewController { - // ... + @StackDestination + var featureAControllers: [Int: FeatureAController] + + @StackDestination + var featureBControllers: [Int: FeatureBController] func bindViewModel() { navigationStack( - for viewModel.publisher(for: \.state.navigationStack), - switch: { [unowned self] route in + viewModel.publisher(for: \.state.path), + switch: destinations { destinations, route, index in switch route { case .featureA: - makeFeatureAController() + destinations.$featureAControllers[index] case .featureB: - makeFeatureBController() + destinations.$featureBControllers[index] } }, - onDismiss: capture { _self, _ in - _self.viewModel.send(.dismiss) + onDismiss: capture { _self, indices in + // can be handled like `state.path.remove(atOffsets: IndexSet(indices))` + // should remove all requested indices before publishing an update + _self.viewModel.send(.dismiss(indices)) } ).store(in: &cancellables) } @@ -99,16 +107,18 @@ final class MyViewController: UIViewController { ### Notes and good practices - One controller should not manage navigation stack and navigation tree + + > The logic behind collecting controllers navigation stack uses same storage for `navigationStack`s and `navigationDestination`s, so it should not cause any crashes, but it will break the logic - Routing controller's parent should be navigationController -- Tips above will prevent you from making logical errors + + > It's just generally a good practice, but the package handles this corner case and everything should work fine ## Coming soon - Rich example - Readme update -- Docc documentation - Presentation helpers -- Tests (maybe 🤡) +- There are a few compiler todos to resolve ## Installation diff --git a/Sources/CombineNavigation/Animation.swift b/Sources/CombineNavigation/Animation.swift deleted file mode 100644 index 846bcf1..0000000 --- a/Sources/CombineNavigation/Animation.swift +++ /dev/null @@ -1,19 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -public func withNavigationAnimation( - enabled: Bool = true, - perform operation: () throws -> R -) rethrows -> R { - try NavigationAnimation.$enabled.withValue(enabled, operation: operation) -} - -public func withNavigationAnimation( - enabled: Bool = true, - perform operation: () async throws -> R -) async rethrows -> R { - try await NavigationAnimation.$enabled.withValue(enabled, operation: operation) -} - -internal enum NavigationAnimation { - @TaskLocal static var enabled: Bool = true -} -#endif diff --git a/Sources/CombineNavigation/CocoaViewController+.swift b/Sources/CombineNavigation/CocoaViewController+.swift deleted file mode 100644 index c961452..0000000 --- a/Sources/CombineNavigation/CocoaViewController+.swift +++ /dev/null @@ -1,179 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import Capture -import CocoaAliases -import CombineExtensions -import FoundationExtensions - -// MARK: - Public API - -// MARK: navigationStack - -extension CocoaViewController { - public func navigationStack< - P: Publisher, - C: Collection & Equatable, - Route: Hashable - >( - _ publisher: P, - switch controller: @escaping (Route) -> UIViewController, - onDismiss: @escaping (Route) -> Void - ) -> Cancellable where - P.Output == C, - P.Failure == Never, - C.Element == Route - { - publisher - .sinkValues(capture { _self, routes in - _self.__navigationStack = routes.map { route in - .init( - id: route, - controller: { controller(route) }, - onDismiss: { onDismiss(route) } - ) - } - }) - } - - public func navigationStack< - P: Publisher, - Stack, - IDs: Collection & Equatable, - Route - >( - _ publisher: P, - ids: @escaping (Stack) -> IDs, - route: @escaping (Stack, IDs.Element) -> Route?, - switch controller: @escaping (Route) -> UIViewController, - onDismiss: @escaping (IDs.Element) -> Void - ) -> Cancellable where - P.Output == Stack, - P.Failure == Never, - IDs.Element: Hashable - { - publisher - .sinkValues(capture { _self, stack in - _self.__navigationStack = ids(stack).compactMap { id in - route(stack, id).map { route in - .init( - id: id, - controller: { controller(route) }, - onDismiss: { onDismiss(id) } - ) - } - } - }) - } -} - -// MARK: navigationDestination - -extension CocoaViewController { - public func navigationDestination( - _ id: AnyHashable, - isPresented publisher: P, - controller: @escaping () -> CocoaViewController, - onDismiss: @escaping () -> Void - ) -> AnyCancellable where - P.Output == Bool, - P.Failure == Never - { - publisher - .sinkValues(capture { _self, isPresented in - _self.__navigationStack = isPresented ? [ - .init( - id: id, - controller: controller, - onDismiss: onDismiss - ) - ] : [] - }) - } - - public func navigationDestination( - _ publisher: P, - switch controller: @escaping (P.Output) -> CocoaViewController?, - onDismiss: @escaping () -> Void - ) -> AnyCancellable where - P.Output: Hashable & ExpressibleByNilLiteral, - P.Failure == Never - { - publisher - .sinkValues(capture { _self, route in - _self.__navigationStack = route == nil ? [] : [ - .init( - id: route, - controller: { controller(route) }, - onDismiss: onDismiss - ) - ] - }) - } -} - -// MARK: - Internal API - -extension CocoaViewController { - @AssociatedObject - internal var __navigationStack: [NavigationRoute] = [] { - didSet { requestNavigationStackSync() } - } - - internal func navigationStackControllers( - for navigation: UINavigationController - ) -> [CocoaViewController] { - __navigationStack.flatMap { route in - controller(for: route, in: navigation).map { controller in - [controller] + controller.navigationStackControllers(for: navigation) - }.or([]) - } - } -} - -// MARK: Configure managed route - -extension CocoaViewController { - @AssociatedObject - fileprivate var __dismissCancellables: [AnyHashable: AnyCancellable] = [:] - - fileprivate func controller( - for route: NavigationRoute, - in navigation: UINavigationController - ) -> CocoaViewController? { - return route.controller().map { controller in - navigation - .dismissPublisher(for: controller) - .sinkValues(capture { _self in - _self.__dismissCancellables - .removeValue(forKey: route.id)? - .cancel() - return route.onDismiss() - }) - .store(for: route.id, in: &__dismissCancellables) - return controller - } - } -} - -// MARK: Sync navigation stack - -extension CocoaViewController { - @AssociatedObject - fileprivate var __navigationControllerCancellable: AnyCancellable? - - fileprivate func requestNavigationStackSync() { - if let navigationController { - syncNavigationStack(using: navigationController) - } else { - publisher(for: \.navigationController) - .compactMap { $0 } - .sinkValues(capture { $0.syncNavigationStack(using: $1) }) - .store(in: &__navigationControllerCancellable) - } - } - - fileprivate func syncNavigationStack(using navigation: UINavigationController) { - __navigationControllerCancellable = nil - navigation.syncNavigationStack(for: self) - } -} -#endif diff --git a/Sources/CombineNavigation/CocoaViewController+API.swift b/Sources/CombineNavigation/CocoaViewController+API.swift new file mode 100644 index 0000000..d4dc228 --- /dev/null +++ b/Sources/CombineNavigation/CocoaViewController+API.swift @@ -0,0 +1,128 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import CombineExtensions +import FoundationExtensions + +// MARK: - Public API + +// MARK: navigationStack + +extension CocoaViewController { + /// Subscribes on publisher of navigation stack state + public func navigationStack< + P: Publisher, + C: Collection & Equatable, + Route: Hashable + >( + _ publisher: P, + switch controller: @escaping (Route, C.Index) -> CocoaViewController, + onDismiss: @escaping (Set) -> Void + ) -> Cancellable where + P.Output == C, + P.Failure == Never, + C.Element == Route, + C.Index: Hashable, + C.Indices: Equatable + { + navigationStack( + publisher, + ids: \.indices, + route: { ($0[$1], $1) }, + switch: controller, + onDismiss: onDismiss + ) + } + + /// Subscribes on publisher of navigation stack state + public func navigationStack< + P: Publisher, + Stack, + IDs: Collection & Equatable, + Route + >( + _ publisher: P, + ids: @escaping (Stack) -> IDs, + route: @escaping (Stack, IDs.Element) -> Route?, + switch controller: @escaping (Route) -> CocoaViewController, + onDismiss: @escaping (Set) -> Void + ) -> Cancellable where + P.Output == Stack, + P.Failure == Never, + IDs.Element: Hashable + { + publisher + .sinkValues(capture { _self, stack in + _self.updateNavigationStack( + ids(stack).compactMap { id in + route(stack, id).map { route in + .init( + id: id, + controller: { controller(route) } + ) + } + }, + onDismiss: _self.capture { _self in + onDismiss(Set( + _self.routesToDismiss().compactMap { $0.id as? IDs.Element } + )) + } + ) + }) + } +} + +// MARK: navigationDestination + +extension CocoaViewController { + /// Subscribes on publisher of navigation destination state + public func navigationDestination( + _ id: AnyHashable, + isPresented publisher: P, + controller: @escaping () -> CocoaViewController, + onDismiss: @escaping () -> Void + ) -> AnyCancellable where + P.Output == Bool, + P.Failure == Never + { + navigationDestination( + publisher.map { isPresented in + isPresented ? id : nil + }, + switch: { id in + id.map { _ in controller() } + }, + onDismiss: { + onDismiss() + } + ) + } + + /// Subscribes on publisher of navigation destination state + public func navigationDestination( + _ publisher: P, + switch controller: @escaping (P.Output) -> CocoaViewController?, + onDismiss: @escaping () -> Void + ) -> AnyCancellable where + P.Output: Hashable & ExpressibleByNilLiteral, + P.Failure == Never + { + publisher + .sinkValues(capture { _self, route in + _self.updateNavigationStack( + route == nil ? [] : [ + NavigationRoute( + id: route, + controller: { controller(route) } + ) + ], + onDismiss: { [weak self] in + guard let self, self.routesToDismiss().contains(where: { $0.id == route as AnyHashable }) + else { return } + onDismiss() + } + ) + }) + } +} +#endif diff --git a/Sources/CombineNavigation/Destinations/StackDestination.swift b/Sources/CombineNavigation/Destinations/StackDestination.swift new file mode 100644 index 0000000..af10dad --- /dev/null +++ b/Sources/CombineNavigation/Destinations/StackDestination.swift @@ -0,0 +1,67 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +/// Wrapper for creating and accessing managed navigation stack controllers +@propertyWrapper +open class StackDestination< + StackElementID: Hashable, + Controller: CocoaViewController +>: Weakifiable { + private var _controllers: [StackElementID: Weak] = [:] + public var wrappedValue: [StackElementID: Controller] { + let controllers = _controllers.compactMapValues(\.wrappedValue) + _controllers = controllers.mapValues(Weak.init(wrappedValue:)) + return controllers + } + public var projectedValue: StackDestination { self } + + private var _initControllerOverride: (() -> Controller)? + + /// Sets instance-specific override for creating a new controller + /// + /// This override has the highest priority when creating a new controller + /// + /// To disable isntance-specific override pass `nil` to this method + public func overrideInitController( + with closure: (() -> Controller)? + ) { + _initControllerOverride = closure + } + + @_spi(Internals) public class func initController() -> Controller { + return Controller() + } + + @_spi(Internals) open func configureController(_ controller: Controller) {} + + /// Creates a new instance + public init() {} + + /// Creates a new instance with instance-specific override for creating a new controller + /// + /// This override has the highest priority when creating a new controller, default one is just `Controller()` + /// **which can lead to crashes if controller doesn't have an empty init** + /// + /// Default implementation is suitable for most controllers, however if you have a controller which + /// doesn't have a custom init you'll have to use this method or if you have a base controller that + /// requires custom init it'll be beneficial for you to create a custom subclass of StackDestination + /// and override it's `initController` class method, you can find an example in tests. + convenience init(_ initControllerOverride: @escaping () -> Controller) { + self.init() + self._initControllerOverride = initControllerOverride + } + + /// Returns `wrappedValue[id]` if present, intializes and configures a new instance otherwise + public subscript(_ id: StackElementID) -> Controller { + return wrappedValue[id] ?? { + let controller = _initControllerOverride?() ?? Self.initController() + configureController(controller) + _controllers[id] = Weak(controller) + return controller + }() + } +} +#endif diff --git a/Sources/CombineNavigation/Destination.swift b/Sources/CombineNavigation/Destinations/TreeDestination.swift similarity index 87% rename from Sources/CombineNavigation/Destination.swift rename to Sources/CombineNavigation/Destinations/TreeDestination.swift index e21c150..ef5f936 100644 --- a/Sources/CombineNavigation/Destination.swift +++ b/Sources/CombineNavigation/Destinations/TreeDestination.swift @@ -4,14 +4,15 @@ import CocoaAliases import Combine import FoundationExtensions +/// Wrapper for creating and accessing managed navigation destination controller @propertyWrapper -open class Destination: Weakifiable { +open class TreeDestination: Weakifiable { private weak var _controller: Controller? public var wrappedValue: Controller? { _controller } - public var projectedValue: Destination { self } - + public var projectedValue: TreeDestination { self } + private var _initControllerOverride: (() -> Controller)? - + /// Sets instance-specific override for creating a new controller /// /// This override has the highest priority when creating a new controller @@ -22,16 +23,16 @@ open class Destination: Weakifiable { ) { _initControllerOverride = closure } - + @_spi(Internals) public class func initController() -> Controller { return Controller() } - + @_spi(Internals) open func configureController(_ controller: Controller) {} - + /// Creates a new instance public init() {} - + /// Creates a new instance with instance-specific override for creating a new controller /// /// This override has the highest priority when creating a new controller, default one is just `Controller()` @@ -39,13 +40,13 @@ open class Destination: Weakifiable { /// /// Default implementation is suitable for most controllers, however if you have a controller which /// doesn't have a custom init you'll have to use this method or if you have a base controller that - /// requires custom init it'll be beneficial for you to create a custom subclass of Destination + /// requires custom init it'll be beneficial for you to create a custom subclass of TreeDestination /// and override it's `initController` class method, you can find an example in tests. convenience init(_ initControllerOverride: @escaping () -> Controller) { self.init() self._initControllerOverride = initControllerOverride } - + /// Returns wrappedValue if present, intializes and configures a new instance otherwise public func callAsFunction() -> Controller { wrappedValue ?? { diff --git a/Sources/CombineNavigation/Internal/CocoaViewController+.swift b/Sources/CombineNavigation/Internal/CocoaViewController+.swift new file mode 100644 index 0000000..00b0226 --- /dev/null +++ b/Sources/CombineNavigation/Internal/CocoaViewController+.swift @@ -0,0 +1,104 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import CombineExtensions +import FoundationExtensions + +// MARK: Get/set managed routes + +extension CocoaViewController { + @AssociatedObject + private var __navigationStack: [NavigationRoute] = [] + + @AssociatedObject + fileprivate var __onDestinationDismiss: (() -> Void)? + + @AssociatedObject + fileprivate var __destinationDismissCancellable: AnyCancellable? + + internal func routesToDismiss() -> [NavigationRoute] { + guard let controllers = navigationController?.viewControllers + else { return [] } + + return __navigationStack.filter { route in + !controllers.contains { $0.objectID == route.controllerID } + } + } + + internal func updateNavigationStack( + _ navigationStack: [NavigationRoute], + onDismiss: @escaping () -> Void + ) { + __onDestinationDismiss = onDismiss + __navigationStack = navigationStack + requestNavigationStackSync() + } + + internal func navigationStackControllers( + for navigation: UINavigationController + ) -> [CocoaViewController] { + let navigationStack = __navigationStack + return zip(navigationStack, navigationStack.indices).flatMap { route, index in + controller(for: route, in: navigation).map { controller in + __navigationStack[index].controllerID = controller.objectID + return [controller] + controller.navigationStackControllers(for: navigation) + }.or([]) + } + } +} + +// MARK: Configure managed route + +extension CocoaViewController { + @AssociatedObject + fileprivate var __dismissCancellables: [AnyHashable: AnyCancellable] = [:] + + fileprivate func controller( + for route: NavigationRoute, + in navigation: UINavigationController + ) -> CocoaViewController? { + return route.controller().map { controller in +// navigation +// .dismissPublisher(for: self) +// .sinkValues(capture { _self in +// _self.__dismissCancellables +// .removeValue(forKey: route.id)? +// .cancel() +// return route.onDismiss() +// }) +// .store(for: route.id, in: &__dismissCancellables) + return controller + } + } +} + +// MARK: Sync navigation stack + +extension CocoaViewController { + @AssociatedObject + fileprivate var __navigationControllerCancellable: AnyCancellable? + + fileprivate func requestNavigationStackSync() { + if let navigationController { + syncNavigationStack(using: navigationController) + } else { + publisher(for: \.navigationController) + .compactMap { $0 } + .sinkValues(capture { $0.syncNavigationStack(using: $1) }) + .store(in: &__navigationControllerCancellable) + } + } + + fileprivate func syncNavigationStack(using navigation: UINavigationController) { + __navigationControllerCancellable = nil + + navigation.dismissPublisher() + .sinkValues(capture { _self in + _self.__onDestinationDismiss?() + }) + .store(in: &__destinationDismissCancellable) + + navigation.syncNavigationStack(for: self) + } +} +#endif diff --git a/Sources/CombineNavigation/Extensions/AnyCancellable+.swift b/Sources/CombineNavigation/Internal/Helpers/AnyCancellable+.swift similarity index 100% rename from Sources/CombineNavigation/Extensions/AnyCancellable+.swift rename to Sources/CombineNavigation/Internal/Helpers/AnyCancellable+.swift diff --git a/Sources/CombineNavigation/Extensions/NSObject+.swift b/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift similarity index 100% rename from Sources/CombineNavigation/Extensions/NSObject+.swift rename to Sources/CombineNavigation/Internal/Helpers/NSObject+.swift diff --git a/Sources/CombineNavigation/NavigationRoute.swift b/Sources/CombineNavigation/Internal/NavigationRoute.swift similarity index 68% rename from Sources/CombineNavigation/NavigationRoute.swift rename to Sources/CombineNavigation/Internal/NavigationRoute.swift index 4d13704..c3896db 100644 --- a/Sources/CombineNavigation/NavigationRoute.swift +++ b/Sources/CombineNavigation/Internal/NavigationRoute.swift @@ -3,6 +3,14 @@ import CocoaAliases import FoundationExtensions import Combine +struct NavigationRouteIndexedID< + Route: Hashable, + Index: Hashable +>: Hashable { + let route: Route + let index: Index +} + struct NavigationRoute: Hashable, Equatable, Identifiable { static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id @@ -13,17 +21,15 @@ struct NavigationRoute: Hashable, Equatable, Identifiable { } let id: AnyHashable + var controllerID: ObjectIdentifier? let controller: () -> CocoaViewController? - let onDismiss: () -> Void init( id: AnyHashable, - controller: @escaping () -> CocoaViewController?, - onDismiss: @escaping () -> Void + controller: @escaping () -> CocoaViewController? ) { self.id = id self.controller = controller - self.onDismiss = onDismiss } } #endif diff --git a/Sources/CombineNavigation/UINavigationController+.swift b/Sources/CombineNavigation/Internal/UINavigationController+.swift similarity index 77% rename from Sources/CombineNavigation/UINavigationController+.swift rename to Sources/CombineNavigation/Internal/UINavigationController+.swift index b1c79bf..d9afb57 100644 --- a/Sources/CombineNavigation/UINavigationController+.swift +++ b/Sources/CombineNavigation/Internal/UINavigationController+.swift @@ -5,26 +5,21 @@ import Combine import FoundationExtensions extension UINavigationController { - internal func dismissPublisher(for controller: CocoaViewController) -> some Publisher { - let controllerID = controller.objectID + internal func dismissPublisher() -> some Publisher { return Publishers.Merge3( - publisher(for: #selector(UINavigationController.popViewController)), - publisher(for: #selector(UINavigationController.popToViewController)), - publisher(for: #selector(UINavigationController.popToRootViewController)) + publisher(for: #selector(UINavigationController.popViewController)).map { print("pop") }, + publisher(for: #selector(UINavigationController.popToViewController)).map { print("popTo") }, + publisher(for: #selector(UINavigationController.popToRootViewController)).map { print("popToRoot") } ) .flatMap { [weak self] in Future { promise in guard let self else { return promise(.success(false)) } - func controllerIsNotInStack() -> Bool { - !self.viewControllers.contains { $0.objectID == controllerID } - } - guard let transitionCoordinator = self.transitionCoordinator - else { return promise(.success(controllerIsNotInStack())) } // Handle programmatic pop + else { return promise(.success(true)) } // Handle non-interactive pop // Handle interactive pop if not cancelled transitionCoordinator.animate(alongsideTransition: nil) { context in - promise(.success(!context.isCancelled && controllerIsNotInStack())) + promise(.success(context.isCancelled)) } }} .filter { $0 } @@ -58,7 +53,7 @@ extension UINavigationController { // valid push/pop animation, unmanaged controllers are thrown away setViewControllers( navigationStack, - animated: NavigationAnimation.$enabled.get() + animated: NavigationAnimation.$isEnabled.get() ) } } diff --git a/Sources/CombineNavigation/NavigationAnimation.swift b/Sources/CombineNavigation/NavigationAnimation.swift new file mode 100644 index 0000000..6dd9fe3 --- /dev/null +++ b/Sources/CombineNavigation/NavigationAnimation.swift @@ -0,0 +1,157 @@ +#if canImport(UIKit) && !os(watchOS) +import Combine + +/// Disables navigation animations for the duration of the synchronous operation. +/// +/// Basically a convenience function for calling ``withNavigationAnimation(_:perform:file:line:)-76iad`` +@discardableResult +public func withoutNavigationAnimation( + perform operation: () throws -> R, + file: String = #fileID, + line: UInt = #line +) rethrows -> R { + try withNavigationAnimation(false, perform: operation) +} + +/// Disables navigation animations for the duration of the asynchronous operation. +/// +/// Basically a convenience function for calling ``withNavigationAnimation(_:perform:file:line:)-76iad`` +@discardableResult +public func withoutNavigationAnimation( + perform operation: () async throws -> R, + file: String = #fileID, + line: UInt = #line +) async rethrows -> R { + try await withNavigationAnimation(false, perform: operation) +} + +/// Binds task-local NavigationAnimation.isEnabled to the specific value for the duration of the synchronous operation. +/// +/// See [TaskLocal.withValue](https://developer.apple.com/documentation/swift/tasklocal/withvalue(_:operation:file:line:)-79atg) +/// for more details +@discardableResult +public func withNavigationAnimation( + _ enabled: Bool = true, + perform operation: () throws -> R, + file: String = #fileID, + line: UInt = #line +) rethrows -> R { + try NavigationAnimation.$isEnabled.withValue( + enabled, + operation: operation, + file: file, + line: line + ) +} + +/// Binds task-local NavigationAnimation.isEnabled to the specific value for the duration of the asynchronous operation. +/// +/// See [TaskLocal.withValue](https://developer.apple.com/documentation/swift/tasklocal/withvalue(_:operation:file:line:)-1xjor) +/// for more details +@discardableResult +public func withNavigationAnimation( + _ enabled: Bool = true, + perform operation: () async throws -> R, + file: String = #fileID, + line: UInt = #line +) async rethrows -> R { + try await NavigationAnimation.$isEnabled.withValue( + enabled, + operation: operation, + file: file, + line: line + ) +} + +internal enum NavigationAnimation { + @TaskLocal static var isEnabled: Bool = true +} + +extension Publisher { + /// Wraps Subscriber.receive calls in ``withNavigationAnimation(_:perform:file:line:)-76iad`` + /// + /// Basically a convenience method for calling ``withNavigationAnimation(_:file:line:)`` + public func withoutNavigationAnimation( + file: String = #fileID, + line: UInt = #line + ) -> some Publisher { + return withNavigationAnimation( + false, + file: file, + line: line + ) + } + + /// Wraps Subscriber.receive calls in ``withNavigationAnimation(_:perform:file:line:)-76iad`` + public func withNavigationAnimation( + _ enabled: Bool = true, + file: String = #fileID, + line: UInt = #line + ) -> some Publisher { + return NavigationAnimationPublisher( + upstream: self, + isNavigationAnimationEnabled: enabled, + file: file, + line: line + ) + } +} + +private struct NavigationAnimationPublisher: Publisher { + typealias Output = Upstream.Output + typealias Failure = Upstream.Failure + + var upstream: Upstream + var isNavigationAnimationEnabled: Bool + var file: String + var line: UInt + + func receive(subscriber: S) + where S.Input == Output, S.Failure == Failure { + let conduit = Subscriber( + downstream: subscriber, + isNavigationAnimationEnabled: isNavigationAnimationEnabled + ) + self.upstream.receive(subscriber: conduit) + } + + private final class Subscriber: Combine.Subscriber { + typealias Input = Downstream.Input + typealias Failure = Downstream.Failure + + let downstream: Downstream + let isNavigationAnimationEnabled: Bool + var file: String + var line: UInt + + init( + downstream: Downstream, + isNavigationAnimationEnabled: Bool, + file: String = #fileID, + line: UInt = #line + ) { + self.downstream = downstream + self.isNavigationAnimationEnabled = isNavigationAnimationEnabled + self.file = file + self.line = line + } + + func receive(subscription: Subscription) { + self.downstream.receive(subscription: subscription) + } + + func receive(_ input: Input) -> Subscribers.Demand { + CombineNavigation.withNavigationAnimation( + isNavigationAnimationEnabled, + perform: { self.downstream.receive(input) }, + file: file, + line: line + ) + } + + func receive(completion: Subscribers.Completion) { + self.downstream.receive(completion: completion) + } + } +} +#endif diff --git a/Sources/CombineNavigation/Plugin.swift b/Sources/CombineNavigation/Plugin.swift new file mode 100644 index 0000000..a6be8b4 --- /dev/null +++ b/Sources/CombineNavigation/Plugin.swift @@ -0,0 +1,14 @@ +#if canImport(UIKit) && !os(watchOS) +import CocoaAliases +import CombineNavigationMacros + +@attached( + extension, + conformances: RoutingController, + names: named(Destinations), named(_makeDestinations()) +) +public macro RoutingController() = #externalMacro( + module: "CombineNavigationMacros", + type: "RoutingControllerMacro" +) +#endif diff --git a/Sources/CombineNavigation/RoutingController.swift b/Sources/CombineNavigation/RoutingController.swift index bac0ecd..ea0c3cb 100644 --- a/Sources/CombineNavigation/RoutingController.swift +++ b/Sources/CombineNavigation/RoutingController.swift @@ -1,28 +1,18 @@ +#if canImport(UIKit) && !os(watchOS) import CocoaAliases -import CombineNavigationMacros -@attached( - extension, - conformances: RoutingControllerProtocol, - names: named(Destinations), named(_makeDestinations()) -) -public macro RoutingController() = #externalMacro( - module: "CombineNavigationMacros", - type: "RoutingControllerMacro" -) - -public protocol RoutingControllerProtocol: CocoaViewController { +public protocol RoutingController: CocoaViewController { associatedtype Destinations func _makeDestinations() -> Destinations } -extension RoutingControllerProtocol { - private static func _mapNavigationDestinations( +extension RoutingController { + private static func _mapNavigationDestinations( _ destinations: Destinations, - _ mapping: @escaping (Destinations, Route) -> Output - ) -> (Route) -> Output { - return { route in - mapping(destinations, route) + _ mapping: @escaping (Destinations, repeat each Arg) -> Output + ) -> (repeat each Arg) -> Output { + return { (arg: repeat each Arg) in + mapping(destinations, repeat each arg) } } @@ -44,4 +34,14 @@ extension RoutingControllerProtocol { mapping ) } + + public func destinations( + _ mapping: @escaping (Destinations, Route, Int) -> CocoaViewController + ) -> (Route, Int) -> CocoaViewController { + Self._mapNavigationDestinations( + _makeDestinations(), + mapping + ) + } } +#endif diff --git a/Sources/CombineNavigationMacros/Helpers/Operators.swift b/Sources/CombineNavigationMacros/Helpers/Operators.swift deleted file mode 100644 index 5623103..0000000 --- a/Sources/CombineNavigationMacros/Helpers/Operators.swift +++ /dev/null @@ -1,43 +0,0 @@ -func ==( - lhs: KeyPath, - rhs: B -) -> (A) -> Bool { - return { a in - a[keyPath: lhs] == rhs - } -} - -func !=( - lhs: KeyPath, - rhs: B -) -> (A) -> Bool { - return { a in - a[keyPath: lhs] != rhs - } -} - -func ||( - lhs: @escaping (A) -> Bool, - rhs: @escaping (A) -> Bool -) -> (A) -> Bool { - return { a in - lhs(a) || rhs(a) - } -} - -func &&( - lhs: @escaping (A) -> Bool, - rhs: @escaping (A) -> Bool -) -> (A) -> Bool { - return { a in - lhs(a) && rhs(a) - } -} - -prefix func !( - f: @escaping (A) -> Bool -) -> (A) -> Bool { - return { a in - !f(a) - } -} diff --git a/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift index d4d66c4..57c3262 100644 --- a/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift +++ b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift @@ -5,7 +5,7 @@ import SwiftSyntaxMacros public struct RoutingControllerMacro { static let moduleName = "CombineNavigation" - static let conformanceName = "RoutingControllerProtocol" + static let conformanceName = "RoutingController" static var qualifiedConformanceName: String { "\(Self.moduleName).\(Self.conformanceName)" } static var conformanceNames: [String] { [Self.conformanceName, Self.qualifiedConformanceName] } } @@ -39,44 +39,144 @@ extension RoutingControllerMacro: ExtensionMacro { return decl.cast(ExtensionDeclSyntax.self) }() - extensionDecl.memberBlock = MemberBlockSyntax( - members: MemberBlockItemListSyntax( - makeExtensionMembers( - from: navigationChildren(for: decl) + do { + extensionDecl.memberBlock = MemberBlockSyntax( + members: MemberBlockItemListSyntax( + try makeDeclarationsExtensionMembers(for: decl) ) ) - ) + } catch { + return (error as! DiagnosticsError).diagnostics.first.map { diagnostic in + context.diagnose(diagnostic, return: [ExtensionDeclSyntax]()) + } ?? [] + } return [extensionDecl] } - static func navigationChildren(for declaration: ClassDeclSyntax) -> [MemberBlockItemSyntax] { - declaration.memberBlock.members.compactMap { member in - guard let variable = member.decl.as(VariableDeclSyntax.self) - else { return nil } - - let isNavigationChild = variable.attributes.contains { attribute in - guard let attribute = attribute.as(AttributeSyntax.self) - else { return false } - return attribute.attributeName.description.hasSuffix("Destination") + /// Creates an array of MemberBlockItemSyntax from + static func makeDeclarationsExtensionMembers( + for declaration: ClassDeclSyntax + ) throws -> [MemberBlockItemSyntax] { + let navigationDestinations: [Variable] = declaration.memberBlock.members + .compactMap { member in + guard let variable = Variable(member.decl) + else { return nil } + + let isNavigationChild = variable.attributes.contains { attribute in + switch attribute { + case let .attribute(attribute) where attribute.name.name.hasSuffix("Destination"): + return true + default: + return false + } + } + + return isNavigationChild ? variable : nil } - return isNavigationChild ? member : nil - } - } - - static func makeExtensionMembers( - from navigationChildren: [MemberBlockItemSyntax] - ) -> [MemberBlockItemSyntax] { let destinationStructDocComment: String = """ /// Container for captured destinations without referring to self /// - /// > Generated by `CombineNavigation.RoutingController` macro + /// > Generated by `CombineNavigationMacros.RoutingController` macro /// /// Use in `navigationDestination`/`navigationStack` methods to map /// routes to specific destinations using `destinations` method """ + if navigationDestinations.isEmpty { + var destinationsStructDecl: DeclSyntax = """ + \(raw: destinationStructDocComment) + public struct Destinations { + public subscript(_ id: some Hashable) -> CocoaViewController? { + return nil + } + } + """ + + destinationsStructDecl.trailingTrivia = .newlines(2) + + let makeDestinationsFuncDecl: DeclSyntax = """ + public func _makeDestinations() -> Destinations { + return Destinations() + } + """ + + return [ + MemberBlockItemSyntax(decl: destinationsStructDecl), + MemberBlockItemSyntax(decl: makeDestinationsFuncDecl) + ] + } + + var destinationsStructMembers = navigationDestinations + .map(\._syntax) + .map { + var decl = MemberBlockItemSyntax(decl: $0) + decl.trailingTrivia = .newlines(1) + return decl + } + + + typealias StackDestination = (identifier: String, idType: String) + let stackDestinations: [StackDestination] = try navigationDestinations.compactMap { decl in + guard + let attribute = decl.attributes.first?.attribute, + attribute.name.name.hasSuffix("StackDestination"), + let identifier = decl.bindings.first?.identifier + else { return StackDestination?.none } + + let idType: String = if let genericArgument = attribute.name.genericArguments?.first { + genericArgument.description + } else if let typeDecl = decl.bindings.first?.type?._syntax.as(DictionaryTypeSyntax.self) { + typeDecl.key.description + } else { + throw DiagnosticsError(diagnostics: [ + .requiresDictionaryLiteralForStackDestination(attribute._syntax) + ]) + } + + return StackDestination(identifier, idType) + } + + + + if let firstStackDestination = stackDestinations.first { + let erasedIDType = "some Hashable" + let commonIDType = stackDestinations.allSatisfy { destination in + destination.idType == firstStackDestination.idType + } ? firstStackDestination.idType : nil + + var stackDestinationsCoalecing: String { + let subscriptCall = commonIDType.map { _ in "[id]" } ?? "[id as! $0.idType]" + return stackDestinations + .map { "\($0.identifier)\(subscriptCall)" } + .joined(separator: "\n\t?? ") + } + + let inputType = commonIDType ?? erasedIDType + + let destinationsStructSubscriptDecl: DeclSyntax = + """ + \npublic subscript(_ id: \(raw: inputType)) -> CocoaViewController? { + return \(raw: stackDestinationsCoalecing) + } + """ + + destinationsStructMembers.append(MemberBlockItemSyntax( + decl: destinationsStructSubscriptDecl + )) + } else { + let destinationsStructSubscriptDecl: DeclSyntax = """ + \npublic subscript(_ id: some Hashable) -> CocoaViewController? { + return nil + } + """ + + destinationsStructMembers.append(MemberBlockItemSyntax( + decl: destinationsStructSubscriptDecl + )) + } + let destinationsStructDecl = StructDeclSyntax( leadingTrivia: Trivia.init( pieces: destinationStructDocComment @@ -87,19 +187,23 @@ extension RoutingControllerMacro: ExtensionMacro { DeclModifierSyntax(name: "public") }, name: "Destinations", - memberBlock: MemberBlockSyntax(members: MemberBlockItemListSyntax(navigationChildren)), + memberBlock: MemberBlockSyntax( + members: MemberBlockItemListSyntax( + destinationsStructMembers + ) + ), trailingTrivia: .newlines(2) ).as(DeclSyntax.self) - let destinationsInitParams = navigationChildren.compactMap { member in - Variable(member.decl)?.bindings.first?.identifier - }.map { identifier in + let destinationsInitParams = navigationDestinations.compactMap( + \.bindings.first?.identifier + ).map { identifier in "\(identifier): $\(identifier)" }.joined(separator: ",\n") let makeDestinationsFuncDecl: DeclSyntax = """ public func _makeDestinations() -> Destinations { - Destinations( + return Destinations( \(raw: destinationsInitParams) ) } @@ -133,4 +237,14 @@ fileprivate extension Diagnostic { _requiresClassDeclaration(node) }() } + + static func requiresDictionaryLiteralForStackDestination(_ node: AttributeSyntax) -> Self { + DiagnosticBuilder(for: node) + .messageID(domain: "RoutingController", id: "requires_dictionary_literal_for_stack_destination") + .message(""" + `@StackDestination` requires explicit wrapper type \ + or dictionary type literal declaration for value. + """) + .build() + } } diff --git a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift index f8a94a2..f3c695e 100644 --- a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift +++ b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift @@ -2,7 +2,7 @@ import XCTest import MacroTesting import CombineNavigationMacros -final class RoutingControllerTests: XCTestCase { +final class RoutingControllerMacroTests: XCTestCase { override func invokeTest() { withMacroTesting( isRecording: false, @@ -60,41 +60,77 @@ final class RoutingControllerTests: XCTestCase { } func testAttachmentToClass() { + assertMacro { + """ + @RoutingController + final class CustomController {} + """ + } expansion: { + """ + final class CustomController {} + + extension CustomController: CombineNavigation.RoutingController { + /// Container for captured destinations without referring to self + /// + /// > Generated by `CombineNavigationMacros.RoutingController` macro + /// + /// Use in `navigationDestination`/`navigationStack` methods to map + /// routes to specific destinations using `destinations` method + public struct Destinations { + public subscript(_ id: some Hashable) -> CocoaViewController? { + return nil + } + } + + public func _makeDestinations() -> Destinations { + return Destinations() + } + } + """ + } + } + + func testAttachmentToClass_TreeDestinations() { assertMacro { """ @RoutingController final class CustomController { - @Destination - var firstDetailController: UIViewController? - @Destination - var secondDetailController: UIViewController? + @TreeDestination + var firstDetailController: CocoaViewController? + @TreeDestination + var secondDetailController: CocoaViewController? } """ } expansion: { """ final class CustomController { - @Destination - var firstDetailController: UIViewController? - @Destination - var secondDetailController: UIViewController? + @TreeDestination + var firstDetailController: CocoaViewController? + @TreeDestination + var secondDetailController: CocoaViewController? } - extension CustomController: CombineNavigation.RoutingControllerProtocol { + extension CustomController: CombineNavigation.RoutingController { /// Container for captured destinations without referring to self /// - /// > Generated by `CombineNavigation.RoutingController` macro + /// > Generated by `CombineNavigationMacros.RoutingController` macro /// /// Use in `navigationDestination`/`navigationStack` methods to map /// routes to specific destinations using `destinations` method public struct Destinations { - @Destination - var firstDetailController: UIViewController? - @Destination - var secondDetailController: UIViewController? + @TreeDestination + var firstDetailController: CocoaViewController? + + @TreeDestination + var secondDetailController: CocoaViewController? + + public subscript(_ id: some Hashable) -> CocoaViewController? { + return nil + } } public func _makeDestinations() -> Destinations { - Destinations( + return Destinations( firstDetailController: $firstDetailController, secondDetailController: $secondDetailController ) @@ -104,42 +140,121 @@ final class RoutingControllerTests: XCTestCase { } } - func testCustomDeclaration() { + func testAttachmentToClass_StackDestinations() { + assertMacro { + """ + @RoutingController + final class CustomController { + @StackDestination + var firstDetailController + @StackDestination + var secondDetailController: [Int: CocoaViewController] + } + """ + } expansion: { + """ + final class CustomController { + @StackDestination + var firstDetailController + @StackDestination + var secondDetailController: [Int: CocoaViewController] + } + + extension CustomController: CombineNavigation.RoutingController { + /// Container for captured destinations without referring to self + /// + /// > Generated by `CombineNavigationMacros.RoutingController` macro + /// + /// Use in `navigationDestination`/`navigationStack` methods to map + /// routes to specific destinations using `destinations` method + public struct Destinations { + @StackDestination + var firstDetailController + + @StackDestination + var secondDetailController: [Int: CocoaViewController] + + public subscript(_ id: Int) -> CocoaViewController? { + return firstDetailController[id] + ?? secondDetailController[id] + } + } + + public func _makeDestinations() -> Destinations { + return Destinations( + firstDetailController: $firstDetailController, + secondDetailController: $secondDetailController + ) + } + } + """ + } + } + + // Specifying wrappedValue type expricitly is supported only + // for DictionaryType literal + func testAttachmentToClass_StackDestinations_DictionaryType() { + assertMacro { + """ + @RoutingController + final class CustomController { + @StackDestination + var firstDetailController: Dictionary + } + """ + } diagnostics: { + """ + @RoutingController + final class CustomController { + @StackDestination + ╰─ 🛑 `@StackDestination` requires explicit wrapper type or dictionary type literal declaration for value. + var firstDetailController: Dictionary + } + """ + } + } + + func testAttachmentToClass_CustomDestinations() { assertMacro { """ @RoutingController final class CustomController { @CustomDestination - var firstDetailController: UIViewController? + var firstDetailController: CocoaViewController? @CustomDestination - var secondDetailController: UIViewController? + var secondDetailController: CocoaViewController? } """ } expansion: { """ final class CustomController { @CustomDestination - var firstDetailController: UIViewController? + var firstDetailController: CocoaViewController? @CustomDestination - var secondDetailController: UIViewController? + var secondDetailController: CocoaViewController? } - extension CustomController: CombineNavigation.RoutingControllerProtocol { + extension CustomController: CombineNavigation.RoutingController { /// Container for captured destinations without referring to self /// - /// > Generated by `CombineNavigation.RoutingController` macro + /// > Generated by `CombineNavigationMacros.RoutingController` macro /// /// Use in `navigationDestination`/`navigationStack` methods to map /// routes to specific destinations using `destinations` method public struct Destinations { @CustomDestination - var firstDetailController: UIViewController? + var firstDetailController: CocoaViewController? + @CustomDestination - var secondDetailController: UIViewController? + var secondDetailController: CocoaViewController? + + public subscript(_ id: some Hashable) -> CocoaViewController? { + return nil + } } public func _makeDestinations() -> Destinations { - Destinations( + return Destinations( firstDetailController: $firstDetailController, secondDetailController: $secondDetailController ) diff --git a/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift b/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift new file mode 100644 index 0000000..5b8a314 --- /dev/null +++ b/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift @@ -0,0 +1,140 @@ +import XCTest +import CocoaAliases +@_spi(Internals) @testable import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) +final class StackDestinationTests: XCTestCase { + func testMain() { + @StackDestination + var sut: [AnyHashable: CustomViewController] + + @StackDestination({ .init(value: 1) }) + var configuredSUT: [AnyHashable: CustomViewController] + + var mergedNavigationStack: [CustomViewController] = [] + + var navigationStackState: [Int] = [] + func peekStackID() -> Int? { navigationStackState.indices.last } + + do { navigationStackState.append(0) // add first + mergedNavigationStack.append(_sut[peekStackID()]) + + XCTAssertNotEqual(sut[peekStackID()], nil) + XCTAssertEqual(configuredSUT[peekStackID()], nil) + + XCTAssert(_sut[peekStackID()] === sut[peekStackID()]) + XCTAssert(_sut[peekStackID()] === mergedNavigationStack.last) + XCTAssert(_configuredSUT[peekStackID()] !== mergedNavigationStack.last) + } + + do { navigationStackState.append(0) // add second + mergedNavigationStack.append(_configuredSUT[peekStackID()]) + + XCTAssertNotEqual(configuredSUT[peekStackID()], nil) + XCTAssertEqual(sut[peekStackID()], nil) + + XCTAssert(_configuredSUT[peekStackID()] === configuredSUT[peekStackID()]) + XCTAssert(_configuredSUT[peekStackID()] === mergedNavigationStack.last) + XCTAssert(_sut[peekStackID()] !== mergedNavigationStack.last) + } + + do { + XCTAssertEqual(sut[0]?.isConfiguredByCustomNavigationChild, false) + XCTAssertEqual(sut[0]?.value, 0) + + XCTAssertEqual(configuredSUT[1]?.isConfiguredByCustomNavigationChild, false) + XCTAssertEqual(configuredSUT[1]?.value, 1) + } + + do { navigationStackState.append(0) // add third + mergedNavigationStack.append(_sut[peekStackID()]) + + XCTAssertNotEqual(sut[peekStackID()], nil) + XCTAssertEqual(configuredSUT[peekStackID()], nil) + + XCTAssert(zip(mergedNavigationStack, [ + sut[0], + configuredSUT[1], + sut[2] + ].compactMap { $0 }).allSatisfy(===)) + + XCTAssert(_sut[peekStackID()] === sut[peekStackID()]) + } + } + + func testInheritance() { + @CustomStackDestination + var sut: [AnyHashable: CustomViewController] + + @CustomStackDestination({ .init(value: 2) }) + var configuredSUT: [AnyHashable: CustomViewController] + + XCTAssertEqual(_sut[0].value, 1) + XCTAssertEqual(_configuredSUT[0].value, 2) + + XCTAssertEqual(_sut[0].isConfiguredByCustomNavigationChild, true) + XCTAssertEqual(_configuredSUT[0].isConfiguredByCustomNavigationChild, true) + + // Should compile to pass the test + _sut.customNavigationChildSpecificMethod() + + // Should compile to pass the test + $sut.customNavigationChildSpecificMethod() + } +} + +fileprivate class CustomViewController: CocoaViewController { + var value: Int = 0 + var isConfiguredByCustomNavigationChild: Bool = false + + convenience init() { + self.init(value: 0) + } + + required init(value: Int) { + super.init(nibName: nil, bundle: nil) + self.value = value + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } +} + +@propertyWrapper +fileprivate final class CustomStackDestination< + StackElementID: Hashable, + Controller: CustomViewController +>: StackDestination< + StackElementID, + Controller +> { + override var wrappedValue: [StackElementID: Controller] { + super.wrappedValue + } + + override var projectedValue: CustomStackDestination { + super.projectedValue as! Self + } + + func customNavigationChildSpecificMethod() { } + + /// Override this method to apply initial configuration to the controller + /// + /// `CombineNavigation` should be imported as `@_spi(Internal) import` + /// to override this declaration + override func configureController(_ controller: Controller) { + controller.isConfiguredByCustomNavigationChild = true + } + + /// This wrapper is binded to a custom controller type + /// so you can override wrapper's `initController` method + /// to call some specific initializer + /// + /// `CombineNavigation` should be imported as `@_spi(Internal) import` + /// to override this declaration + override class func initController() -> Controller { + .init(value: 1) + } +} +#endif diff --git a/Tests/CombineNavigationTests/DestinationTests.swift b/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift similarity index 56% rename from Tests/CombineNavigationTests/DestinationTests.swift rename to Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift index c4d88f2..b5db001 100644 --- a/Tests/CombineNavigationTests/DestinationTests.swift +++ b/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift @@ -3,39 +3,39 @@ import CocoaAliases @_spi(Internals) @testable import CombineNavigation #if canImport(UIKit) && !os(watchOS) -final class DestinationTests: XCTestCase { +final class TreeDestinationTests: XCTestCase { func testMain() { - @Destination - var basic: CustomViewController? + @TreeDestination + var sut: CustomViewController? - @Destination({ .init(value: 1) }) - var configuredBasic: CustomViewController? + @TreeDestination({ .init(value: 1) }) + var configuredSUT: CustomViewController? - XCTAssertEqual(_basic().value, 0) - XCTAssertEqual(_configuredBasic().value, 1) + XCTAssertEqual(_sut().value, 0) + XCTAssertEqual(_configuredSUT().value, 1) - XCTAssertEqual(_basic().isConfiguredByCustomNavigationChild, false) - XCTAssertEqual(_configuredBasic().isConfiguredByCustomNavigationChild, false) + XCTAssertEqual(_sut().isConfiguredByCustomNavigationChild, false) + XCTAssertEqual(_configuredSUT().isConfiguredByCustomNavigationChild, false) } func testInheritance() { - @CustomDestination - var custom: CustomViewController? + @CustomTreeDestination + var sut: CustomViewController? - @CustomDestination({ .init(value: 2) }) - var configuredCustom: CustomViewController? + @CustomTreeDestination({ .init(value: 2) }) + var configuredSUT: CustomViewController? - XCTAssertEqual(_custom().value, 1) - XCTAssertEqual(_configuredCustom().value, 2) + XCTAssertEqual(_sut().value, 1) + XCTAssertEqual(_configuredSUT().value, 2) - XCTAssertEqual(_custom().isConfiguredByCustomNavigationChild, true) - XCTAssertEqual(_configuredCustom().isConfiguredByCustomNavigationChild, true) + XCTAssertEqual(_sut().isConfiguredByCustomNavigationChild, true) + XCTAssertEqual(_configuredSUT().isConfiguredByCustomNavigationChild, true) // Should compile to pass the test - _custom.customNavigationChildSpecificMethod() + _sut.customNavigationChildSpecificMethod() // Should compile to pass the test - $custom.customNavigationChildSpecificMethod() + $sut.customNavigationChildSpecificMethod() } } @@ -58,9 +58,9 @@ fileprivate class CustomViewController: CocoaViewController { } @propertyWrapper -fileprivate final class CustomDestination: Destination { +fileprivate final class CustomTreeDestination: TreeDestination { override var wrappedValue: Controller? { super.wrappedValue } - override var projectedValue: CustomDestination { super.projectedValue as! Self } + override var projectedValue: CustomTreeDestination { super.projectedValue as! Self } func customNavigationChildSpecificMethod() { } diff --git a/Tests/CombineNavigationTests/Helpers/wait.swift b/Tests/CombineNavigationTests/Helpers/wait.swift deleted file mode 100644 index efc3d9b..0000000 --- a/Tests/CombineNavigationTests/Helpers/wait.swift +++ /dev/null @@ -1,15 +0,0 @@ -import XCTest -import FoundationExtensions - -#warning("TODO: Remove before release if not used") -extension XCTestCase { - func wait(for interval: TimeInterval) { - let expectation = XCTestExpectation() - - DispatchQueue.main.asyncAfter(deadline: .interval(interval)) { - expectation.fulfill() - } - - wait(for: [expectation], timeout: interval + 0.5) - } -} diff --git a/Tests/CombineNavigationTests/RoutingControllerTests.swift b/Tests/CombineNavigationTests/RoutingControllerTests.swift index 67eae26..1779238 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTests.swift @@ -4,18 +4,63 @@ import Capture import Combine @testable import CombineNavigation #if canImport(UIKit) && !os(watchOS) - +import SwiftUI #warning("TODO: Add test for `navigationStack(_:ids:route:switch:onDismiss:)`") #warning("TODO: Add test for `navigationDestination(_:isPresented:controller:onDismiss:)`") final class RoutingControllerTests: XCTestCase { + func testAnimation() { + let viewModel = TreeViewModel() + let controller = TreeViewController() + let navigationController = UINavigationController(rootViewController: controller) + controller.viewModel = viewModel + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + + let expectation = XCTestExpectation() + _ = withoutNavigationAnimation { + // Escape out of context, won't work with dispach queues unfortunately + Task { @MainActor in + // Wait for 1 second + try await Task.sleep(nanoseconds: 1_000_000_000) + + // Fails if animation is enabled + viewModel.state.value.destination = .feedback() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.feedbackController) + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 1.5) + } + + func testAnimationPublisher() { + let viewModel = TreeViewModel() + let controller = TreeViewController() + let navigationController = UINavigationController(rootViewController: controller) + + // Disable animations using publisher + viewModel.animationsDisabled = true + controller.viewModel = viewModel + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + + // Fails if animation is enabled + viewModel.state.value.destination = .feedback() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.feedbackController) + } + func testNavigationTree() { let viewModel = TreeViewModel() let controller = TreeViewController() let navigationController = UINavigationController(rootViewController: controller) controller.viewModel = viewModel - + // Disable navigation animation for tests - withNavigationAnimation(enabled: false) { + withoutNavigationAnimation { XCTAssertEqual(navigationController.viewControllers.count, 1) XCTAssert(navigationController.topViewController === controller) @@ -47,26 +92,21 @@ final class RoutingControllerTests: XCTestCase { controller.viewModel = viewModel // Disable navigation animation for tests - withNavigationAnimation(enabled: false) { + withoutNavigationAnimation { XCTAssertEqual(navigationController.viewControllers.count, 1) XCTAssert(navigationController.topViewController === controller) viewModel.state.value.path.append(.feedback()) XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.topViewController === controller.feedbackController) + XCTAssert(navigationController.topViewController === controller.$feedbackControllers[0]) viewModel.state.value.path.append(.orderDetail()) XCTAssertEqual(navigationController.viewControllers.count, 3) - XCTAssert(navigationController.topViewController === controller.orderDetailController) + XCTAssert(navigationController.topViewController === controller.$orderDetailControllers[1]) - #warning("TODO: Improve Destination for stacks") - // The problem: - // - // In this test we don't push same destinations - // because there is only one controller for destination available - // - // Probably we need to introduce StackDestination type - // to keep an array of controllers + viewModel.state.value.path.append(.feedback()) + XCTAssertEqual(navigationController.viewControllers.count, 4) + XCTAssert(navigationController.topViewController === controller.$feedbackControllers[2]) viewModel.state.value.path.removeAll() XCTAssertEqual(navigationController.viewControllers.count, 1) @@ -74,40 +114,100 @@ final class RoutingControllerTests: XCTestCase { viewModel.state.value.path.append(.feedback()) XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.topViewController === controller.feedbackController) + XCTAssert(navigationController.topViewController === controller.$feedbackControllers[0]) viewModel.state.value.path.append(.orderDetail()) XCTAssertEqual(navigationController.viewControllers.count, 3) - XCTAssert(navigationController.topViewController === controller.orderDetailController) + XCTAssert(navigationController.topViewController === controller.$orderDetailControllers[1]) _ = viewModel.state.value.path.popLast() XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.topViewController === controller.feedbackController) + XCTAssert(navigationController.topViewController === controller.$feedbackControllers[0]) viewModel.state.value.path.append(.orderDetail()) XCTAssertEqual(navigationController.viewControllers.count, 3) - XCTAssert(navigationController.topViewController === controller.orderDetailController) + XCTAssert(navigationController.topViewController === controller.$orderDetailControllers[1]) + // pop + XCTAssertEqual(viewModel.state.value.path.count, 2) navigationController.popViewController(animated: false) XCTAssertEqual(viewModel.state.value.path.count, 1) - viewModel.state.value.path.append(.orderDetail()) - XCTAssertEqual(navigationController.viewControllers.count, 3) - XCTAssert(navigationController.topViewController === controller.orderDetailController) + // popTo + viewModel.state.value.path = [.feedback(), .feedback(), .orderDetail(), .feedback(), .orderDetail()] + XCTAssertEqual(navigationController.viewControllers.count, 6) - #warning("TODO: Improve dismiss") - // Fails, probably because: - // - // - Navigation pops multiple controllers - // -> onDismiss removes them from state one-by-one - // --> when the first one is removed state is updated - // ---> when state is updated it sends an update to routing controller - // ----> routing controller updates navigation stack with the state - // - // To fix it we probably need to find a way to batch dismiss controllers navigationController.popToViewController(controller, animated: false) XCTAssertEqual(viewModel.state.value.path.count, 0) XCTAssertEqual(navigationController.viewControllers.count, 1) + + // popToRoot + viewModel.state.value.path = [.feedback(), .feedback(), .orderDetail(), .feedback(), .orderDetail()] + XCTAssertEqual(navigationController.viewControllers.count, 6) + + navigationController.popToRootViewController(animated: false) + XCTAssertEqual(viewModel.state.value.path.count, 0) + XCTAssertEqual(navigationController.viewControllers.count, 1) + } + } + + func testNavigationStackDestinations() { + let viewModel = StackViewModel() + let controller = StackViewController() + let navigationController = UINavigationController(rootViewController: controller) + controller.viewModel = viewModel + + // Disable navigation animation for tests + withoutNavigationAnimation { + viewModel.state.value.path = [.feedback(), .feedback(), .orderDetail(), .feedback(), .orderDetail()] + XCTAssertEqual(navigationController.viewControllers.count, 6) + + let destinations = controller._makeDestinations() + + XCTAssert(zip(navigationController.viewControllers, [ + controller, + controller.feedbackControllers[0], + controller.feedbackControllers[1], + controller.orderDetailControllers[2], + controller.feedbackControllers[3], + controller.orderDetailControllers[4], + ]).allSatisfy(===)) + + XCTAssert(zip(navigationController.viewControllers, [ + controller, + controller.$feedbackControllers[0], + controller.$feedbackControllers[1], + controller.$orderDetailControllers[2], + controller.$feedbackControllers[3], + controller.$orderDetailControllers[4], + ]).allSatisfy(===)) + + XCTAssert(zip(navigationController.viewControllers, [ + controller, + destinations.feedbackControllers[0], + destinations.feedbackControllers[1], + destinations.orderDetailControllers[2], + destinations.feedbackControllers[3], + destinations.orderDetailControllers[4], + ]).allSatisfy(===)) + + XCTAssert(zip(navigationController.viewControllers, [ + controller, + destinations.$feedbackControllers[0], + destinations.$feedbackControllers[1], + destinations.$orderDetailControllers[2], + destinations.$feedbackControllers[3], + destinations.$orderDetailControllers[4], + ]).allSatisfy(===)) + + XCTAssert(zip(navigationController.viewControllers, [ + controller, + destinations[0], + destinations[1], + destinations[2], + destinations[3], + destinations[4], + ]).allSatisfy(===)) } } } @@ -143,6 +243,16 @@ fileprivate class TreeViewModel { } let state = CurrentValueSubject(.init()) + var animationsDisabled: Bool = false + + var publisher: some Publisher { + animationsDisabled + ? state + .withNavigationAnimation(false) + .eraseToAnyPublisher() + : state + .eraseToAnyPublisher() + } } @RoutingController @@ -150,13 +260,13 @@ fileprivate class TreeViewController: CocoaViewController { private var cancellables: Set = [] var viewModel: TreeViewModel! { - didSet { bind(viewModel.state) } + didSet { bind(viewModel.publisher) } } - @Destination + @TreeDestination var orderDetailController: OrderDetailsController? - @Destination + @TreeDestination var feedbackController: FeedbackController? func bind>(_ publisher: P) { @@ -216,27 +326,25 @@ fileprivate class StackViewController: CocoaViewController { didSet { bind(viewModel.state) } } - @Destination - var orderDetailController: OrderDetailsController? + @StackDestination + var orderDetailControllers: [Int: OrderDetailsController] - @Destination - var feedbackController: FeedbackController? + @StackDestination + var feedbackControllers: [Int: FeedbackController] func bind>(_ publisher: P) { navigationStack( publisher.map(\.path).map { $0.map(\.tag) }.removeDuplicates(), - switch: destinations { destinations, route in + switch: destinations { destinations, route, index in switch route { case .orderDetail: - destinations.$orderDetailController() + destinations.$orderDetailControllers[index] case .feedback: - destinations.$feedbackController() + destinations.$feedbackControllers[index] } }, - onDismiss: capture { _self, route in - guard let index = _self.viewModel.state.value.path.lastIndex(where: { $0.tag == route }) - else { return } - _self.viewModel.state.value.path.remove(at: index) + onDismiss: capture { _self, indices in + _self.viewModel.state.value.path.remove(atOffsets: IndexSet(indices)) } ) .store(in: &cancellables) From 5b0422e8cfc772bd1caafa65f93904c208e1e4ed Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 16 Dec 2023 02:24:54 +0100 Subject: [PATCH 04/43] feat: API Improvements, tests and bug fixes --- Package.swift | 2 +- README.md | 17 +- .../CocoaViewController+API.swift | 84 ++-- .../Destinations/StackDestination.swift | 40 +- .../Destinations/TreeDestination.swift | 26 +- .../Internal/CocoaViewController+.swift | 104 ---- .../CombineNavigationRouter+API.swift | 133 +++++ .../Internal/CombineNavigationRouter.swift | 159 ++++++ .../Internal/Helpers/NSObject+.swift | 2 +- .../Internal/NavigationRoute.swift | 35 -- .../Internal/UINavigationController+.swift | 215 ++++++-- .../NavigationAnimation.swift | 30 ++ Sources/CombineNavigation/Plugin.swift | 1 - .../CombineNavigation/RoutingController.swift | 16 +- .../RoutingControllerMacro.swift | 6 +- .../RoutingControllerMacroTests.swift | 8 +- .../Destinations/StackDestinationTests.swift | 13 +- .../NavigationAnimationTests.swift | 231 +++++++++ .../RoutingControllerStackTests.swift | 200 ++++++++ .../RoutingControllerTests.swift | 471 ++++++++---------- .../RoutingControllerTreeTests.swift | 116 +++++ 21 files changed, 1359 insertions(+), 550 deletions(-) delete mode 100644 Sources/CombineNavigation/Internal/CocoaViewController+.swift create mode 100644 Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift create mode 100644 Sources/CombineNavigation/Internal/CombineNavigationRouter.swift delete mode 100644 Sources/CombineNavigation/Internal/NavigationRoute.swift create mode 100644 Tests/CombineNavigationTests/NavigationAnimationTests.swift create mode 100644 Tests/CombineNavigationTests/RoutingControllerStackTests.swift create mode 100644 Tests/CombineNavigationTests/RoutingControllerTreeTests.swift diff --git a/Package.swift b/Package.swift index d32a8c8..c9c6d7c 100644 --- a/Package.swift +++ b/Package.swift @@ -40,7 +40,7 @@ let package = Package( ), .package( url: "https://github.com/capturecontext/swift-foundation-extensions.git", - .upToNextMinor(from: "0.3.4") + .upToNextMinor(from: "0.4.0") ), .package( url: "https://github.com/stackotter/swift-macro-toolkit.git", diff --git a/README.md b/README.md index 0c4a62a..3aa2dc2 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [![SwiftPM 5.9](https://img.shields.io/badge/swiftpm-5.9-ED523F.svg?style=flat)](https://github.com/CaptureContext/swift-declarative-configuration/actions/workflows/Test.yml) ![Platforms](https://img.shields.io/badge/platforms-iOS_13_|_macOS_11_|_tvOS_13_|_watchOS_6_|_Catalyst_13-ED523F.svg?style=flat) [![@capture_context](https://img.shields.io/badge/contact-@capture__context-1DA1F2.svg?style=flat&logo=twitter)](https://twitter.com/capture_context) +>Package compiles for all platforms, but functionality is available if UIKit can be imported and the platform is not watchOS. + > This readme is draft and the branch is still an `beta` version untill all [todos](#Coming soon) are resolved. ## Usage @@ -31,7 +33,7 @@ final class MyViewController: UIViewController { destinations.$detailsController() } }, - onDismiss: capture { _self in + onPop: capture { _self in _self.viewModel.send(.dismiss) } ).store(in: &cancellables) @@ -56,7 +58,7 @@ final class MyViewController: UIViewController { "my_feature_details" isPresented: viewModel.publisher(for: \.state.detais.isNotNil), controller: destinations { $0.$detailsController() }, - onDismiss: capture { $0.viewModel.send(.dismiss) } + onPop: capture { $0.viewModel.send(.dismiss) } ).store(in: &cancellables) } } @@ -94,7 +96,7 @@ final class MyViewController: UIViewController { destinations.$featureBControllers[index] } }, - onDismiss: capture { _self, indices in + onPop: capture { _self, indices in // can be handled like `state.path.remove(atOffsets: IndexSet(indices))` // should remove all requested indices before publishing an update _self.viewModel.send(.dismiss(indices)) @@ -104,15 +106,6 @@ final class MyViewController: UIViewController { } ``` -### Notes and good practices - -- One controller should not manage navigation stack and navigation tree - - > The logic behind collecting controllers navigation stack uses same storage for `navigationStack`s and `navigationDestination`s, so it should not cause any crashes, but it will break the logic -- Routing controller's parent should be navigationController - - > It's just generally a good practice, but the package handles this corner case and everything should work fine - ## Coming soon - Rich example diff --git a/Sources/CombineNavigation/CocoaViewController+API.swift b/Sources/CombineNavigation/CocoaViewController+API.swift index d4dc228..10bffbe 100644 --- a/Sources/CombineNavigation/CocoaViewController+API.swift +++ b/Sources/CombineNavigation/CocoaViewController+API.swift @@ -17,7 +17,7 @@ extension CocoaViewController { >( _ publisher: P, switch controller: @escaping (Route, C.Index) -> CocoaViewController, - onDismiss: @escaping (Set) -> Void + onPop: @escaping ([C.Index]) -> Void ) -> Cancellable where P.Output == C, P.Failure == Never, @@ -25,12 +25,10 @@ extension CocoaViewController { C.Index: Hashable, C.Indices: Equatable { - navigationStack( + combineNavigationRouter.navigationStack( publisher, - ids: \.indices, - route: { ($0[$1], $1) }, switch: controller, - onDismiss: onDismiss + onPop: onPop ) } @@ -44,31 +42,20 @@ extension CocoaViewController { _ publisher: P, ids: @escaping (Stack) -> IDs, route: @escaping (Stack, IDs.Element) -> Route?, - switch controller: @escaping (Route) -> CocoaViewController, - onDismiss: @escaping (Set) -> Void + switch controller: @escaping (Route, IDs.Element) -> CocoaViewController, + onPop: @escaping ([IDs.Element]) -> Void ) -> Cancellable where P.Output == Stack, P.Failure == Never, IDs.Element: Hashable { - publisher - .sinkValues(capture { _self, stack in - _self.updateNavigationStack( - ids(stack).compactMap { id in - route(stack, id).map { route in - .init( - id: id, - controller: { controller(route) } - ) - } - }, - onDismiss: _self.capture { _self in - onDismiss(Set( - _self.routesToDismiss().compactMap { $0.id as? IDs.Element } - )) - } - ) - }) + combineNavigationRouter.navigationStack( + publisher, + ids: ids, + route: route, + switch: controller, + onPop: onPop + ) } } @@ -80,49 +67,34 @@ extension CocoaViewController { _ id: AnyHashable, isPresented publisher: P, controller: @escaping () -> CocoaViewController, - onDismiss: @escaping () -> Void + onPop: @escaping () -> Void ) -> AnyCancellable where P.Output == Bool, P.Failure == Never { - navigationDestination( - publisher.map { isPresented in - isPresented ? id : nil - }, - switch: { id in - id.map { _ in controller() } - }, - onDismiss: { - onDismiss() - } + combineNavigationRouter.navigationDestination( + id, + isPresented: publisher, + controller: controller, + onPop: onPop ) } /// Subscribes on publisher of navigation destination state - public func navigationDestination( + public func navigationDestination( _ publisher: P, - switch controller: @escaping (P.Output) -> CocoaViewController?, - onDismiss: @escaping () -> Void + switch controller: @escaping (Route) -> CocoaViewController, + onPop: @escaping () -> Void ) -> AnyCancellable where - P.Output: Hashable & ExpressibleByNilLiteral, + Route: Hashable, + P.Output == Route?, P.Failure == Never { - publisher - .sinkValues(capture { _self, route in - _self.updateNavigationStack( - route == nil ? [] : [ - NavigationRoute( - id: route, - controller: { controller(route) } - ) - ], - onDismiss: { [weak self] in - guard let self, self.routesToDismiss().contains(where: { $0.id == route as AnyHashable }) - else { return } - onDismiss() - } - ) - }) + combineNavigationRouter.navigationDestination( + publisher, + switch: controller, + onPop: onPop + ) } } #endif diff --git a/Sources/CombineNavigation/Destinations/StackDestination.swift b/Sources/CombineNavigation/Destinations/StackDestination.swift index af10dad..a52275c 100644 --- a/Sources/CombineNavigation/Destinations/StackDestination.swift +++ b/Sources/CombineNavigation/Destinations/StackDestination.swift @@ -18,7 +18,9 @@ open class StackDestination< } public var projectedValue: StackDestination { self } - private var _initControllerOverride: (() -> Controller)? + private var _initControllerOverride: ((StackElementID) -> Controller)? + + private var _configuration: ((Controller, StackElementID) -> Void)? /// Sets instance-specific override for creating a new controller /// @@ -26,16 +28,33 @@ open class StackDestination< /// /// To disable isntance-specific override pass `nil` to this method public func overrideInitController( - with closure: (() -> Controller)? + with closure: ((StackElementID) -> Controller)? ) { _initControllerOverride = closure } - @_spi(Internals) public class func initController() -> Controller { + /// Sets instance-specific configuration for controllers + public func setConfiguration( + _ closure: ((Controller, StackElementID) -> Void)? + ) { + _configuration = closure + closure.map { configure in + wrappedValue.forEach { id, controller in + configure(controller, id) + } + } + } + + @_spi(Internals) public class func initController( + for id: StackElementID + ) -> Controller { return Controller() } - @_spi(Internals) open func configureController(_ controller: Controller) {} + @_spi(Internals) open func configureController( + _ controller: Controller, + for id: StackElementID + ) {} /// Creates a new instance public init() {} @@ -49,19 +68,22 @@ open class StackDestination< /// doesn't have a custom init you'll have to use this method or if you have a base controller that /// requires custom init it'll be beneficial for you to create a custom subclass of StackDestination /// and override it's `initController` class method, you can find an example in tests. - convenience init(_ initControllerOverride: @escaping () -> Controller) { + public convenience init(_ initControllerOverride: @escaping (StackElementID) -> Controller) { self.init() - self._initControllerOverride = initControllerOverride + self.overrideInitController(with: initControllerOverride) } /// Returns `wrappedValue[id]` if present, intializes and configures a new instance otherwise public subscript(_ id: StackElementID) -> Controller { - return wrappedValue[id] ?? { - let controller = _initControllerOverride?() ?? Self.initController() - configureController(controller) + let controller = wrappedValue[id] ?? { + let controller = _initControllerOverride?(id) ?? Self.initController(for: id) _controllers[id] = Weak(controller) + configureController(controller, for: id) + _configuration?(controller, id) return controller }() + + return controller } } #endif diff --git a/Sources/CombineNavigation/Destinations/TreeDestination.swift b/Sources/CombineNavigation/Destinations/TreeDestination.swift index ef5f936..18e2b17 100644 --- a/Sources/CombineNavigation/Destinations/TreeDestination.swift +++ b/Sources/CombineNavigation/Destinations/TreeDestination.swift @@ -12,7 +12,9 @@ open class TreeDestination: Weakifiable { public var projectedValue: TreeDestination { self } private var _initControllerOverride: (() -> Controller)? - + + private var _configuration: ((Controller) -> Void)? + /// Sets instance-specific override for creating a new controller /// /// This override has the highest priority when creating a new controller @@ -23,7 +25,17 @@ open class TreeDestination: Weakifiable { ) { _initControllerOverride = closure } - + + /// Sets instance-specific configuration for controllers + public func setConfiguration( + _ closure: ((Controller) -> Void)? + ) { + _configuration = closure + closure.map { configure in + wrappedValue.map(configure) + } + } + @_spi(Internals) public class func initController() -> Controller { return Controller() } @@ -42,19 +54,21 @@ open class TreeDestination: Weakifiable { /// doesn't have a custom init you'll have to use this method or if you have a base controller that /// requires custom init it'll be beneficial for you to create a custom subclass of TreeDestination /// and override it's `initController` class method, you can find an example in tests. - convenience init(_ initControllerOverride: @escaping () -> Controller) { + public convenience init(_ initControllerOverride: @escaping () -> Controller) { self.init() - self._initControllerOverride = initControllerOverride + self.overrideInitController(with: initControllerOverride) } /// Returns wrappedValue if present, intializes and configures a new instance otherwise public func callAsFunction() -> Controller { - wrappedValue ?? { + let controller = wrappedValue ?? { let controller = _initControllerOverride?() ?? Self.initController() configureController(controller) - _controller = controller + _configuration?(controller) + self._controller = controller return controller }() + return controller } } #endif diff --git a/Sources/CombineNavigation/Internal/CocoaViewController+.swift b/Sources/CombineNavigation/Internal/CocoaViewController+.swift deleted file mode 100644 index 00b0226..0000000 --- a/Sources/CombineNavigation/Internal/CocoaViewController+.swift +++ /dev/null @@ -1,104 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import Capture -import CocoaAliases -import CombineExtensions -import FoundationExtensions - -// MARK: Get/set managed routes - -extension CocoaViewController { - @AssociatedObject - private var __navigationStack: [NavigationRoute] = [] - - @AssociatedObject - fileprivate var __onDestinationDismiss: (() -> Void)? - - @AssociatedObject - fileprivate var __destinationDismissCancellable: AnyCancellable? - - internal func routesToDismiss() -> [NavigationRoute] { - guard let controllers = navigationController?.viewControllers - else { return [] } - - return __navigationStack.filter { route in - !controllers.contains { $0.objectID == route.controllerID } - } - } - - internal func updateNavigationStack( - _ navigationStack: [NavigationRoute], - onDismiss: @escaping () -> Void - ) { - __onDestinationDismiss = onDismiss - __navigationStack = navigationStack - requestNavigationStackSync() - } - - internal func navigationStackControllers( - for navigation: UINavigationController - ) -> [CocoaViewController] { - let navigationStack = __navigationStack - return zip(navigationStack, navigationStack.indices).flatMap { route, index in - controller(for: route, in: navigation).map { controller in - __navigationStack[index].controllerID = controller.objectID - return [controller] + controller.navigationStackControllers(for: navigation) - }.or([]) - } - } -} - -// MARK: Configure managed route - -extension CocoaViewController { - @AssociatedObject - fileprivate var __dismissCancellables: [AnyHashable: AnyCancellable] = [:] - - fileprivate func controller( - for route: NavigationRoute, - in navigation: UINavigationController - ) -> CocoaViewController? { - return route.controller().map { controller in -// navigation -// .dismissPublisher(for: self) -// .sinkValues(capture { _self in -// _self.__dismissCancellables -// .removeValue(forKey: route.id)? -// .cancel() -// return route.onDismiss() -// }) -// .store(for: route.id, in: &__dismissCancellables) - return controller - } - } -} - -// MARK: Sync navigation stack - -extension CocoaViewController { - @AssociatedObject - fileprivate var __navigationControllerCancellable: AnyCancellable? - - fileprivate func requestNavigationStackSync() { - if let navigationController { - syncNavigationStack(using: navigationController) - } else { - publisher(for: \.navigationController) - .compactMap { $0 } - .sinkValues(capture { $0.syncNavigationStack(using: $1) }) - .store(in: &__navigationControllerCancellable) - } - } - - fileprivate func syncNavigationStack(using navigation: UINavigationController) { - __navigationControllerCancellable = nil - - navigation.dismissPublisher() - .sinkValues(capture { _self in - _self.__onDestinationDismiss?() - }) - .store(in: &__destinationDismissCancellable) - - navigation.syncNavigationStack(for: self) - } -} -#endif diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift new file mode 100644 index 0000000..df08e05 --- /dev/null +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift @@ -0,0 +1,133 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import CombineExtensions +import FoundationExtensions + +// MARK: - Public API + +// MARK: navigationStack + +extension CombineNavigationRouter { + /// Subscribes on publisher of navigation stack state + func navigationStack< + P: Publisher, + C: Collection & Equatable, + Route: Hashable + >( + _ publisher: P, + switch controller: @escaping (Route, C.Index) -> CocoaViewController, + onPop: @escaping ([C.Index]) -> Void + ) -> Cancellable where + P.Output == C, + P.Failure == Never, + C.Element == Route, + C.Index: Hashable, + C.Indices: Equatable + { + navigationStack( + publisher, + ids: \.indices, + route: { $0[$1] }, + switch: { route, id in + controller(route, id) + }, + onPop: onPop + ) + } + + /// Subscribes on publisher of navigation stack state + func navigationStack< + P: Publisher, + Stack, + IDs: Collection & Equatable, + Route + >( + _ publisher: P, + ids: @escaping (Stack) -> IDs, + route: @escaping (Stack, IDs.Element) -> Route?, + switch controller: @escaping (Route, IDs.Element) -> CocoaViewController, + onPop: @escaping ([IDs.Element]) -> Void + ) -> Cancellable where + P.Output == Stack, + P.Failure == Never, + IDs.Element: Hashable + { + let getRoutes: (Stack) -> [NavigationRoute] = capture(orReturn: []) { _self, stack in + ids(stack).compactMap { id in + route(stack, id).map { route in + _self.makeNavigationRoute(for: id) { controller(route, id) } + } + } + } + + return publisher + .sinkValues(capture { _self, stack in + let managedRoutes = getRoutes(stack) + + _self.setRoutes( + managedRoutes, + onPop: managedRoutes.isNotEmpty + ? { poppedRoutes in + let poppedIDs: [IDs.Element] = poppedRoutes.compactMap { route in + guard managedRoutes.contains(where: { $0 === route }) else { return nil } + return route.id as? IDs.Element + } + + if poppedIDs.isNotEmpty { onPop(poppedIDs) } + } + : nil + ) + }) + } +} + +// MARK: navigationDestination + +extension CombineNavigationRouter { + /// Subscribes on publisher of navigation destination state + func navigationDestination( + _ id: AnyHashable, + isPresented publisher: P, + controller: @escaping () -> CocoaViewController, + onPop: @escaping () -> Void + ) -> AnyCancellable where + P.Output == Bool, + P.Failure == Never + { + navigationDestination( + publisher.map { $0 ? id : nil }, + switch: { _ in controller() }, + onPop: onPop + ) + } + + /// Subscribes on publisher of navigation destination state + func navigationDestination( + _ publisher: P, + switch controller: @escaping (Route) -> CocoaViewController, + onPop: @escaping () -> Void + ) -> AnyCancellable where + Route: Hashable, + P.Output == Optional, + P.Failure == Never + { + publisher + .map { [weak self] (route) -> NavigationRoute? in + guard let self, let route else { return nil } + return self.makeNavigationRoute(for: route) { controller(route) } + } + .sinkValues(capture { _self, route in + _self.setRoutes( + route.map { [$0] }.or([]), + onPop: route.map { route in + return { poppedRoutes in + let shouldTriggerPopHandler = poppedRoutes.contains(where: { $0 === route }) + if shouldTriggerPopHandler { onPop() } + } + } + ) + }) + } +} +#endif diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift new file mode 100644 index 0000000..a92a071 --- /dev/null +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift @@ -0,0 +1,159 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +extension CocoaViewController { + var combineNavigationRouter: CombineNavigationRouter { + getAssociatedObject(forKey: #function) ?? { + let router = CombineNavigationRouter(self) + setAssociatedObject(router, forKey: #function) + return router + }() + } + + public func addRoutedChild(_ controller: CocoaViewController) { + combineNavigationRouter.addChild(controller.combineNavigationRouter) + addChild(controller) + } +} + +extension CombineNavigationRouter { + class NavigationRoute: Identifiable { + let id: AnyHashable + let routingControllerID: ObjectIdentifier + private(set) var routedControllerID: ObjectIdentifier? + private let controller: () -> CocoaViewController? + + init( + id: AnyHashable, + routingControllerID: ObjectIdentifier, + controller: @escaping () -> CocoaViewController? + ) { + self.id = id + self.routingControllerID = routingControllerID + self.controller = controller + } + + func makeController( + routedBy parentRouter: CombineNavigationRouter + ) -> CocoaViewController? { + let controller = self.controller() + controller?.combineNavigationRouter.parent = parentRouter + routedControllerID = controller?.objectID + return controller + } + } +} + +final class CombineNavigationRouter: Weakifiable { + fileprivate weak var parent: CombineNavigationRouter? + fileprivate weak var node: CocoaViewController! + fileprivate var directChildren: [Weak] = [] { + didSet { directChildren.removeAll(where: \.object.isNil) } + } + + fileprivate var navigationControllerCancellable: AnyCancellable? + fileprivate var destinationDismissCancellable: AnyCancellable? + + fileprivate var popHandler: (([NavigationRoute]) -> Void)? + fileprivate var routes: [NavigationRoute] = [] + + fileprivate init(_ node: CocoaViewController?) { + self.node = node + } + + fileprivate func addChild(_ router: CombineNavigationRouter) { + router.parent = self + directChildren.removeAll(where: { $0.object === router }) + directChildren.append(.init(router)) + } + + func setRoutes( + _ routes: [NavigationRoute], + onPop: (([NavigationRoute]) -> Void)? + ) { + self.routes = routes + self.popHandler = onPop + self.requestNavigationStackSync() + } + + func makeNavigationRoute( + for id: ID, + controller: @escaping () -> CocoaViewController? + ) -> NavigationRoute { + NavigationRoute( + id: id, + routingControllerID: node.objectID, + controller: controller + ) + } +} + +// MARK: Navigation stack sync + +extension CombineNavigationRouter { + fileprivate func requestNavigationStackSync() { + guard let node else { return } + + if let navigation = node.navigationController { + syncNavigationStack(using: navigation) + } else { + node.publisher(for: \.navigationController) + .compactMap { $0 } + .sinkValues(capture { $0.syncNavigationStack(using: $1) }) + .store(in: &navigationControllerCancellable) + } + } + + private func syncNavigationStack(using navigation: UINavigationController) { + navigationControllerCancellable = nil + + navigation.popPublisher + .sinkValues(capture { _self, controllers in + let routes = _self.routes.reduce(into: ( + kept: [NavigationRoute](), + popped: [NavigationRoute]() + )) { routes, route in + if controllers.contains(where: { $0.objectID == route.routedControllerID }) { + routes.popped.append(route) + } else { + routes.kept.append(route) + } + } + _self.routes = routes.kept + _self.popHandler?(routes.popped) + }) + .store(in: &destinationDismissCancellable) + + navigation.setViewControllers( + buildNavigationStack() + ) + } +} + +// MARK: Build navigation stack + +extension CombineNavigationRouter { + fileprivate func buildNavigationStack() -> [CocoaViewController] { + parent + .map { $0.buildNavigationStack() } + .or( + [node].compactMap { $0 } + + self.buildManagedNavigationStack() + ) + } + + private func buildManagedNavigationStack() -> [CocoaViewController] { + prepareRoutedControllers().flatMap { controller in + [controller] + controller.combineNavigationRouter.buildManagedNavigationStack() + } + } + + private func prepareRoutedControllers() -> [CocoaViewController] { + directChildren.compactMap(\.object).flatMap { $0.prepareRoutedControllers() } + + routes.compactMap { $0.makeController(routedBy: self) } + } +} +#endif diff --git a/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift b/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift index c57f113..b8a6a9a 100644 --- a/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift +++ b/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift @@ -1,5 +1,5 @@ import Foundation extension NSObject { - var objectID: ObjectIdentifier { .init(self) } + internal var objectID: ObjectIdentifier { .init(self) } } diff --git a/Sources/CombineNavigation/Internal/NavigationRoute.swift b/Sources/CombineNavigation/Internal/NavigationRoute.swift deleted file mode 100644 index c3896db..0000000 --- a/Sources/CombineNavigation/Internal/NavigationRoute.swift +++ /dev/null @@ -1,35 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import CocoaAliases -import FoundationExtensions -import Combine - -struct NavigationRouteIndexedID< - Route: Hashable, - Index: Hashable ->: Hashable { - let route: Route - let index: Index -} - -struct NavigationRoute: Hashable, Equatable, Identifiable { - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } - - let id: AnyHashable - var controllerID: ObjectIdentifier? - let controller: () -> CocoaViewController? - - init( - id: AnyHashable, - controller: @escaping () -> CocoaViewController? - ) { - self.id = id - self.controller = controller - } -} -#endif diff --git a/Sources/CombineNavigation/Internal/UINavigationController+.swift b/Sources/CombineNavigation/Internal/UINavigationController+.swift index d9afb57..e008f1d 100644 --- a/Sources/CombineNavigation/Internal/UINavigationController+.swift +++ b/Sources/CombineNavigation/Internal/UINavigationController+.swift @@ -5,57 +5,174 @@ import Combine import FoundationExtensions extension UINavigationController { - internal func dismissPublisher() -> some Publisher { - return Publishers.Merge3( - publisher(for: #selector(UINavigationController.popViewController)).map { print("pop") }, - publisher(for: #selector(UINavigationController.popToViewController)).map { print("popTo") }, - publisher(for: #selector(UINavigationController.popToRootViewController)).map { print("popToRoot") } + private typealias PopSubject = PassthroughSubject<[CocoaViewController], Never> + + private var popSubject: PopSubject { + getAssociatedObject(forKey: #function) ?? { + let subject = PopSubject() + setAssociatedObject(subject, forKey: #function) + return subject + }() + } + + /// Publisher for popped controllers + /// + /// Emits an event on calls of: + /// - `popViewController` + /// - `popToViewController` + /// - `popToRootViewController` + /// - `setViewController` when some controllers are removed (even if there was pop animation) + /// + /// Underlying subject is triggered by swizzled methods in `CombineNavigation` module. + /// + /// > On interactive pop an event will be emitted only when the pop is finished and is not cancelled + /// + /// > Is not called when `viewControllers` property is mutated directly + public var popPublisher: some Publisher<[CocoaViewController], Never> { + return popSubject + } + + fileprivate func handlePop(of controllers: [CocoaViewController]) { + handlePop { self.popSubject.send(controllers) } + } + + private func handlePop(_ onPop: @escaping () -> Void) { + guard let transitionCoordinator = self.transitionCoordinator + else { return onPop() } // Handle non-interactive pop + + // Handle interactive pop if not cancelled + transitionCoordinator.animate(alongsideTransition: nil) { context in + if context.isCancelled { return } + onPop() + } + } +} + +// MARK: - Swizzling +// Swizzle methods that may pop some viewControllers +// with tracking versions which forward popped controllers +// to UINavigationController.popSubject + +// Swift swizzling causes infinite recursion for objc methods +// +// Forum: +// https://forums.swift.org/t/dynamicreplacement-causes-infinite-recursion/52768 +// +// Swift issues: +// https://github.com/apple/swift/issues/62214 +// https://github.com/apple/swift/issues/53916 +// +// Have to use objc swizzling +// +//extension UINavigationController { +// @_dynamicReplacement(for: popViewController(animated:)) +// public func _trackedPopViewController( +// animated: Bool +// ) -> CocoaViewController? { +// let controller = popViewController(animated: animated) +// controller.map { handlePop(of: [$0]) } +// return controller +// } +// +// @_dynamicReplacement(for: popToRootViewController(animated:)) +// public func _trackedPopToRootViewController( +// animated: Bool +// ) -> [CocoaViewController]? { +// let controllers = popToRootViewController(animated: animated) +// controllers.map(handlePop) +// return controllers +// } +// +// @_dynamicReplacement(for: popToViewController(_:animated:)) +// public func _trackedPopToViewController( +// _ controller: CocoaViewController, +// animated: Bool +// ) -> [CocoaViewController]? { +// let controllers = popToViewController(controller, animated: animated) +// controllers.map(handlePop) +// return controllers +// } +// +// @_dynamicReplacement(for: setViewControllers(_:animated:)) +// public func _trackedSetViewControllers( +// _ controllers: [CocoaViewController], +// animated: Bool +// ) { +// let poppedControllers = viewControllers.filter { oldController in +// !controllers.contains { $0 === oldController } +// } +// +// setViewControllers(controllers, animated: animated) +// handlePop(of: poppedControllers) +// } +//} + +extension UINavigationController { + // Runs once in app lifetime + private static let swizzle: Void = { + objc_exchangeImplementations( + #selector(popViewController(animated:)), + #selector(__swizzledPopViewController) + ) + + objc_exchangeImplementations( + #selector(popToViewController(_:animated:)), + #selector(__swizzledPopToViewController) + ) + + objc_exchangeImplementations( + #selector(popToRootViewController(animated:)), + #selector(__swizzledPopToRootViewController) ) - .flatMap { [weak self] in Future { promise in - guard let self else { return promise(.success(false)) } - - guard let transitionCoordinator = self.transitionCoordinator - else { return promise(.success(true)) } // Handle non-interactive pop - - // Handle interactive pop if not cancelled - transitionCoordinator.animate(alongsideTransition: nil) { context in - promise(.success(context.isCancelled)) - } - }} - .filter { $0 } - .replaceOutput(with: ()) - } - - internal func syncNavigationStack(for controller: CocoaViewController) { - // controller, that manages navigation stack - // or it's parent, the parent of the pointer - // must be current navigation controller - let navigationStackPointer: UIViewController = { - var pointer = controller - while pointer.parent !== self { - guard let parent = pointer.parent else { - fatalError("Attempt to sync navigationStack from unrelated viewController") - } - pointer = parent - } - return pointer - }() - - // controllers before navigation stack managing controller - let prefix = viewControllers.prefix(while: { $0 !== navigationStackPointer }) - - // managed navigation stack - let suffix = controller.navigationStackControllers(for: self) - - let navigationStack = prefix + [navigationStackPointer] + suffix - - // setViewControllers updates navigation stack with - // valid push/pop animation, unmanaged controllers are thrown away - setViewControllers( - navigationStack, - animated: NavigationAnimation.$isEnabled.get() + + objc_exchangeImplementations( + #selector(setViewControllers(_:animated:)), + #selector(__swizzledSetViewControllers) ) + }() + + // Swizzle automatically when the first + // navigationController loads it's view + open override func loadView() { + UINavigationController.swizzle + super.viewDidLoad() + } + + @objc dynamic func __swizzledPopViewController( + animated: Bool + ) -> CocoaViewController? { + let controller = __swizzledPopViewController(animated: animated) + controller.map { handlePop(of: [$0]) } + return controller } -} + @objc dynamic func __swizzledPopToRootViewController( + animated: Bool + ) -> [CocoaViewController]? { + let controllers = __swizzledPopToRootViewController(animated: animated) + controllers.map(handlePop) + return controllers + } + + @objc dynamic func __swizzledPopToViewController( + _ controller: CocoaViewController, + animated: Bool + ) -> [CocoaViewController]? { + let controllers = __swizzledPopToViewController(controller, animated: animated) + controllers.map(handlePop) + return controllers + } + + @objc dynamic func __swizzledSetViewControllers( + _ controllers: [CocoaViewController], + animated: Bool + ) { + let poppedControllers = viewControllers.filter { oldController in + !controllers.contains { $0 === oldController } + } + + __swizzledSetViewControllers(controllers, animated: animated) + handlePop(of: poppedControllers) + } +} #endif diff --git a/Sources/CombineNavigation/NavigationAnimation.swift b/Sources/CombineNavigation/NavigationAnimation.swift index 6dd9fe3..ad73fe5 100644 --- a/Sources/CombineNavigation/NavigationAnimation.swift +++ b/Sources/CombineNavigation/NavigationAnimation.swift @@ -1,5 +1,35 @@ #if canImport(UIKit) && !os(watchOS) import Combine +import CocoaAliases + +extension UINavigationController { + @discardableResult + public func popViewController() -> CocoaViewController? { + popViewController(animated: NavigationAnimation.$isEnabled.get()) + } + + @discardableResult + public func popToRootViewController() -> [CocoaViewController]? { + popToRootViewController(animated: NavigationAnimation.$isEnabled.get()) + } + + @discardableResult + public func popToViewController( + _ controller: CocoaViewController + ) -> [CocoaViewController]? { + popToViewController(controller, animated: NavigationAnimation.$isEnabled.get()) + } + + public func setViewControllers( + _ controllers: [CocoaViewController] + ) { + setViewControllers(controllers, animated: NavigationAnimation.$isEnabled.get()) + } + + public func pushViewController(_ controller: CocoaViewController) { + pushViewController(controller, animated: NavigationAnimation.$isEnabled.get()) + } +} /// Disables navigation animations for the duration of the synchronous operation. /// diff --git a/Sources/CombineNavigation/Plugin.swift b/Sources/CombineNavigation/Plugin.swift index a6be8b4..8c1a50c 100644 --- a/Sources/CombineNavigation/Plugin.swift +++ b/Sources/CombineNavigation/Plugin.swift @@ -1,6 +1,5 @@ #if canImport(UIKit) && !os(watchOS) import CocoaAliases -import CombineNavigationMacros @attached( extension, diff --git a/Sources/CombineNavigation/RoutingController.swift b/Sources/CombineNavigation/RoutingController.swift index ea0c3cb..0b430b4 100644 --- a/Sources/CombineNavigation/RoutingController.swift +++ b/Sources/CombineNavigation/RoutingController.swift @@ -25,19 +25,9 @@ extension RoutingController { ) } - public func destinations( - _ mapping: @escaping (Destinations, Route) -> CocoaViewController? - ) -> (Route) -> CocoaViewController? - where Route: ExpressibleByNilLiteral { - Self._mapNavigationDestinations( - _makeDestinations(), - mapping - ) - } - - public func destinations( - _ mapping: @escaping (Destinations, Route, Int) -> CocoaViewController - ) -> (Route, Int) -> CocoaViewController { + public func destinations( + _ mapping: @escaping (Destinations, Route, ID) -> CocoaViewController + ) -> (Route, ID) -> CocoaViewController { Self._mapNavigationDestinations( _makeDestinations(), mapping diff --git a/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift index 57c3262..be87927 100644 --- a/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift +++ b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift @@ -88,7 +88,7 @@ extension RoutingControllerMacro: ExtensionMacro { var destinationsStructDecl: DeclSyntax = """ \(raw: destinationStructDocComment) public struct Destinations { - public subscript(_ id: some Hashable) -> CocoaViewController? { + public subscript(_ id: some Hashable) -> UIViewController? { return nil } } @@ -157,7 +157,7 @@ extension RoutingControllerMacro: ExtensionMacro { let destinationsStructSubscriptDecl: DeclSyntax = """ - \npublic subscript(_ id: \(raw: inputType)) -> CocoaViewController? { + \npublic subscript(_ id: \(raw: inputType)) -> UIViewController? { return \(raw: stackDestinationsCoalecing) } """ @@ -167,7 +167,7 @@ extension RoutingControllerMacro: ExtensionMacro { )) } else { let destinationsStructSubscriptDecl: DeclSyntax = """ - \npublic subscript(_ id: some Hashable) -> CocoaViewController? { + \npublic subscript(_ id: some Hashable) -> UIViewController? { return nil } """ diff --git a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift index f3c695e..1708f7e 100644 --- a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift +++ b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift @@ -77,7 +77,7 @@ final class RoutingControllerMacroTests: XCTestCase { /// Use in `navigationDestination`/`navigationStack` methods to map /// routes to specific destinations using `destinations` method public struct Destinations { - public subscript(_ id: some Hashable) -> CocoaViewController? { + public subscript(_ id: some Hashable) -> UIViewController? { return nil } } @@ -124,7 +124,7 @@ final class RoutingControllerMacroTests: XCTestCase { @TreeDestination var secondDetailController: CocoaViewController? - public subscript(_ id: some Hashable) -> CocoaViewController? { + public subscript(_ id: some Hashable) -> UIViewController? { return nil } } @@ -174,7 +174,7 @@ final class RoutingControllerMacroTests: XCTestCase { @StackDestination var secondDetailController: [Int: CocoaViewController] - public subscript(_ id: Int) -> CocoaViewController? { + public subscript(_ id: Int) -> UIViewController? { return firstDetailController[id] ?? secondDetailController[id] } @@ -248,7 +248,7 @@ final class RoutingControllerMacroTests: XCTestCase { @CustomDestination var secondDetailController: CocoaViewController? - public subscript(_ id: some Hashable) -> CocoaViewController? { + public subscript(_ id: some Hashable) -> UIViewController? { return nil } } diff --git a/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift b/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift index 5b8a314..0eea279 100644 --- a/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift +++ b/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift @@ -8,7 +8,7 @@ final class StackDestinationTests: XCTestCase { @StackDestination var sut: [AnyHashable: CustomViewController] - @StackDestination({ .init(value: 1) }) + @StackDestination({ _ in .init(value: 1) }) var configuredSUT: [AnyHashable: CustomViewController] var mergedNavigationStack: [CustomViewController] = [] @@ -66,7 +66,7 @@ final class StackDestinationTests: XCTestCase { @CustomStackDestination var sut: [AnyHashable: CustomViewController] - @CustomStackDestination({ .init(value: 2) }) + @CustomStackDestination({ _ in .init(value: 2) }) var configuredSUT: [AnyHashable: CustomViewController] XCTAssertEqual(_sut[0].value, 1) @@ -123,7 +123,10 @@ fileprivate final class CustomStackDestination< /// /// `CombineNavigation` should be imported as `@_spi(Internal) import` /// to override this declaration - override func configureController(_ controller: Controller) { + override func configureController( + _ controller: Controller, + for id: StackElementID + ) { controller.isConfiguredByCustomNavigationChild = true } @@ -133,7 +136,9 @@ fileprivate final class CustomStackDestination< /// /// `CombineNavigation` should be imported as `@_spi(Internal) import` /// to override this declaration - override class func initController() -> Controller { + override class func initController( + for id: StackElementID + ) -> Controller { .init(value: 1) } } diff --git a/Tests/CombineNavigationTests/NavigationAnimationTests.swift b/Tests/CombineNavigationTests/NavigationAnimationTests.swift new file mode 100644 index 0000000..fc275e7 --- /dev/null +++ b/Tests/CombineNavigationTests/NavigationAnimationTests.swift @@ -0,0 +1,231 @@ +import XCTest +import CocoaAliases +import Capture +import Combine +@testable import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) + +extension XCTestCase { + @discardableResult + func withExpectation( + timeout: TimeInterval, + execute operation: (XCTestExpectation) -> T + ) -> T { + let expectation = XCTestExpectation() + let result = operation(expectation) + wait(for: [expectation], timeout: timeout) + return result + } + + func withExpectations( + timeout: TimeInterval, + execute operations: ((XCTestExpectation) -> Void)... + ) { + withExpectations( + timeout: timeout, + execute: operations[...] + ) + } + + private func withExpectations( + timeout: TimeInterval, + execute operations: ArraySlice<(XCTestExpectation) -> Void> + ) { + guard let operation = operations.first else { return } + withExpectation(timeout: timeout, execute: operation) + withExpectations(timeout: timeout, execute: operations.dropFirst()) + } +} + +final class NavigationAnimationTests: XCTestCase { + func expectationWithoutAnimation( + delay nanoseconds: UInt64 = 1_000_000_000, + execute operation: @escaping (XCTestExpectation) -> Void + ) -> (XCTestExpectation) -> Void { + return { expectation in + _ = withoutNavigationAnimation { + // Escape out of context, won't work with dispach queues unfortunately + Task { @MainActor in + // Wait for 1 second + try await Task.sleep(nanoseconds: nanoseconds) + operation(expectation) + } + } + } + } + + func testAnimationWithTaskDelayUIKitOnly() { + let rootController = CocoaViewController() + let navigationController = UINavigationController(rootViewController: rootController) + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === rootController) + + withExpectations( + timeout: 1.5, + execute: expectationWithoutAnimation { expectation in + let destinationController = CocoaViewController() + navigationController.pushViewController(destinationController) + + // Fails if animation is enabled + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === destinationController) + expectation.fulfill() + }, + expectationWithoutAnimation { expectation in + navigationController.popViewController() + + // Fails if animation is enabled + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === rootController) + expectation.fulfill() + }, + expectationWithoutAnimation { expectation in + let destinationController = CocoaViewController() + navigationController.pushViewController(CocoaViewController()) + navigationController.pushViewController(CocoaViewController()) + navigationController.pushViewController(destinationController) + + // Fails if animation is enabled + XCTAssertEqual(navigationController.viewControllers.count, 4) + XCTAssert(navigationController.topViewController === destinationController) + expectation.fulfill() + }, + expectationWithoutAnimation { expectation in + let destinationController = navigationController.viewControllers[3] + navigationController.popToViewController(destinationController) + + // Fails if animation is enabled + XCTAssertEqual(navigationController.viewControllers.count, 4) + XCTAssert(navigationController.topViewController === destinationController) + expectation.fulfill() + }, + expectationWithoutAnimation { expectation in + let destinationController = CocoaViewController() + navigationController.setViewControllers([rootController, destinationController]) + + // Fails if animation is enabled + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === destinationController) + expectation.fulfill() + } + ) + } + + func testAnimationWithTaskDelay() { + let viewModel = TreeViewModel() + let controller = TreeViewController() + let navigationController = UINavigationController(rootViewController: controller) + controller.viewModel = viewModel + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + + withExpectation( + timeout: 1.5, + execute: expectationWithoutAnimation { expectation in + // Fails if animation is enabled + viewModel.state.value.destination = .feedback() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.feedbackController) + expectation.fulfill() + } + ) + } + + func testAnimationPublisher() { + let viewModel = TreeViewModel() + let controller = TreeViewController() + let navigationController = UINavigationController(rootViewController: controller) + + // Disable animations using publisher + viewModel.animationsDisabled = true + controller.viewModel = viewModel + + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + + // Fails if animation is enabled + viewModel.state.value.destination = .feedback() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.feedbackController) + } +} + +fileprivate let testDestinationID = UUID() + +fileprivate class OrderDetailsController: CocoaViewController {} +fileprivate class FeedbackController: CocoaViewController {} + +// MARK: - Tree + +fileprivate class TreeViewModel { + struct State { + enum Destination: Equatable { + /// UUID represents some state + case orderDetail(UUID = testDestinationID) + case feedback(UUID = testDestinationID) + + enum Tag: Hashable { + case orderDetail + case feedback + } + + var tag: Tag { + switch self { + case .orderDetail: return .orderDetail + case .feedback: return .feedback + } + } + } + + var destination: Destination? + } + + let state = CurrentValueSubject(.init()) + var animationsDisabled: Bool = false + + var publisher: some Publisher { + animationsDisabled + ? state + .withNavigationAnimation(false) + .eraseToAnyPublisher() + : state + .eraseToAnyPublisher() + } +} + +@RoutingController +fileprivate class TreeViewController: CocoaViewController { + private var cancellables: Set = [] + + var viewModel: TreeViewModel! { + didSet { bind(viewModel.publisher) } + } + + @TreeDestination + var orderDetailController: OrderDetailsController? + + @TreeDestination + var feedbackController: FeedbackController? + + func bind>(_ publisher: P) { + navigationDestination( + publisher.map(\.destination?.tag).removeDuplicates(), + switch: destinations { destinations, route in + switch route { + case .orderDetail: + destinations.$orderDetailController() + case .feedback: + destinations.$feedbackController() + } + }, + onPop: capture { _self in + _self.viewModel.state.value.destination = .none + } + ) + .store(in: &cancellables) + } +} +#endif diff --git a/Tests/CombineNavigationTests/RoutingControllerStackTests.swift b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift new file mode 100644 index 0000000..6ad63c1 --- /dev/null +++ b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift @@ -0,0 +1,200 @@ +import XCTest +import CocoaAliases +import Capture +import Combine +@testable import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) + +final class RoutingControllerStackTests: XCTestCase { + func testNavigationStack() { + let viewModel = StackViewModel() + let controller = StackViewController() + let navigationController = UINavigationController(rootViewController: controller) + controller.viewModel = viewModel + + // Disable navigation animation for tests + withoutNavigationAnimation { + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + + viewModel.state.value.path.append(.feedback()) + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.$feedbackControllers[0]) + + viewModel.state.value.path.append(.orderDetail()) + XCTAssertEqual(navigationController.viewControllers.count, 3) + XCTAssert(navigationController.topViewController === controller.$orderDetailControllers[1]) + + viewModel.state.value.path.append(.feedback()) + XCTAssertEqual(navigationController.viewControllers.count, 4) + XCTAssert(navigationController.topViewController === controller.$feedbackControllers[2]) + + viewModel.state.value.path.removeAll() + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + + viewModel.state.value.path.append(.feedback()) + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.$feedbackControllers[0]) + + viewModel.state.value.path.append(.orderDetail()) + XCTAssertEqual(navigationController.viewControllers.count, 3) + XCTAssert(navigationController.topViewController === controller.$orderDetailControllers[1]) + + _ = viewModel.state.value.path.popLast() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.$feedbackControllers[0]) + + viewModel.state.value.path.append(.orderDetail()) + XCTAssertEqual(navigationController.viewControllers.count, 3) + XCTAssert(navigationController.topViewController === controller.$orderDetailControllers[1]) + + // pop + XCTAssertEqual(viewModel.state.value.path.count, 2) + navigationController.popViewController(animated: false) + XCTAssertEqual(viewModel.state.value.path.count, 1) + + // popTo + viewModel.state.value.path = [.feedback(), .feedback(), .orderDetail(), .feedback(), .orderDetail()] + XCTAssertEqual(navigationController.viewControllers.count, 6) + + navigationController.popToViewController(controller, animated: false) + XCTAssertEqual(viewModel.state.value.path.count, 0) + XCTAssertEqual(navigationController.viewControllers.count, 1) + + // popToRoot + viewModel.state.value.path = [.feedback(), .feedback(), .orderDetail(), .feedback(), .orderDetail()] + XCTAssertEqual(navigationController.viewControllers.count, 6) + + navigationController.popToRootViewController(animated: false) + XCTAssertEqual(viewModel.state.value.path.count, 0) + XCTAssertEqual(navigationController.viewControllers.count, 1) + } + } + + func testNavigationStackDestinations() { + let viewModel = StackViewModel() + let controller = StackViewController() + let navigationController = UINavigationController(rootViewController: controller) + controller.viewModel = viewModel + + // Disable navigation animation for tests + withoutNavigationAnimation { + viewModel.state.value.path = [.feedback(), .feedback(), .orderDetail(), .feedback(), .orderDetail()] + XCTAssertEqual(navigationController.viewControllers.count, 6) + + let destinations = controller._makeDestinations() + + XCTAssert(zip(navigationController.viewControllers, [ + controller, + controller.feedbackControllers[0], + controller.feedbackControllers[1], + controller.orderDetailControllers[2], + controller.feedbackControllers[3], + controller.orderDetailControllers[4], + ]).allSatisfy(===)) + + XCTAssert(zip(navigationController.viewControllers, [ + controller, + controller.$feedbackControllers[0], + controller.$feedbackControllers[1], + controller.$orderDetailControllers[2], + controller.$feedbackControllers[3], + controller.$orderDetailControllers[4], + ]).allSatisfy(===)) + + XCTAssert(zip(navigationController.viewControllers, [ + controller, + destinations.feedbackControllers[0], + destinations.feedbackControllers[1], + destinations.orderDetailControllers[2], + destinations.feedbackControllers[3], + destinations.orderDetailControllers[4], + ]).allSatisfy(===)) + + XCTAssert(zip(navigationController.viewControllers, [ + controller, + destinations.$feedbackControllers[0], + destinations.$feedbackControllers[1], + destinations.$orderDetailControllers[2], + destinations.$feedbackControllers[3], + destinations.$orderDetailControllers[4], + ]).allSatisfy(===)) + + XCTAssert(zip(navigationController.viewControllers, [ + controller, + destinations[0], + destinations[1], + destinations[2], + destinations[3], + destinations[4], + ]).allSatisfy(===)) + } + } +} + +fileprivate let testDestinationID = UUID() + +fileprivate class OrderDetailsController: CocoaViewController {} +fileprivate class FeedbackController: CocoaViewController {} + +fileprivate class StackViewModel { + struct State { + enum Destination { + /// UUID represents some state + case orderDetail(UUID = testDestinationID) + case feedback(UUID = testDestinationID) + + enum Tag: Hashable { + case orderDetail + case feedback + } + + var tag: Tag { + switch self { + case .orderDetail: return .orderDetail + case .feedback: return .feedback + } + } + } + + var path: [Destination] = [] + } + + let state = CurrentValueSubject(.init()) +} + +@RoutingController +fileprivate class StackViewController: CocoaViewController { + private var cancellables: Set = [] + + var viewModel: StackViewModel! { + didSet { bind(viewModel.state) } + } + + @StackDestination + var orderDetailControllers: [Int: OrderDetailsController] + + @StackDestination + var feedbackControllers: [Int: FeedbackController] + + func bind>(_ publisher: P) { + navigationStack( + publisher.map(\.path).map { $0.map(\.tag) }.removeDuplicates(), + switch: destinations { destinations, route, index in + switch route { + case .orderDetail: + destinations.$orderDetailControllers[index] + case .feedback: + destinations.$feedbackControllers[index] + } + }, + onPop: capture { _self, indices in + _self.viewModel.state.value.path.remove(atOffsets: IndexSet(indices)) + } + ) + .store(in: &cancellables) + } +} +#endif diff --git a/Tests/CombineNavigationTests/RoutingControllerTests.swift b/Tests/CombineNavigationTests/RoutingControllerTests.swift index 1779238..63460ad 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTests.swift @@ -2,256 +2,127 @@ import XCTest import CocoaAliases import Capture import Combine +import CombineSchedulers @testable import CombineNavigation + #if canImport(UIKit) && !os(watchOS) import SwiftUI -#warning("TODO: Add test for `navigationStack(_:ids:route:switch:onDismiss:)`") -#warning("TODO: Add test for `navigationDestination(_:isPresented:controller:onDismiss:)`") -final class RoutingControllerTests: XCTestCase { - func testAnimation() { - let viewModel = TreeViewModel() - let controller = TreeViewController() - let navigationController = UINavigationController(rootViewController: controller) - controller.viewModel = viewModel - - XCTAssertEqual(navigationController.viewControllers.count, 1) - XCTAssert(navigationController.topViewController === controller) - - let expectation = XCTestExpectation() - _ = withoutNavigationAnimation { - // Escape out of context, won't work with dispach queues unfortunately - Task { @MainActor in - // Wait for 1 second - try await Task.sleep(nanoseconds: 1_000_000_000) - - // Fails if animation is enabled - viewModel.state.value.destination = .feedback() - XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.topViewController === controller.feedbackController) - expectation.fulfill() - } - } - - wait(for: [expectation], timeout: 1.5) - } - - func testAnimationPublisher() { - let viewModel = TreeViewModel() - let controller = TreeViewController() - let navigationController = UINavigationController(rootViewController: controller) - - // Disable animations using publisher - viewModel.animationsDisabled = true - controller.viewModel = viewModel - - XCTAssertEqual(navigationController.viewControllers.count, 1) - XCTAssert(navigationController.topViewController === controller) - - // Fails if animation is enabled - viewModel.state.value.destination = .feedback() - XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.topViewController === controller.feedbackController) - } - - func testNavigationTree() { - let viewModel = TreeViewModel() - let controller = TreeViewController() - let navigationController = UINavigationController(rootViewController: controller) - controller.viewModel = viewModel - - // Disable navigation animation for tests - withoutNavigationAnimation { - XCTAssertEqual(navigationController.viewControllers.count, 1) - XCTAssert(navigationController.topViewController === controller) - - viewModel.state.value.destination = .feedback() - XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.topViewController === controller.feedbackController) - - viewModel.state.value.destination = .orderDetail() - XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.topViewController === controller.orderDetailController) - - navigationController.popViewController(animated: false) - XCTAssertEqual(viewModel.state.value.destination, .none) - - viewModel.state.value.destination = .feedback() - XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.topViewController === controller.feedbackController) - - viewModel.state.value.destination = .none - XCTAssertEqual(navigationController.viewControllers.count, 1) - XCTAssert(navigationController.topViewController === controller) - } - } - - func testNavigationStack() { - let viewModel = StackViewModel() - let controller = StackViewController() - let navigationController = UINavigationController(rootViewController: controller) - controller.viewModel = viewModel - - // Disable navigation animation for tests - withoutNavigationAnimation { - XCTAssertEqual(navigationController.viewControllers.count, 1) - XCTAssert(navigationController.topViewController === controller) - - viewModel.state.value.path.append(.feedback()) - XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.topViewController === controller.$feedbackControllers[0]) - viewModel.state.value.path.append(.orderDetail()) - XCTAssertEqual(navigationController.viewControllers.count, 3) - XCTAssert(navigationController.topViewController === controller.$orderDetailControllers[1]) - - viewModel.state.value.path.append(.feedback()) - XCTAssertEqual(navigationController.viewControllers.count, 4) - XCTAssert(navigationController.topViewController === controller.$feedbackControllers[2]) - - viewModel.state.value.path.removeAll() - XCTAssertEqual(navigationController.viewControllers.count, 1) - XCTAssert(navigationController.topViewController === controller) - - viewModel.state.value.path.append(.feedback()) - XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.topViewController === controller.$feedbackControllers[0]) - - viewModel.state.value.path.append(.orderDetail()) - XCTAssertEqual(navigationController.viewControllers.count, 3) - XCTAssert(navigationController.topViewController === controller.$orderDetailControllers[1]) - - _ = viewModel.state.value.path.popLast() - XCTAssertEqual(navigationController.viewControllers.count, 2) - XCTAssert(navigationController.topViewController === controller.$feedbackControllers[0]) - - viewModel.state.value.path.append(.orderDetail()) - XCTAssertEqual(navigationController.viewControllers.count, 3) - XCTAssert(navigationController.topViewController === controller.$orderDetailControllers[1]) - - // pop - XCTAssertEqual(viewModel.state.value.path.count, 2) - navigationController.popViewController(animated: false) - XCTAssertEqual(viewModel.state.value.path.count, 1) - - // popTo - viewModel.state.value.path = [.feedback(), .feedback(), .orderDetail(), .feedback(), .orderDetail()] - XCTAssertEqual(navigationController.viewControllers.count, 6) - - navigationController.popToViewController(controller, animated: false) - XCTAssertEqual(viewModel.state.value.path.count, 0) - XCTAssertEqual(navigationController.viewControllers.count, 1) - - // popToRoot - viewModel.state.value.path = [.feedback(), .feedback(), .orderDetail(), .feedback(), .orderDetail()] - XCTAssertEqual(navigationController.viewControllers.count, 6) - - navigationController.popToRootViewController(animated: false) - XCTAssertEqual(viewModel.state.value.path.count, 0) - XCTAssertEqual(navigationController.viewControllers.count, 1) - } - } - - func testNavigationStackDestinations() { - let viewModel = StackViewModel() - let controller = StackViewController() - let navigationController = UINavigationController(rootViewController: controller) - controller.viewModel = viewModel +// TODO: Add test for `navigationStack(_:ids:route:switch:onDismiss:)`") +// TODO: Add test for `navigationDestination(_:isPresented:controller:onDismiss:)`") +final class RoutingControllerTests: XCTestCase { + func testMain() { + let root = StackViewController() + let viewModel = StackViewModel(initialState: .init()) + let navigation = UINavigationController(rootViewController: root) + navigation.loadViewIfNeeded() + root.loadViewIfNeeded() + root.viewModel = viewModel - // Disable navigation animation for tests withoutNavigationAnimation { - viewModel.state.value.path = [.feedback(), .feedback(), .orderDetail(), .feedback(), .orderDetail()] - XCTAssertEqual(navigationController.viewControllers.count, 6) - - let destinations = controller._makeDestinations() - - XCTAssert(zip(navigationController.viewControllers, [ - controller, - controller.feedbackControllers[0], - controller.feedbackControllers[1], - controller.orderDetailControllers[2], - controller.feedbackControllers[3], - controller.orderDetailControllers[4], - ]).allSatisfy(===)) - - XCTAssert(zip(navigationController.viewControllers, [ - controller, - controller.$feedbackControllers[0], - controller.$feedbackControllers[1], - controller.$orderDetailControllers[2], - controller.$feedbackControllers[3], - controller.$orderDetailControllers[4], - ]).allSatisfy(===)) - - XCTAssert(zip(navigationController.viewControllers, [ - controller, - destinations.feedbackControllers[0], - destinations.feedbackControllers[1], - destinations.orderDetailControllers[2], - destinations.feedbackControllers[3], - destinations.orderDetailControllers[4], - ]).allSatisfy(===)) - - XCTAssert(zip(navigationController.viewControllers, [ - controller, - destinations.$feedbackControllers[0], - destinations.$feedbackControllers[1], - destinations.$orderDetailControllers[2], - destinations.$feedbackControllers[3], - destinations.$orderDetailControllers[4], - ]).allSatisfy(===)) - - XCTAssert(zip(navigationController.viewControllers, [ - controller, - destinations[0], - destinations[1], - destinations[2], - destinations[3], - destinations[4], - ]).allSatisfy(===)) + XCTAssertEqual(navigation.viewControllers.count, 1) + + root.viewModel.state.root.state.destination = .tree(.init( + initialState: .init(destination: .tree(.init( + initialState: .init(destination: .tree(.init( + initialState: .init() + ))) + ))) + )) + XCTAssertEqual(navigation.viewControllers.count, 4) + + root.viewModel.state.path.append(.tree(.init( + initialState: .init() + ))) + XCTAssertEqual(navigation.viewControllers.count, 5) + + navigation.popViewController() + XCTAssertEqual(navigation.viewControllers.count, 4) + XCTAssertEqual(root.viewModel.state.path.count, 0) + XCTAssertNotNil( + root.viewModel.state.root + .state.destination?.tree? + .state.destination?.tree? + .state.destination?.tree + ) + + root.viewModel.state.root + .state.destination?.tree? + .state.destination = .tree(.init(initialState: .init())) + + XCTAssertNil( + root.viewModel.state.root + .state.destination?.tree? + .state.destination?.tree? + .state.destination?.tree + ) + + XCTAssertEqual(navigation.viewControllers.count, 3) + + navigation.popViewController() + XCTAssertNil( + root.viewModel.state.root + .state.destination?.tree? + .state.destination?.tree + ) } } } -fileprivate let testDestinationID = UUID() - -fileprivate class OrderDetailsController: CocoaViewController {} -fileprivate class FeedbackController: CocoaViewController {} - -// MARK: - Tree - fileprivate class TreeViewModel { struct State { - enum Destination: Equatable { + enum Destination { /// UUID represents some state - case orderDetail(UUID = testDestinationID) - case feedback(UUID = testDestinationID) + case tree(TreeViewModel) + case stack(StackViewModel) enum Tag: Hashable { - case orderDetail - case feedback + case tree + case stack + } + + var tree: TreeViewModel? { + switch self { + case let .tree(viewModel): + viewModel + default: + nil + } + } + + var stack: StackViewModel? { + switch self { + case let .stack(viewModel): + viewModel + default: + nil + } } var tag: Tag { switch self { - case .orderDetail: return .orderDetail - case .feedback: return .feedback + case .tree: return .tree + case .stack: return .stack } } } + var id: UUID = .init() var destination: Destination? } - let state = CurrentValueSubject(.init()) - var animationsDisabled: Bool = false + init(initialState: State) { + self._state = .init(initialState) + } + + private let _state: CurrentValueSubject + public var state: State { + get { _state.value } + set { _state.value = newValue } + } var publisher: some Publisher { - animationsDisabled - ? state - .withNavigationAnimation(false) - .eraseToAnyPublisher() - : state - .eraseToAnyPublisher() + _state } } @@ -260,30 +131,55 @@ fileprivate class TreeViewController: CocoaViewController { private var cancellables: Set = [] var viewModel: TreeViewModel! { - didSet { bind(viewModel.publisher) } + didSet { + cancellables = [] + guard let viewModel else { return } + bind(viewModel.publisher) + } } @TreeDestination - var orderDetailController: OrderDetailsController? + var treeController: TreeViewController? @TreeDestination - var feedbackController: FeedbackController? + var stackController: StackViewController? + + func scope(_ viewModel: TreeViewModel?) { + $treeController.setConfiguration { controller in + controller.viewModel = viewModel?.state.destination?.tree + } + + $stackController.setConfiguration { controller in + controller.viewModel = viewModel?.state.destination?.stack + } + } + + func bind( + _ publisher: some Publisher + ) { + publisher.map(\.destination).removeDuplicates { lhs, rhs in + lhs.flatMap(\.tree).map(ObjectIdentifier.init) + == rhs.flatMap(\.tree).map(ObjectIdentifier.init) + && + lhs.flatMap(\.stack).map(ObjectIdentifier.init) + == rhs.flatMap(\.stack).map(ObjectIdentifier.init) + }.sinkValues(capture { _self, destination in + self.scope(_self.viewModel) + }) + .store(in: &cancellables) - func bind>(_ publisher: P) { navigationDestination( publisher.map(\.destination?.tag).removeDuplicates(), switch: destinations { destinations, route in switch route { - case .orderDetail: - destinations.$orderDetailController() - case .feedback: - destinations.$feedbackController() - case .none: - nil + case .tree: + destinations.$treeController() + case .stack: + destinations.$stackController() } }, - onDismiss: capture { _self in - _self.viewModel.state.value.destination = .none + onPop: capture { _self in + _self.viewModel.state.destination = .none } ) .store(in: &cancellables) @@ -296,26 +192,57 @@ fileprivate class StackViewModel { struct State { enum Destination { /// UUID represents some state - case orderDetail(UUID = testDestinationID) - case feedback(UUID = testDestinationID) + case tree(TreeViewModel) + case stack(StackViewModel) enum Tag: Hashable { - case orderDetail - case feedback + case tree + case stack + } + + var tree: TreeViewModel? { + switch self { + case let .tree(viewModel): + viewModel + default: + nil + } + } + + var stack: StackViewModel? { + switch self { + case let .stack(viewModel): + viewModel + default: + nil + } } var tag: Tag { switch self { - case .orderDetail: return .orderDetail - case .feedback: return .feedback + case .tree: return .tree + case .stack: return .stack } } } + var root: TreeViewModel = .init(initialState: .init()) var path: [Destination] = [] } - let state = CurrentValueSubject(.init()) + init(initialState: State) { + self._state = .init(initialState) + } + + private let _state: CurrentValueSubject + public var state: State { + get { _state.value } + set { _state.value = newValue } + } + + var publisher: some Publisher { + _state + } } @RoutingController @@ -323,28 +250,68 @@ fileprivate class StackViewController: CocoaViewController { private var cancellables: Set = [] var viewModel: StackViewModel! { - didSet { bind(viewModel.state) } + didSet { + cancellables = [] + guard let viewModel else { return } + bind(viewModel.publisher) + } } + var contentController: TreeViewController = .init() + @StackDestination - var orderDetailControllers: [Int: OrderDetailsController] + var treeControllers: [Int: TreeViewController] @StackDestination - var feedbackControllers: [Int: FeedbackController] + var stackControllers: [Int: StackViewController] + + override func viewDidLoad() { + super.viewDidLoad() + addRoutedChild(contentController) + view.addSubview(contentController.view) + contentController.view.frame = view.bounds + contentController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + contentController.didMove(toParent: self) + } + + func scope(_ viewModel: StackViewModel?) { + contentController.viewModel = viewModel?.state.root + + $treeControllers.setConfiguration { controller, id in + controller.viewModel = viewModel?.state.path[safe: id]?.tree + } + + $stackControllers.setConfiguration { controller, id in + controller.viewModel = viewModel?.state.path[safe: id]?.stack + } + } + + func bind( + _ publisher: some Publisher + ) { + publisher.map(\.path).removeDuplicates { lhs, rhs in + lhs.compactMap(\.tree).map(ObjectIdentifier.init) + == rhs.compactMap(\.tree).map(ObjectIdentifier.init) + && + lhs.compactMap(\.stack).map(ObjectIdentifier.init) + == rhs.compactMap(\.stack).map(ObjectIdentifier.init) + }.sinkValues(capture { _self, destination in + self.scope(_self.viewModel) + }) + .store(in: &cancellables) - func bind>(_ publisher: P) { navigationStack( publisher.map(\.path).map { $0.map(\.tag) }.removeDuplicates(), - switch: destinations { destinations, route, index in + switch: destinations { destination, route, index in switch route { - case .orderDetail: - destinations.$orderDetailControllers[index] - case .feedback: - destinations.$feedbackControllers[index] + case .tree: + destination.$treeControllers[index] + case .stack: + destination.$stackControllers[index] } }, - onDismiss: capture { _self, indices in - _self.viewModel.state.value.path.remove(atOffsets: IndexSet(indices)) + onPop: capture { _self, indices in + _self.viewModel.state.path.remove(atOffsets: IndexSet(indices)) } ) .store(in: &cancellables) diff --git a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift new file mode 100644 index 0000000..6d22b5a --- /dev/null +++ b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift @@ -0,0 +1,116 @@ +import XCTest +import CocoaAliases +import Capture +import Combine +@testable import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) + +final class RoutingControllerTreeTests: XCTestCase { + func testNavigationTree() { + let viewModel = TreeViewModel() + let controller = TreeViewController() + let navigationController = UINavigationController(rootViewController: controller) + controller.viewModel = viewModel + + // Disable navigation animation for tests + withoutNavigationAnimation { + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + + viewModel.state.value.destination = .feedback() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.feedbackController) + + viewModel.state.value.destination = .orderDetail() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.orderDetailController) + + navigationController.popViewController(animated: false) + XCTAssertEqual(viewModel.state.value.destination, .none) + + viewModel.state.value.destination = .feedback() + XCTAssertEqual(navigationController.viewControllers.count, 2) + XCTAssert(navigationController.topViewController === controller.feedbackController) + + viewModel.state.value.destination = .none + XCTAssertEqual(navigationController.viewControllers.count, 1) + XCTAssert(navigationController.topViewController === controller) + } + } +} + +fileprivate let testDestinationID = UUID() + +fileprivate class OrderDetailsController: CocoaViewController {} +fileprivate class FeedbackController: CocoaViewController {} + +fileprivate class TreeViewModel { + struct State { + enum Destination: Equatable { + /// UUID represents some state + case orderDetail(UUID = testDestinationID) + case feedback(UUID = testDestinationID) + + enum Tag: Hashable { + case orderDetail + case feedback + } + + var tag: Tag { + switch self { + case .orderDetail: return .orderDetail + case .feedback: return .feedback + } + } + } + + var destination: Destination? + } + + let state = CurrentValueSubject(.init()) + var animationsDisabled: Bool = false + + var publisher: some Publisher { + animationsDisabled + ? state + .withNavigationAnimation(false) + .eraseToAnyPublisher() + : state + .eraseToAnyPublisher() + } +} + +@RoutingController +fileprivate class TreeViewController: CocoaViewController { + private var cancellables: Set = [] + + var viewModel: TreeViewModel! { + didSet { bind(viewModel.publisher) } + } + + @TreeDestination + var orderDetailController: OrderDetailsController? + + @TreeDestination + var feedbackController: FeedbackController? + + func bind>(_ publisher: P) { + navigationDestination( + publisher.map(\.destination?.tag).removeDuplicates(), + switch: destinations { destinations, route in + switch route { + case .orderDetail: + destinations.$orderDetailController() + case .feedback: + destinations.$feedbackController() + } + }, + onPop: capture { _self in + _self.viewModel.state.value.destination = .none + } + ) + .store(in: &cancellables) + } +} +#endif From eeb8172e71d542566729ee5db9f88acad6c55c64 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 16 Dec 2023 03:25:55 +0100 Subject: [PATCH 05/43] feat(CI): Update script --- .github/workflows/Test.yml | 2 +- Makefile | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index 42eb7a9..5dd6b25 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -12,7 +12,7 @@ jobs: !contains(github.event.head_commit.message, '[ci skip]') && !contains(github.event.head_commit.message, '[ci skip test]') && !contains(github.event.head_commit.message, '[ci skip test_macos]') - runs-on: macOS-12 + runs-on: macOS-13 timeout-minutes: 30 steps: - uses: actions/checkout@v3 diff --git a/Makefile b/Makefile index 781fc7e..a8c2481 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,5 @@ test: - @swift test + xcodebuild \ + -scheme CombineNavigation \ + -destination platform="iOS Simulator,name=iPhone 15 Pro,OS=17.0" \ + test | xcpretty && exit 0 From 24bd60cd561ca69ecf0be27e7a571cd6580d2280 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 16 Dec 2023 03:26:24 +0100 Subject: [PATCH 06/43] cleanup: Remove warnings --- Package.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Package.swift b/Package.swift index c9c6d7c..9c2c6e2 100644 --- a/Package.swift +++ b/Package.swift @@ -3,13 +3,6 @@ import PackageDescription import CompilerPluginSupport -#warning("TODO: Add rich example") -// The example is WIP, it's a simple twitter-like app -// but already has examples for Tree-based and recursive Tree-based -// navigation. Stack-based navigation and basic deeplinking is planned -// -// Do not forget to add it to repo before publishing a release ^^ - let package = Package( name: "combine-cocoa-navigation", platforms: [ From e64b46efdccd035405592df5ef619a9260bb3e0b Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 16 Dec 2023 04:20:36 +0100 Subject: [PATCH 07/43] feat: Explicit swizzling --- README.md | 22 +++++++++++++++++++ Sources/CombineNavigation/Bootstrap.swift | 7 ++++++ ...UINavigationController+PopPublisher.swift} | 9 +------- 3 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 Sources/CombineNavigation/Bootstrap.swift rename Sources/CombineNavigation/{Internal/UINavigationController+.swift => UINavigationController+PopPublisher.swift} (95%) diff --git a/README.md b/README.md index 3aa2dc2..a150525 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,28 @@ This library was primarely created for [TCA](https://github.com/pointfreeco/swift-composable-archtiecture) navigation with Cocoa. However it's geneic enough to use with pure combine. But to dive more into general understanding of stack-based and tree based navigation take a look at TCA docs. +### Setup + +It's **extremely important** to call `bootstrap()` function in the beginning of your app's lifecycle to perform required swizzling for enabling `UINavigationController.popPublisher()` + +```swift +import UIKit +import CombineNavigation + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [ + UIApplication.LaunchOptionsKey: Any + ]? + ) -> Bool { + CombineNavigation.bootstrap() + return true + } +} +``` + ### Tree-based navigation Basically all you need is to call `navigationDestination` method of the viewController, it accepts routing publisher and mapping of the route to the destination controller. Your code may look somewhat like this: diff --git a/Sources/CombineNavigation/Bootstrap.swift b/Sources/CombineNavigation/Bootstrap.swift new file mode 100644 index 0000000..4a6d674 --- /dev/null +++ b/Sources/CombineNavigation/Bootstrap.swift @@ -0,0 +1,7 @@ +#if canImport(UIKit) && !os(watchOS) +import CocoaAliases + +public func bootstrap() { + UINavigationController.swizzle +} +#endif diff --git a/Sources/CombineNavigation/Internal/UINavigationController+.swift b/Sources/CombineNavigation/UINavigationController+PopPublisher.swift similarity index 95% rename from Sources/CombineNavigation/Internal/UINavigationController+.swift rename to Sources/CombineNavigation/UINavigationController+PopPublisher.swift index e008f1d..b1e5bae 100644 --- a/Sources/CombineNavigation/Internal/UINavigationController+.swift +++ b/Sources/CombineNavigation/UINavigationController+PopPublisher.swift @@ -109,7 +109,7 @@ extension UINavigationController { extension UINavigationController { // Runs once in app lifetime - private static let swizzle: Void = { + internal static let swizzle: Void = { objc_exchangeImplementations( #selector(popViewController(animated:)), #selector(__swizzledPopViewController) @@ -131,13 +131,6 @@ extension UINavigationController { ) }() - // Swizzle automatically when the first - // navigationController loads it's view - open override func loadView() { - UINavigationController.swizzle - super.viewDidLoad() - } - @objc dynamic func __swizzledPopViewController( animated: Bool ) -> CocoaViewController? { From 8656b207548d391ff156f9bdd3ae6f79c1b4cae7 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 16 Dec 2023 04:21:11 +0100 Subject: [PATCH 08/43] feat(wip): Example --- .../contents.xcworkspacedata | 7 + .../CombineNavigationExample.xcscheme | 66 ++++ Example/Example.xcodeproj/project.pbxproj | 370 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + Example/Example/AppDelegate.swift | 45 +++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + Example/Example/Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 ++ Example/Example/Info.plist | 23 ++ Example/Example/SceneDelegate.swift | 72 ++++ Example/Package.swift | 38 ++ .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../CurrentUserProfileFeature.swift | 84 ++++ .../CurrentUserProfileView.swift | 79 ++++ .../FeedAndProfileViewController.swift | 1 + .../FeedTabFeature/FeedTabController.swift | 75 ++++ .../FeedTabFeature/FeedTabFeature.swift | 95 +++++ .../MainFeature/MainFeature.swift | 8 + .../Model/FollowerModel.swift | 32 ++ .../Model/TweetModel.swift | 117 ++++++ .../Model/UserModel.swift | 37 ++ .../ProfileFeature/ProfileFeature.swift | 31 ++ .../ProfileFeature/ProfileView.swift | 66 ++++ .../ProfileFeedFeature.swift | 39 ++ .../ProfileFeedFeature/ProfileFeedView.swift | 41 ++ .../ProfileTabFeature/ProfileTabFeature.swift | 72 ++++ .../TweetDetailController.swift | 48 +++ .../TweetDetailFeature.swift | 85 ++++ .../TweetDetailFeature/TweetDetailView.swift | 49 +++ .../TweetFeature/TweetFeature.swift | 97 +++++ .../TweetFeature/TweetView.swift | 46 +++ .../TweetsFeedController.swift | 53 +++ .../TweetsFeedFeature/TweetsFeedFeature.swift | 59 +++ .../TweetsListFeature/TweetsListFeature.swift | 35 ++ .../TweetsListFeature/TweetsListView.swift | 37 ++ .../UserProfileFeature.swift | 65 +++ .../UserProfileFeature/UserProfileView.swift | 65 +++ .../UserSettingsFeature.swift | 23 ++ .../UserSettingsView.swift | 23 ++ .../_Core/ComposableViewController.swift | 81 ++++ .../_Mock/MockThreads.swift | 201 ++++++++++ 44 files changed, 2453 insertions(+) create mode 100644 Example/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigationExample.xcscheme create mode 100644 Example/Example.xcodeproj/project.pbxproj create mode 100644 Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Example/Example/AppDelegate.swift create mode 100644 Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Example/Example/Assets.xcassets/Contents.json create mode 100644 Example/Example/Base.lproj/LaunchScreen.storyboard create mode 100644 Example/Example/Info.plist create mode 100644 Example/Example/SceneDelegate.swift create mode 100644 Example/Package.swift create mode 100644 Example/Project.xcworkspace/contents.xcworkspacedata create mode 100644 Example/Project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileView.swift create mode 100644 Example/Sources/CombineNavigationExample/FeedAndProfileView/FeedAndProfileViewController.swift create mode 100644 Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabController.swift create mode 100644 Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/MainFeature/MainFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/Model/FollowerModel.swift create mode 100644 Example/Sources/CombineNavigationExample/Model/TweetModel.swift create mode 100644 Example/Sources/CombineNavigationExample/Model/UserModel.swift create mode 100644 Example/Sources/CombineNavigationExample/ProfileFeature/ProfileFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/ProfileFeature/ProfileView.swift create mode 100644 Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedView.swift create mode 100644 Example/Sources/CombineNavigationExample/ProfileTabFeature/ProfileTabFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailController.swift create mode 100644 Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailView.swift create mode 100644 Example/Sources/CombineNavigationExample/TweetFeature/TweetFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/TweetFeature/TweetView.swift create mode 100644 Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedController.swift create mode 100644 Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListView.swift create mode 100644 Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileView.swift create mode 100644 Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsFeature.swift create mode 100644 Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsView.swift create mode 100644 Example/Sources/CombineNavigationExample/_Core/ComposableViewController.swift create mode 100644 Example/Sources/CombineNavigationExample/_Mock/MockThreads.swift diff --git a/Example/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/Example/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Example/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigationExample.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigationExample.xcscheme new file mode 100644 index 0000000..bbbd3ee --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigationExample.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj new file mode 100644 index 0000000..269adc4 --- /dev/null +++ b/Example/Example.xcodeproj/project.pbxproj @@ -0,0 +1,370 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + E17092122B048C170026D033 /* CombineNavigationExample in Frameworks */ = {isa = PBXBuildFile; productRef = E17092112B048C170026D033 /* CombineNavigationExample */; }; + E1A59CDA2AFFF5D400E08FF8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A59CD92AFFF5D400E08FF8 /* AppDelegate.swift */; }; + E1A59CDC2AFFF5D400E08FF8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A59CDB2AFFF5D400E08FF8 /* SceneDelegate.swift */; }; + E1A59CE32AFFF5D600E08FF8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E1A59CE22AFFF5D600E08FF8 /* Assets.xcassets */; }; + E1A59CE62AFFF5D600E08FF8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E1A59CE42AFFF5D600E08FF8 /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + E1A59CD62AFFF5D400E08FF8 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + E1A59CD92AFFF5D400E08FF8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + E1A59CDB2AFFF5D400E08FF8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + E1A59CE22AFFF5D600E08FF8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + E1A59CE52AFFF5D600E08FF8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + E1A59CE72AFFF5D600E08FF8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + E1A59CD32AFFF5D400E08FF8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E17092122B048C170026D033 /* CombineNavigationExample in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + E17092102B048C170026D033 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + E1A59CCD2AFFF5D400E08FF8 = { + isa = PBXGroup; + children = ( + E1A59CD82AFFF5D400E08FF8 /* Example */, + E1A59CD72AFFF5D400E08FF8 /* Products */, + E17092102B048C170026D033 /* Frameworks */, + ); + sourceTree = ""; + }; + E1A59CD72AFFF5D400E08FF8 /* Products */ = { + isa = PBXGroup; + children = ( + E1A59CD62AFFF5D400E08FF8 /* Example.app */, + ); + name = Products; + sourceTree = ""; + }; + E1A59CD82AFFF5D400E08FF8 /* Example */ = { + isa = PBXGroup; + children = ( + E1A59CD92AFFF5D400E08FF8 /* AppDelegate.swift */, + E1A59CDB2AFFF5D400E08FF8 /* SceneDelegate.swift */, + E1A59CE22AFFF5D600E08FF8 /* Assets.xcassets */, + E1A59CE42AFFF5D600E08FF8 /* LaunchScreen.storyboard */, + E1A59CE72AFFF5D600E08FF8 /* Info.plist */, + ); + path = Example; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E1A59CD52AFFF5D400E08FF8 /* Example */ = { + isa = PBXNativeTarget; + buildConfigurationList = E1A59CEA2AFFF5D600E08FF8 /* Build configuration list for PBXNativeTarget "Example" */; + buildPhases = ( + E1A59CD22AFFF5D400E08FF8 /* Sources */, + E1A59CD32AFFF5D400E08FF8 /* Frameworks */, + E1A59CD42AFFF5D400E08FF8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Example; + packageProductDependencies = ( + E17092112B048C170026D033 /* CombineNavigationExample */, + ); + productName = Example; + productReference = E1A59CD62AFFF5D400E08FF8 /* Example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + E1A59CCE2AFFF5D400E08FF8 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + E1A59CD52AFFF5D400E08FF8 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = E1A59CD12AFFF5D400E08FF8 /* Build configuration list for PBXProject "Example" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = E1A59CCD2AFFF5D400E08FF8; + packageReferences = ( + ); + productRefGroup = E1A59CD72AFFF5D400E08FF8 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E1A59CD52AFFF5D400E08FF8 /* Example */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + E1A59CD42AFFF5D400E08FF8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E1A59CE62AFFF5D600E08FF8 /* LaunchScreen.storyboard in Resources */, + E1A59CE32AFFF5D600E08FF8 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + E1A59CD22AFFF5D400E08FF8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E1A59CDA2AFFF5D400E08FF8 /* AppDelegate.swift in Sources */, + E1A59CDC2AFFF5D400E08FF8 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + E1A59CE42AFFF5D600E08FF8 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + E1A59CE52AFFF5D600E08FF8 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + E1A59CE82AFFF5D600E08FF8 /* 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 = 17.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"; + }; + name = Debug; + }; + E1A59CE92AFFF5D600E08FF8 /* 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 = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + E1A59CEB2AFFF5D600E08FF8 /* 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_FILE = Example/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; + 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 = dev.capturecontext.Example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E1A59CEC2AFFF5D600E08FF8 /* 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_FILE = Example/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; + 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 = dev.capturecontext.Example; + 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 */ + E1A59CD12AFFF5D400E08FF8 /* Build configuration list for PBXProject "Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E1A59CE82AFFF5D600E08FF8 /* Debug */, + E1A59CE92AFFF5D600E08FF8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + E1A59CEA2AFFF5D600E08FF8 /* Build configuration list for PBXNativeTarget "Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E1A59CEB2AFFF5D600E08FF8 /* Debug */, + E1A59CEC2AFFF5D600E08FF8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + E17092112B048C170026D033 /* CombineNavigationExample */ = { + isa = XCSwiftPackageProductDependency; + productName = CombineNavigationExample; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = E1A59CCE2AFFF5D400E08FF8 /* Project object */; +} diff --git a/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Example/Example/AppDelegate.swift b/Example/Example/AppDelegate.swift new file mode 100644 index 0000000..83984ac --- /dev/null +++ b/Example/Example/AppDelegate.swift @@ -0,0 +1,45 @@ +// +// AppDelegate.swift +// Example +// +// Created by Maxim Krouk on 11.11.2023. +// + +import UIKit +import CombineNavigation + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + CombineNavigation.bootstrap() + return true + } + + // MARK: UISceneSession Lifecycle + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + return UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + } + + func application( + _ application: UIApplication, + didDiscardSceneSessions sceneSessions: Set + ) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, + // this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, + // as they will not return. + } +} + diff --git a/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/Example/Example/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/Example/Example/Assets.xcassets/Contents.json b/Example/Example/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/Example/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/Example/Base.lproj/LaunchScreen.storyboard b/Example/Example/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/Example/Example/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Example/Info.plist b/Example/Example/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/Example/Example/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/Example/Example/SceneDelegate.swift b/Example/Example/SceneDelegate.swift new file mode 100644 index 0000000..adaa2be --- /dev/null +++ b/Example/Example/SceneDelegate.swift @@ -0,0 +1,72 @@ +// +// SceneDelegate.swift +// Example +// +// Created by Maxim Krouk on 11.11.2023. +// + +import UIKit +import ComposableArchitecture +import CombineNavigationExample +import CombineNavigation + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + let store = Store( + initialState: FeedTabFeature.State(feed: .init(list: .init( + uncheckedUniqueElements: TweetModel.mockTweets.filter(\.replyTo.isNil) + .map { .mock(model: $0) } + ))), + reducer: { + FeedTabFeature()._printChanges() + } + ) + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let scene = scene as? UIWindowScene else { return } + let controller = FeedTabController() + controller.setStore(store) + + let window = UIWindow(windowScene: scene) + self.window = window + + window.rootViewController = UINavigationController( + rootViewController: controller + ) + + window.makeKeyAndVisible() + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } +} + diff --git a/Example/Package.swift b/Example/Package.swift new file mode 100644 index 0000000..6b435cd --- /dev/null +++ b/Example/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "CombineNavigationExample", + platforms: [ + .iOS(.v16) + ], + products: [ + .library( + name: "CombineNavigationExample", + targets: ["CombineNavigationExample"] + ), + ], + dependencies: [ + .package(path: ".."), + .package( + url: "https://github.com/pointfreeco/swift-composable-architecture.git", + .upToNextMajor(from: "1.5.0") + ), + ], + targets: [ + .target( + name: "CombineNavigationExample", + dependencies: [ + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + .product( + name: "CombineNavigation", + package: "combine-cocoa-navigation" + ) + ] + ) + ] +) diff --git a/Example/Project.xcworkspace/contents.xcworkspacedata b/Example/Project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..774dbcb --- /dev/null +++ b/Example/Project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Example/Project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/Project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Example/Project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileFeature.swift b/Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileFeature.swift new file mode 100644 index 0000000..0439964 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileFeature.swift @@ -0,0 +1,84 @@ +import ComposableArchitecture +import FoundationExtensions + +@Reducer +public struct CurrentUserProfileFeature { + public init() {} + + @Reducer + public struct Destination { + public enum State: Equatable { + case avatarPreivew(URL) + case userSettings(UserSettingsFeature.State) + } + + public enum Action: Equatable { + case avatarPreivew(Never) + case userSettings(UserSettingsFeature.Action) + } + + public var body: some ReducerOf { + Scope( + state: \.avatarPreivew, + action: \.avatarPreivew, + child: EmptyReducer.init + ) + Scope( + state: \.userSettings, + action: \.userSettings, + child: UserSettingsFeature.init + ) + } + } + + public struct State: Equatable { + public var model: UserModel + public var tweets: TweetsListFeature.State + + @PresentationState + public var destination: Destination.State? + + public init( + model: UserModel, + tweets: TweetsListFeature.State = [], + destination: Destination.State? = nil + ) { + self.model = model + self.tweets = tweets + self.destination = destination + } + } + + @CasePathable + public enum Action: Equatable { + case destination(PresentationAction) + case tweetsList(TweetsListFeature.Action) + case tapOnAvatar + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .tapOnAvatar: + guard let avatarURL = state.model.avatarURL + else { return .none} + state.destination = .avatarPreivew(avatarURL) + return .none + + default: + return .none + } + } + .ifLet( + \State.$destination, + action: \.destination, + destination: Destination.init + ) + + Scope( + state: \State.tweets, + action: \.tweetsList, + child: TweetsListFeature.init + ) + } +} diff --git a/Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileView.swift b/Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileView.swift new file mode 100644 index 0000000..ce673af --- /dev/null +++ b/Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileView.swift @@ -0,0 +1,79 @@ +import SwiftUI +import ComposableArchitecture + +public struct CurrentUserProfileView: View { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView(.vertical) { + headerView + .padding(.vertical, 32) + Divider() + .padding(.bottom, 32) + tweetsView + } + } + + @ViewBuilder + var headerView: some View { + VStack(spacing: 24) { + WithViewStore(store, observe: \.model.avatarURL) { viewStore in + Circle() + .fill(Color(.label).opacity(0.3)) + .frame(width: 86, height: 86) + .onTapGesture { + viewStore.send(.tapOnAvatar) + } + } + WithViewStore(store, observe: \.model.username) { viewStore in + Text("@" + viewStore.state.lowercased()) + .monospaced() + .bold() + } + } + } + + @ViewBuilder + var tweetsView: some View { + LazyVStack(spacing: 32) { + ForEachStore( + store.scope( + state: \.tweets, + action: { .tweetsList(.tweets($0)) } + ), + content: { store in + WithViewStore(store, observe: \.text) { viewStore in + Text(viewStore.state) + .padding(.horizontal) + .contentShape(Rectangle()) + .onTapGesture { + viewStore.send(.tap) + } + } + } + ) + } + } +} + +#Preview { + NavigationStack { + CurrentUserProfileView(Store( + initialState: .init( + model: .mock(), + tweets: [ + .mock(), + .mock(), + .mock(), + .mock(), + .mock() + ] + ), + reducer: CurrentUserProfileFeature.init + )) + } +} diff --git a/Example/Sources/CombineNavigationExample/FeedAndProfileView/FeedAndProfileViewController.swift b/Example/Sources/CombineNavigationExample/FeedAndProfileView/FeedAndProfileViewController.swift new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/FeedAndProfileView/FeedAndProfileViewController.swift @@ -0,0 +1 @@ + diff --git a/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabController.swift b/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabController.swift new file mode 100644 index 0000000..4e5c032 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabController.swift @@ -0,0 +1,75 @@ +import UIKit +import SwiftUI +import ComposableArchitecture +import Combine +import CombineExtensions +import Capture +import CombineNavigation + +@RoutingController +public final class FeedTabController: ComposableViewControllerOf { + let contentController: TweetsFeedController = .init() + + @StackDestination + var feedControllers: [StackElementID: TweetsFeedController] + + @StackDestination({ _ in .init(rootView: nil) }) + var profileControllers: [StackElementID: UIHostingController] + + public override func viewDidLoad() { + super.viewDidLoad() + + // For direct children this method is used instead of addChild + self.addRoutedChild(contentController) + self.view.addSubview(contentController.view) + contentController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + contentController.view.frame = view.bounds + contentController.didMove(toParent: self) + } + + public override func scope(_ store: Store?) { + contentController.setStore(store?.scope( + state: \.feed, + action: { .feed($0)} + )) + + _feedControllers.setConfiguration { controller, id in + controller.setStore(store?.scope( + state: { $0.path[id: id, case: \.feed] }, + action: { .path(.element(id: id, action: .feed($0))) } + )) + } + + _profileControllers.setConfiguration { controller, id in + controller.rootView = store.map { store in + ProfileView.IfLetView(store.scope( + state: { $0.path[id: id, case: \.profile] }, + action: { .path(.element(id: id, action: .profile($0))) } + )) + } + } + } + + public override func bind( + _ publisher: StorePublisher, + into cancellables: inout Set + ) { + navigationStack( + publisher.map(\.path).removeDuplicates(by: { $0.ids == $1.ids }), + ids: \.ids, + route: { $0[id: $1] }, + switch: destinations { destination, route, id in + switch route { + case .feed: + destination.$feedControllers[id] + case .profile: + destination.$profileControllers[id] + } + }, + onPop: capture { _self, ids in + _self.sendPop(ids, from: \.path) + } + ) + .store(in: &cancellables) + } +} diff --git a/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabFeature.swift b/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabFeature.swift new file mode 100644 index 0000000..00dcb78 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabFeature.swift @@ -0,0 +1,95 @@ +import ComposableArchitecture + +@Reducer +public struct FeedTabFeature { + public init() {} + + @Reducer + public struct Path { + public enum State: Equatable { + case feed(TweetsFeedFeature.State) + case profile(ProfileFeature.State) + } + + public enum Action: Equatable { + case feed(TweetsFeedFeature.Action) + case profile(ProfileFeature.Action) + } + + public var body: some ReducerOf { + Scope( + state: /State.feed, + action: /Action.feed, + child: TweetsFeedFeature.init + ) + Scope( + state: /State.profile, + action: /Action.profile, + child: ProfileFeature.init + ) + } + } + + public struct State: Equatable { + public var feed: TweetsFeedFeature.State + public var path: StackState + + public init( + feed: TweetsFeedFeature.State = .init(), + path: StackState = .init() + ) { + self.feed = feed + self.path = path + } + } + + public enum Action: Equatable { + case feed(TweetsFeedFeature.Action) + case path(StackAction) + } + + public var body: some ReducerOf { + CombineReducers { + Scope( + state: \.feed, + action: \.feed, + child: TweetsFeedFeature.init + ) + Reduce { state, action in + switch action { + case + let .feed(.openProfile(id)), + let .path(.element(_, action: .feed(.openProfile(id)))): + state.path.append(.profile(.user(.init(model: .mock(user: .mock(id: id)))))) + return .none + + case let .path(.element(stackID, action: .profile(profile))): + switch profile { + case .user(.tweetsList(.tweets(.element(_, .tap)))): + guard case let .profile(.user(profile)) = state.path[id: stackID] + else { return .none } + state.path.append(.feed(.init(list: profile.tweets))) + return .none + + case .currentUser(.tweetsList(.tweets(.element(_, .tap)))): + guard case let .profile(.currentUser(profile)) = state.path[id: stackID] + else { return .none } + state.path.append(.feed(.init(list: profile.tweets))) + return .none + + default: + return .none + } + + default: + return .none + } + } + .forEach( + \State.path, + action: \.path, + destination: Path.init + ) + } + } +} diff --git a/Example/Sources/CombineNavigationExample/MainFeature/MainFeature.swift b/Example/Sources/CombineNavigationExample/MainFeature/MainFeature.swift new file mode 100644 index 0000000..1816f84 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/MainFeature/MainFeature.swift @@ -0,0 +1,8 @@ +//import ComposableArchitecture +// +//public struct MainFeature: Reducer { +// public struct State: Equatable { +// public var feed: FeedTabFeature.State +// public var profile: ProfileTabFeature.State +// } +//} diff --git a/Example/Sources/CombineNavigationExample/Model/FollowerModel.swift b/Example/Sources/CombineNavigationExample/Model/FollowerModel.swift new file mode 100644 index 0000000..377bbf0 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/Model/FollowerModel.swift @@ -0,0 +1,32 @@ +import Foundation + +public struct FollowerModel: Equatable, Identifiable { + public var id: UUID { user.id } + public var user: UserModel + public var isFollowingYou: Bool + public var isFollowedByYou: Bool + + public init( + user: UserModel, + isFollowingYou: Bool = false, + isFollowedByYou: Bool = false + ) { + self.user = user + self.isFollowingYou = isFollowingYou + self.isFollowedByYou = isFollowedByYou + } +} + +extension FollowerModel { + public static func mock( + user: UserModel = .mock(), + isFollowingYou: Bool = false, + isFollowedByYou: Bool = false + ) -> Self { + .init( + user: user, + isFollowingYou: isFollowingYou, + isFollowedByYou: isFollowedByYou + ) + } +} diff --git a/Example/Sources/CombineNavigationExample/Model/TweetModel.swift b/Example/Sources/CombineNavigationExample/Model/TweetModel.swift new file mode 100644 index 0000000..a1fca47 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/Model/TweetModel.swift @@ -0,0 +1,117 @@ +import Foundation + +public struct TweetModel: Equatable, Identifiable, Codable { + public var id: UUID + public var authorID: UUID + public var replyTo: UUID? + public var text: String + + public init( + id: UUID, + authorID: UUID, + replyTo: UUID? = nil, + text: String + ) { + self.id = id + self.authorID = authorID + self.replyTo = replyTo + self.text = text + } +} + +extension TweetModel { + public static func mock( + id: UUID = .init(), + authorID: UUID = UserModel.mock().id, + replyTo: UUID? = nil, + text: String = """ + Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ + Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ + In ad laboris labore irure ea ea officia. + """ + ) -> TweetModel { + .init( + id: id, + authorID: authorID, + replyTo: replyTo, + text: text + ) + } + + public func withReplies( + @TweetModelsBuilder replies: (TweetModel) -> [TweetModel] + ) -> [TweetModel] { + [self] + replies(self) + } +} + +@resultBuilder +public struct TweetModelsBuilder: ArrayBuilderProtocol { + public typealias Element = TweetModel +} + +public protocol ArrayBuilderProtocol { + associatedtype Element + + static func buildExpression(_ component: Element) -> [Element] + + static func buildExpression(_ components: [Element]) -> [Element] + + static func buildBlock(_ components: Element...) -> [Element] + + static func buildArray(_ components: [[Element]]) -> [Element] + + static func buildPartialBlock(first: [Element]) -> [Element] + + static func buildPartialBlock(accumulated: [Element], next: [Element]) -> [Element] + + static func buildOptional(_ component: [Element]?) -> [Element] + + static func buildEither(first component: [Element]) -> [Element] + + static func buildEither(second component: [Element]) -> [Element] + + static func buildLimitedAvailability(_ component: [Element]) -> [Element] +} + +extension ArrayBuilderProtocol { + public static func buildExpression(_ component: Element) -> [Element] { + [component] + } + + public static func buildExpression(_ components: [Element]) -> [Element] { + components + } + + public static func buildBlock(_ components: Element...) -> [Element] { + components + } + + public static func buildArray(_ components: [[Element]]) -> [Element] { + components.flatMap { $0 } + } + + public static func buildPartialBlock(first: [Element]) -> [Element] { + first + } + + public static func buildPartialBlock(accumulated: [Element], next: [Element]) -> [Element] { + accumulated + next + } + + public static func buildOptional(_ component: [Element]?) -> [Element] { + component ?? [] + } + + public static func buildEither(first component: [Element]) -> [Element] { + component + } + + public static func buildEither(second component: [Element]) -> [Element] { + component + } + + public static func buildLimitedAvailability(_ component: [Element]) -> [Element] { + component + } +} diff --git a/Example/Sources/CombineNavigationExample/Model/UserModel.swift b/Example/Sources/CombineNavigationExample/Model/UserModel.swift new file mode 100644 index 0000000..d36bbd6 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/Model/UserModel.swift @@ -0,0 +1,37 @@ +import Foundation + +public struct UserModel: Equatable, Identifiable { + public var id: UUID + public var username: String + public var avatarURL: URL? + + public init( + id: UUID, + username: String, + avatarURL: URL? = nil + ) { + self.id = id + self.username = username + self.avatarURL = avatarURL + } +} + +extension UserModel { + static var mockCacheByID: [UUID: UserModel] = [:] + static var mockCacheByUsername: [String: UserModel] = [:] + + public static func mock( + id: UUID = .init(), + username: String = "username", + avatarURL: URL? = nil + ) -> Self { + let user = mockCacheByID[id] ?? mockCacheByUsername[username] ?? UserModel( + id: id, + username: username, + avatarURL: avatarURL + ) + mockCacheByID[user.id] = user + mockCacheByUsername[user.username] = user + return user + } +} diff --git a/Example/Sources/CombineNavigationExample/ProfileFeature/ProfileFeature.swift b/Example/Sources/CombineNavigationExample/ProfileFeature/ProfileFeature.swift new file mode 100644 index 0000000..6fba4d6 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/ProfileFeature/ProfileFeature.swift @@ -0,0 +1,31 @@ +import ComposableArchitecture + +public struct ProfileFeature: Reducer { + public init() {} + + @CasePathable + public enum State: Equatable { + case user(UserProfileFeature.State) + case currentUser(CurrentUserProfileFeature.State) + } + + @CasePathable + public enum Action: Equatable { + case user(UserProfileFeature.Action) + case currentUser(CurrentUserProfileFeature.Action) + } + + public var body: some ReducerOf { + EmptyReducer() + .ifCaseLet( + \.user, + action: \.user, + then: UserProfileFeature.init + ) + .ifCaseLet( + \.currentUser, + action: \.currentUser, + then: CurrentUserProfileFeature.init + ) + } +} diff --git a/Example/Sources/CombineNavigationExample/ProfileFeature/ProfileView.swift b/Example/Sources/CombineNavigationExample/ProfileFeature/ProfileView.swift new file mode 100644 index 0000000..13eef72 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/ProfileFeature/ProfileView.swift @@ -0,0 +1,66 @@ +import SwiftUI +import ComposableArchitecture + +public struct ProfileView: View { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + SwitchStore(store) { state in + switch state { + case .user: + CaseLet( + /ProfileFeature.State.user, + action: ProfileFeature.Action.user, + then: UserProfileView.init + ) + case .currentUser: + CaseLet( + /ProfileFeature.State.currentUser, + action: ProfileFeature.Action.currentUser, + then: CurrentUserProfileView.init + ) + } + } + } + + public struct IfLetView: View { + let store: Store< + ProfileFeature.State?, + ProfileFeature.Action + > + + public init( + _ store: Store< + ProfileFeature.State?, + ProfileFeature.Action + >) { + self.store = store + } + + public var body: some View { + IfLetStore(store, then: ProfileView.init) + } + } +} + +#Preview { + NavigationStack { + ProfileView(Store( + initialState: .user(.init( + model: .mock(), + tweets: [ + .mock(), + .mock(), + .mock(), + .mock(), + .mock() + ] + )), + reducer: ProfileFeature.init + )) + } +} diff --git a/Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedFeature.swift b/Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedFeature.swift new file mode 100644 index 0000000..ffc34f8 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedFeature.swift @@ -0,0 +1,39 @@ +import ComposableArchitecture +import Foundation + +public struct ProfileFeedFeature: Reducer { + public init() {} + + public struct State: Equatable { + public var items: IdentifiedArrayOf + + public init( + items: IdentifiedArrayOf = [] + ) { + self.items = items + } + } + + @CasePathable + public enum Action: Equatable { + case items(IdentifiedActionOf) + case openProfile(UUID) + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .items(.element(id, action: .tapOnAuthor)): + return .send(.openProfile(id)) + + default: + return .none + } + } + .forEach( + \State.items, + action: \.items, + element: TweetFeature.init + ) + } +} diff --git a/Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedView.swift b/Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedView.swift new file mode 100644 index 0000000..480e91f --- /dev/null +++ b/Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedView.swift @@ -0,0 +1,41 @@ +import SwiftUI +import ComposableArchitecture + +public struct ProfileFeedView: View { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView(.vertical) { + LazyVStack(spacing: 24) { + ForEachStore( + store.scope( + state: \.items, + action: { .items($0) } + ), + content: TweetView.init + ) + } + } + } +} + +#Preview { + NavigationStack { + ProfileFeedView(Store( + initialState: .init( + items: [ + .mock(), + .mock(), + .mock(), + .mock(), + .mock() + ] + ), + reducer: ProfileFeedFeature.init + )) + } +} diff --git a/Example/Sources/CombineNavigationExample/ProfileTabFeature/ProfileTabFeature.swift b/Example/Sources/CombineNavigationExample/ProfileTabFeature/ProfileTabFeature.swift new file mode 100644 index 0000000..0d162a4 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/ProfileTabFeature/ProfileTabFeature.swift @@ -0,0 +1,72 @@ +import ComposableArchitecture + +public struct ProfileTabFeature: Reducer { + public init() {} + + public struct Path: Reducer { + public enum State: Equatable { + case feed(TweetsFeedFeature.State) + case profile(UserProfileFeature.State) + } + + @CasePathable + public enum Action: Equatable { + case feed(TweetsFeedFeature.Action) + case profile(UserProfileFeature.Action) + } + + public var body: some ReducerOf { + Scope( + state: /State.feed, + action: /Action.feed, + child: TweetsFeedFeature.init + ) + Scope( + state: /State.profile, + action: /Action.profile, + child: UserProfileFeature.init + ) + } + } + + public struct State: Equatable { + public var path: StackState + + public init( + root: UserProfileFeature.State, + path: StackState = .init() + ) { + self.path = [.profile(root)] + path + } + } + + @CasePathable + public enum Action: Equatable { + case path(StackAction) + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .path(.element(_, action: .feed(.openProfile(id)))): + state.path.append(.profile(.init(model: .mock(user: .mock(id: id))))) + return .none + + case let .path(.element(stackID, .profile(.tweetsList(.tweets(.element(_, .tap)))))): + guard case let .profile(profile) = state.path[id: stackID] + else { return .none } + + state.path.append(.feed(.init(list: profile.tweets))) + return .none + + default: + return .none + } + } + .forEach( + \State.path, + action: \.path, + destination: Path.init + ) + } +} diff --git a/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailController.swift b/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailController.swift new file mode 100644 index 0000000..663c7b8 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailController.swift @@ -0,0 +1,48 @@ +import UIKit +import SwiftUI +import ComposableArchitecture +import Combine +import CombineExtensions +import Capture +import CombineNavigation + +@RoutingController +public final class TweetDetailController: ComposableViewControllerOf { + let host = UIHostingController(rootView: nil) + + @TreeDestination + var detailController: TweetDetailController? + + public override func viewDidLoad() { + super.viewDidLoad() + self.addChild(host) + self.view.addSubview(host.view) + host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + host.view.frame = view.bounds + host.didMove(toParent: self) + } + + public override func scope(_ store: Store?) { + host.rootView = store.map { TweetDetailView($0) } + + _detailController.setConfiguration { controller in + controller.setStore(store?.scope( + state: \.detail, + action: { .detail(.presented($0)) } + )) + } + } + + public override func bind( + _ publisher: StorePublisher, + into cancellables: inout Set + ) { + navigationDestination( + "reply_detail", + isPresented: publisher.detail.isNotNil, + controller: _detailController.callAsFunction, + onPop: captureSend(.detail(.dismiss)) + ) + .store(in: &cancellables) + } +} diff --git a/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailFeature.swift b/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailFeature.swift new file mode 100644 index 0000000..a51e592 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailFeature.swift @@ -0,0 +1,85 @@ +import ComposableArchitecture +import Foundation + +@Reducer +public struct TweetDetailFeature { + public init() {} + + public struct State: Equatable { + public var source: TweetFeature.State + public var replies: TweetsListFeature.State + + @PresentationState + public var detail: TweetDetailFeature.State? + + public init( + source: TweetFeature.State, + replies: TweetsListFeature.State, + detail: TweetDetailFeature.State? = nil + ) { + self.source = source + self.replies = replies + self.detail = detail + } + + public static func collectMock( + for id: UUID + ) -> Self? { + TweetModel.mockTweets[id: id].map { source in + return .init( + source: .mock(model: source), + replies: IdentifiedArray( + uncheckedUniqueElements: TweetModel.mockReplies(for: source.id) + .map { .mock(model: $0) } + ) + ) + } + } + } + + public enum Action: Equatable { + case source(TweetFeature.Action) + case replies(TweetsListFeature.Action) + case detail(PresentationAction) + case openProfile(UUID) + } + + public var body: some ReducerOf { + CombineReducers { + Reduce { state, action in + switch action { + case + let .source(.openProfile(id)), + let .replies(.openProfile(id)), + let .detail(.presented(.openProfile(id))): + return .send(.openProfile(id)) + + case let .replies(.openDetail(id)): + state.detail = .collectMock(for: id) + + return .none + + default: + return .none + } + } + + Scope( + state: \State.source, + action: \.source, + child: TweetFeature.init + ) + + Scope( + state: \State.replies, + action: \.replies, + child: TweetsListFeature.init + ) + } + .ifLet( + \State.$detail, + action: \.detail, + destination: { TweetDetailFeature() } + ) + } +} diff --git a/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailView.swift b/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailView.swift new file mode 100644 index 0000000..cc80336 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailView.swift @@ -0,0 +1,49 @@ +import SwiftUI +import ComposableArchitecture + +public struct TweetDetailView: View { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView(.vertical) { + LazyVStack(spacing: 24) { + TweetView(store.scope( + state: \.source, + action: { .source($0) } + )) + HStack(spacing: 0) { + WithViewStore(store, observe: \.replies.isEmpty) { isEmpty in + if !isEmpty.state { + RoundedRectangle(cornerRadius: 1, style: .circular) + .fill(Color(.label).opacity(0.3)) + .frame(maxWidth: 2, maxHeight: .infinity) + } + } + TweetsListView(store.scope( + state: \.replies, + action: { .replies($0) } + )) + .padding(.top) + } + .padding(.leading) + } + } + } +} + +#Preview { + TweetDetailView(Store( + initialState: .init( + source: .mock(), + replies: [ + .mock(), + .mock() + ] + ), + reducer: TweetDetailFeature.init + )) +} diff --git a/Example/Sources/CombineNavigationExample/TweetFeature/TweetFeature.swift b/Example/Sources/CombineNavigationExample/TweetFeature/TweetFeature.swift new file mode 100644 index 0000000..732870a --- /dev/null +++ b/Example/Sources/CombineNavigationExample/TweetFeature/TweetFeature.swift @@ -0,0 +1,97 @@ +import ComposableArchitecture +import Foundation + +public struct TweetFeature: Reducer { + public init() {} + + public struct State: Equatable, Identifiable { + public var id: UUID + public var replyTo: UUID? + public var author: UserModel + public var text: String + + public init( + id: UUID, + replyTo: UUID? = nil, + author: UserModel, + text: String + ) { + self.id = id + self.replyTo = replyTo + self.author = author + self.text = text + } + } + + @CasePathable + public enum Action: Equatable { + case tap + case tapOnAuthor + case openDetail(for: UUID) + case openProfile(UUID) + } + + public func reduce( + into state: inout State, + action: Action + ) -> Effect { + switch action { + case .tap: + return .send(.openDetail(for: state.id)) + + case .tapOnAuthor: + return.send(.openProfile(state.author.id)) + + default: + return .none + } + } +} + +extension TweetFeature.State { + public static func mock( + model: TweetModel + ) -> Self { + .mock( + id: model.id, + replyTo: model.replyTo, + author: .mock(id: model.authorID), + text: model.text + ) + } + + public static func mock( + id: UUID = .init(), + replyTo: UUID? = nil, + author: UserModel = .mock(), + text: String = """ + Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ + Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ + In ad laboris labore irure ea ea officia. + """ + ) -> Self { + .init( + id: id, + replyTo: replyTo, + author: author, + text: text + ) + } + + public func mockReply( + id: UUID = .init(), + author: UserModel = .mock(), + text: String = """ + Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ + Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ + In ad laboris labore irure ea ea officia. + """ + ) -> Self { + .init( + id: id, + replyTo: self.id, + author: author, + text: text + ) + } +} diff --git a/Example/Sources/CombineNavigationExample/TweetFeature/TweetView.swift b/Example/Sources/CombineNavigationExample/TweetFeature/TweetView.swift new file mode 100644 index 0000000..266dcb5 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/TweetFeature/TweetView.swift @@ -0,0 +1,46 @@ +import SwiftUI +import ComposableArchitecture + +public struct TweetView: View { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + WithViewStore(store, observe: { $0 }) { viewStore in + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 16) { + Circle() // Avatar + .fill(Color(.label).opacity(0.3)) + .frame(width: 54, height: 54) + Text("@" + viewStore.author.username.lowercased()).bold() + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + viewStore.send(.tapOnAuthor) + } + Text(viewStore.text) + .onTapGesture { + viewStore.send(.tap) + } + } + .padding(.horizontal) + .background( + Color(.systemBackground) + .onTapGesture { + viewStore.send(.tap) + } + ) + } + } +} + +#Preview { + TweetView(Store( + initialState: .mock(), + reducer: TweetFeature.init + )) +} diff --git a/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedController.swift b/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedController.swift new file mode 100644 index 0000000..83f18e8 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedController.swift @@ -0,0 +1,53 @@ +import UIKit +import SwiftUI +import ComposableArchitecture +import Combine +import CombineExtensions +import Capture +import CombineNavigation + +@RoutingController +public final class TweetsFeedController: ComposableViewControllerOf { + let host = UIHostingController(rootView: nil) + + @TreeDestination + var detailController: TweetDetailController? + + public override func viewDidLoad() { + super.viewDidLoad() + self.addChild(host) + self.view.addSubview(host.view) + host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + host.view.frame = view.bounds + host.didMove(toParent: self) + } + + public override func scope(_ store: Store?) { + host.rootView = store.map { store in + TweetsListView(store.scope( + state: \.list, + action: { .list($0) } + )) + } + + _detailController.setConfiguration { controller in + controller.setStore(store?.scope( + state: \.detail, + action: { .detail(.presented($0)) } + )) + } + } + + public override func bind( + _ publisher: StorePublisher, + into cancellables: inout Set + ) { + navigationDestination( + "reply_detail", + isPresented: publisher.detail.isNotNil, + controller: _detailController.callAsFunction, + onPop: captureSend(.detail(.dismiss)) + ) + .store(in: &cancellables) + } +} diff --git a/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedFeature.swift b/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedFeature.swift new file mode 100644 index 0000000..c8dfa33 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedFeature.swift @@ -0,0 +1,59 @@ +import ComposableArchitecture +import Foundation + +public struct TweetsFeedFeature: Reducer { + public init() {} + + public struct State: Equatable { + public var list: TweetsListFeature.State + + @PresentationState + public var detail: TweetDetailFeature.State? + + public init( + list: TweetsListFeature.State = [], + detail: TweetDetailFeature.State? = nil + ) { + self.list = list + self.detail = detail + } + } + + @CasePathable + public enum Action: Equatable { + case list(TweetsListFeature.Action) + case detail(PresentationAction) + case openProfile(UUID) + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case + let .list(.tweets(.element(_, .openProfile(id)))), + let .detail(.presented(.openProfile(id))): + return .send(.openProfile(id)) + + case let .list(.tweets(.element(itemID, .openDetail))): + state.detail = state.list[id: itemID].flatMap { tweet in + .collectMock(for: tweet.id) + } + return .none + + default: + return .none + } + } + .ifLet( + \State.$detail, + action: \.detail, + destination: TweetDetailFeature.init + ) + + Scope( + state: \State.list, + action: \.list, + child: TweetsListFeature.init + ) + } +} diff --git a/Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListFeature.swift b/Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListFeature.swift new file mode 100644 index 0000000..5e09ff3 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListFeature.swift @@ -0,0 +1,35 @@ +import ComposableArchitecture +import Foundation + +public struct TweetsListFeature: Reducer { + public init() {} + + public typealias State = IdentifiedArrayOf + + @CasePathable + public enum Action: Equatable { + case tweets(IdentifiedActionOf) + case openDetail(for: UUID) + case openProfile(UUID) + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .tweets(.element(_, .openProfile(id))): + return .send(.openProfile(id)) + + case let .tweets(.element(_, .openDetail(id))): + return .send(.openDetail(for: id)) + + default: + return .none + } + } + .forEach( + \State.self, + action: \.tweets, + element: TweetFeature.init + ) + } +} diff --git a/Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListView.swift b/Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListView.swift new file mode 100644 index 0000000..76f562c --- /dev/null +++ b/Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListView.swift @@ -0,0 +1,37 @@ +import SwiftUI +import ComposableArchitecture + +public struct TweetsListView: View { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView(.vertical) { + LazyVStack(spacing: 24) { + ForEachStore( + store.scope( + state: { $0 }, + action: { .tweets($0) } + ), + content: TweetView.init + ) + } + } + } +} + +#Preview { + NavigationStack { + TweetsListView(Store( + initialState: [ + .mock(), + .mock() + ], + reducer: TweetsListFeature.init + )) + .navigationTitle("Preview") + } +} diff --git a/Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileFeature.swift b/Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileFeature.swift new file mode 100644 index 0000000..ecf7b1a --- /dev/null +++ b/Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileFeature.swift @@ -0,0 +1,65 @@ +import ComposableArchitecture +import FoundationExtensions + +public struct UserProfileFeature: Reducer { + public init() {} + + public struct State: Equatable { + public var model: FollowerModel + public var tweets: TweetsListFeature.State + + @PresentationState + public var avatarPreview: URL? + + public init( + model: FollowerModel, + tweets: TweetsListFeature.State = .init() + ) { + self.model = model + self.tweets = tweets + } + } + + @CasePathable + public enum Action: Equatable { + case avatarPreview(PresentationAction) + case tweetsList(TweetsListFeature.Action) + case openDetail(for: UUID) + case openProfile(UUID) + case tapOnAvatar + case tapFollow + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .tapOnAvatar: + state.avatarPreview = state.model.user.avatarURL + return .none + + case .tapFollow: + state.model.isFollowedByYou.toggle() + return .none + + case let .tweetsList(.tweets(.element(_, .openDetail(id)))): + return .send(.openDetail(for: id)) + + case let .tweetsList(.tweets(.element(_, .openProfile(id)))): + return .send(.openProfile(id)) + + default: + return .none + } + } + .ifLet( + \State.$avatarPreview, + action: \.avatarPreview, + destination: {} + ) + Scope( + state: \.tweets, + action: \.tweetsList, + child: TweetsListFeature.init + ) + } +} diff --git a/Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileView.swift b/Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileView.swift new file mode 100644 index 0000000..73ce049 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileView.swift @@ -0,0 +1,65 @@ +import SwiftUI +import ComposableArchitecture + +public struct UserProfileView: View { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView(.vertical) { + headerView + .padding(.vertical, 32) + Divider() + .padding(.bottom, 32) + TweetsListView(store.scope( + state: \.tweets, + action: { .tweetsList($0) } + )) + } + } + + @ViewBuilder + var headerView: some View { + VStack(spacing: 24) { + WithViewStore(store, observe: \.model.user.avatarURL) { viewStore in + Circle() + .fill(Color(.label).opacity(0.3)) + .frame(width: 86, height: 86) + .onTapGesture { + viewStore.send(.tapOnAvatar) + } + } + WithViewStore(store, observe: \.model.user.username) { viewStore in + Text("@" + viewStore.state.lowercased()) + .monospaced() + .bold() + } + WithViewStore(store, observe: \.model.isFollowedByYou) { viewStore in + Button(action: { viewStore.send(.tapFollow) }) { + Text(viewStore.state ? "Unfollow" : "Follow") + } + } + } + } +} + +#Preview { + NavigationStack { + UserProfileView(Store( + initialState: .init( + model: .mock(), + tweets: [ + .mock(), + .mock(), + .mock(), + .mock(), + .mock() + ] + ), + reducer: UserProfileFeature.init + )) + } +} diff --git a/Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsFeature.swift b/Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsFeature.swift new file mode 100644 index 0000000..dde8123 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsFeature.swift @@ -0,0 +1,23 @@ +import ComposableArchitecture +import Foundation + +public struct UserSettingsFeature: Reducer { + public init() {} + + public struct State: Equatable, Identifiable { + public var id: UUID + + public init( + id: UUID + ) { + self.id = id + } + } + + @CasePathable + public enum Action: Equatable {} + + public var body: some ReducerOf { + EmptyReducer() + } +} diff --git a/Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsView.swift b/Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsView.swift new file mode 100644 index 0000000..b9671ff --- /dev/null +++ b/Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsView.swift @@ -0,0 +1,23 @@ +import SwiftUI +import ComposableArchitecture + +public struct UserSettingsView: View { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + WithViewStore(store, observe: \.id) { viewStore in + Text(viewStore.uuidString) + } + } +} + +#Preview { + UserSettingsView(Store( + initialState: .init(id: .init()), + reducer: UserSettingsFeature.init + )) +} diff --git a/Example/Sources/CombineNavigationExample/_Core/ComposableViewController.swift b/Example/Sources/CombineNavigationExample/_Core/ComposableViewController.swift new file mode 100644 index 0000000..fa90b53 --- /dev/null +++ b/Example/Sources/CombineNavigationExample/_Core/ComposableViewController.swift @@ -0,0 +1,81 @@ +import ComposableArchitecture +import UIKit +import Combine +import Capture + +public typealias ComposableViewControllerOf = ComposableViewController< + R.State, + R.Action +> where R.State: Equatable, R.Action: Equatable + +open class ComposableViewController< + State: Equatable, + Action: Equatable +>: UIViewController { + public typealias Store = ComposableArchitecture.Store + public typealias StorePublisher = ComposableArchitecture.StorePublisher + + private var stateCancellables: Set = [] + private var storeCancellable: Cancellable? + private var store: Store? + private var viewStore: ViewStore? + + private func releaseStore() { + stateCancellables = [] + storeCancellable = nil + store = nil + viewStore = nil + scope(nil) + } + + public func setStore(_ store: ComposableArchitecture.Store?) { + guard let store else { return releaseStore() } + storeCancellable = store.ifLet( + then: capture { _self, store in + _self.setStore(store) + }, + else: capture { _self in + _self.releaseStore() + } + ) + } + + public func setStore(_ store: Store?) { + guard let store else { return releaseStore() } + self.store = store + + let viewStore = ViewStore(store, observe: { $0 }) + self.viewStore = viewStore + + self.scope(store) + self.bind(viewStore.publisher, into: &stateCancellables) + } + + open func scope(_ store: Store?) {} + + open func bind( + _ publisher: StorePublisher, + into cancellables: inout Set + ) {} + + public func withViewStore(_ action: (ViewStore) -> Void) { + viewStore.map(action) + } + + public func send(_ action: Action) { + viewStore?.send(action) + } + + public func sendPop( + _ ids: [StackElementID], + from actionPath: CaseKeyPath> + ) { + ids.first.map { id in + send(actionPath.callAsFunction(.popFrom(id: id))) + } + } + + public func captureSend(_ action: Action) -> () -> Void { + return capture { _self in _self.send(action) } + } +} diff --git a/Example/Sources/CombineNavigationExample/_Mock/MockThreads.swift b/Example/Sources/CombineNavigationExample/_Mock/MockThreads.swift new file mode 100644 index 0000000..dfe0eca --- /dev/null +++ b/Example/Sources/CombineNavigationExample/_Mock/MockThreads.swift @@ -0,0 +1,201 @@ +import ComposableArchitecture +import FoundationExtensions + +extension TweetModel { + public static func mockReplies( + for id: UUID + ) -> IdentifiedArrayOf { + mockTweets[id: id].map { source in + mockTweets.filter { $0.replyTo == source.id } + }.or([]) + } + + public static let mockTweets: IdentifiedArrayOf = .init(uniqueElements: [ + TweetModel.mock( + authorID: UserModel.mock(username: "JohnDoe").id, + text: "Hello, world!" + ).withReplies { model in + TweetModel.mock( + authorID: UserModel.mock(username: "JaneDoe").id, + replyTo: model.id, + text: "Hello, John!" + ) + TweetModel.mock( + authorID: UserModel.mock(username: "Alice").id, + replyTo: model.id, + text: "Nice weather today." + ) + TweetModel.mock( + authorID: UserModel.mock(username: "Bob").id, + replyTo: model.id, + text: "Agree with you, Alice." + ) + TweetModel.mock( + authorID: UserModel.mock(username: "Charlie").id, + replyTo: model.id, + text: "Looking forward to the weekend." + ).withReplies { model in + TweetModel.mock( + authorID: UserModel.mock(username: "Emma").id, + replyTo: model.id, + text: "Me too, Charlie!" + ) + TweetModel.mock( + authorID: UserModel.mock(username: "Oliver").id, + replyTo: model.id, + text: "Same here." + ) + } + TweetModel.mock( + authorID: UserModel.mock(username: "Sophia").id, + replyTo: model.id, + text: "Have a nice day, everyone!" + ) + }, + TweetModel.mock( + authorID: UserModel.mock(username: "Mike").id, + text: "Let's discuss our favorite movies!" + ).withReplies { model in + TweetModel.mock( + authorID: UserModel.mock(username: "Lucy").id, + replyTo: model.id, + text: "I love Titanic." + ) + TweetModel.mock( + authorID: UserModel.mock(username: "Sam").id, + replyTo: model.id, + text: "The Shawshank Redemption is the best!" + ).withReplies { innerModel in + TweetModel.mock( + authorID: UserModel.mock(username: "Tom").id, + replyTo: innerModel.id, + text: "Indeed, it's a touching story." + ) + TweetModel.mock( + authorID: UserModel.mock(username: "EmmaJ").id, + replyTo: innerModel.id, + text: "I was moved to tears by that movie." + ) + } + }, + TweetModel.mock( + authorID: UserModel.mock(username: "Olivia").id, + text: "Crowd-sourcing the best books!" + ).withReplies { model in + for i in 1...10 { + TweetModel.mock( + authorID: UserModel.mock(username: "User\(i)").id, + replyTo: model.id, + text: "Book suggestion #\(i)." + ) + } + }, + TweetModel.mock( + authorID: UserModel.mock(username: "Harry").id, + text: "Who's following the basketball championship?" + ).withReplies { model in + TweetModel.mock( + authorID: UserModel.mock(username: "Nina").id, + replyTo: model.id, + text: "Wouldn't miss it for the world!" + ).withReplies { innerModel in + TweetModel.mock( + authorID: UserModel.mock(username: "Rihanna").id, + replyTo: innerModel.id, + text: "Same here!" + ).withReplies { innerMostModel in + TweetModel.mock( + authorID: UserModel.mock(username: "George").id, + replyTo: innerMostModel.id, + text: "Go Lakers!" + ) + } + } + TweetModel.mock( + authorID: UserModel.mock(username: "Drake").id, + replyTo: model.id, + text: "I'll be at the final game!" + ) + }, + TweetModel.mock( + authorID: UserModel.mock(username: "ElonMusk").id, + text: "Exploring Mars: What are the most significant challenges we're looking to overcome?" + ).withReplies { model in + TweetModel.mock( + authorID: UserModel.mock(username: "AstroJane").id, + replyTo: model.id, + text: "I believe overcoming the harsh weather conditions is a major challenge." + ).withReplies { innerModel in + TweetModel.mock( + authorID: UserModel.mock(username: "ScienceMike").id, + replyTo: innerModel.id, + text: "Absolutely, the extreme cold and dust storms are definitely obstacles." + ) + } + }, + TweetModel.mock( + authorID: UserModel.mock(username: "BillGates").id, + text: "How can technology further help in improving education globally?" + ).withReplies { model in + for i in 1...5 { + TweetModel.mock( + authorID: UserModel.mock(username: "EdTechExpert\(i)").id, + replyTo: model.id, + text: "I think technology #\(i) would greatly improve global education." + ) + } + }, + TweetModel.mock( + authorID: UserModel.mock(username: "TaylorSwift").id, + text: "New album release next month! What themes do you guys hope to hear?" + ).withReplies { model in + TweetModel.mock( + authorID: UserModel.mock(username: "Fan1").id, + replyTo: model.id, + text: "I hope to hear some songs about moving on and finding oneself." + ) + TweetModel.mock( + authorID: UserModel.mock(username: "Fan2").id, + replyTo: model.id, + text: "Can't wait for love songs!" + ).withReplies { innerModel in + TweetModel.mock( + authorID: UserModel.mock(username: "Fan3").id, + replyTo: innerModel.id, + text: "Yes, her love songs always hit differently." + ) + } + }, + TweetModel.mock( + authorID: UserModel.mock(username: "ChefGordon").id, + text: "What's your all-time favorite recipe?" + ).withReplies { model in + TweetModel.mock( + authorID: UserModel.mock(username: "FoodieSam").id, + replyTo: model.id, + text: "I love a classic spaghetti carbonara. Simple, yet so delicious." + ) + TweetModel.mock( + authorID: UserModel.mock(username: "CulinaryMaster").id, + replyTo: model.id, + text: "Can't go wrong with a perfectly cooked steak." + ) + }, + TweetModel.mock( + authorID: UserModel.mock(username: "CryptoExpert").id, + text: "What's everyone's prediction for Bitcoin for the next year?" + ).withReplies { model in + TweetModel.mock( + authorID: UserModel.mock(username: "BitcoinBull").id, + replyTo: model.id, + text: "I foresee a great year ahead for Bitcoin. Hold on to what you've got!" + ).withReplies { innerModel in + TweetModel.mock( + authorID: UserModel.mock(username: "CryptoSkeptic").id, + replyTo: innerModel.id, + text: "I'm not so certain. It's wise to diversify and not put all your eggs in one basket." + ) + } + } + ].flatMap { $0 }) +} From 1bf95e89a3959f0f05b3c5326d0c3b041ddbfec9 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 16 Dec 2023 04:33:08 +0100 Subject: [PATCH 09/43] feat(CI): Update script --- .github/workflows/Test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index 5dd6b25..721de4e 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -16,7 +16,7 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@v3 - - name: Select Xcode 15.0.0 - run: sudo xcode-select -s /Applications/Xcode_15.0.0.app + - name: Select Xcode 15.0 + run: sudo xcode-select -switch /Applications/Xcode_15.0.app - name: Run tests run: make test From 8516e0842cd44e0c426a5cf1ce4de6df6d350b2f Mon Sep 17 00:00:00 2001 From: Maxim Krouk <40476363+maximkrouk@users.noreply.github.com> Date: Sun, 17 Dec 2023 20:27:29 +0100 Subject: [PATCH 10/43] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a150525..3eb2400 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ >Package compiles for all platforms, but functionality is available if UIKit can be imported and the platform is not watchOS. -> This readme is draft and the branch is still an `beta` version untill all [todos](#Coming soon) are resolved. +> This readme is draft and the branch is still an `beta` version untill all [todos](#coming-soon) are resolved. ## Usage From 57849b5ee70be521accdccf65dd5a48be1bfb2f6 Mon Sep 17 00:00:00 2001 From: Maxim Krouk <40476363+maximkrouk@users.noreply.github.com> Date: Sun, 17 Dec 2023 20:27:52 +0100 Subject: [PATCH 11/43] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3eb2400..e192e96 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ >Package compiles for all platforms, but functionality is available if UIKit can be imported and the platform is not watchOS. -> This readme is draft and the branch is still an `beta` version untill all [todos](#coming-soon) are resolved. +> This readme is draft and the branch is still an `beta` version. ## Usage From 6cee35f6d5f24d39ebf1000c6d98ec5500b07b90 Mon Sep 17 00:00:00 2001 From: Maxim Krouk <40476363+maximkrouk@users.noreply.github.com> Date: Mon, 18 Dec 2023 23:56:45 +0100 Subject: [PATCH 12/43] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e192e96..21c8537 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## Usage -This library was primarely created for [TCA](https://github.com/pointfreeco/swift-composable-archtiecture) navigation with Cocoa. However it's geneic enough to use with pure combine. But to dive more into general understanding of stack-based and tree based navigation take a look at TCA docs. +This library was primarely created for [TCA](https://github.com/pointfreeco/swift-composable-architecture) navigation with Cocoa. However it's geneic enough to use with pure combine. But to dive more into general understanding of stack-based and tree based navigation take a look at TCA docs. ### Setup From 8f9215ca35130d374a611ea959f147a6f2a3fc0e Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Tue, 19 Dec 2023 01:53:17 +0100 Subject: [PATCH 13/43] fix(Tests): Add `bootstrap()` call to tests --- .../CombineNavigationTests/RoutingControllerStackTests.swift | 4 ++++ Tests/CombineNavigationTests/RoutingControllerTests.swift | 4 ++++ Tests/CombineNavigationTests/RoutingControllerTreeTests.swift | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/Tests/CombineNavigationTests/RoutingControllerStackTests.swift b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift index 6ad63c1..396fe4f 100644 --- a/Tests/CombineNavigationTests/RoutingControllerStackTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift @@ -7,6 +7,10 @@ import Combine #if canImport(UIKit) && !os(watchOS) final class RoutingControllerStackTests: XCTestCase { + static override func setUp() { + CombineNavigation.bootstrap() + } + func testNavigationStack() { let viewModel = StackViewModel() let controller = StackViewController() diff --git a/Tests/CombineNavigationTests/RoutingControllerTests.swift b/Tests/CombineNavigationTests/RoutingControllerTests.swift index 63460ad..70cc8c7 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTests.swift @@ -11,6 +11,10 @@ import SwiftUI // TODO: Add test for `navigationStack(_:ids:route:switch:onDismiss:)`") // TODO: Add test for `navigationDestination(_:isPresented:controller:onDismiss:)`") final class RoutingControllerTests: XCTestCase { + static override func setUp() { + CombineNavigation.bootstrap() + } + func testMain() { let root = StackViewController() let viewModel = StackViewModel(initialState: .init()) diff --git a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift index 6d22b5a..1677e88 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift @@ -7,6 +7,10 @@ import Combine #if canImport(UIKit) && !os(watchOS) final class RoutingControllerTreeTests: XCTestCase { + static override func setUp() { + CombineNavigation.bootstrap() + } + func testNavigationTree() { let viewModel = TreeViewModel() let controller = TreeViewController() From 9576f507d89d2f4be76cfef5bcb4b56e8f6dc5d2 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Tue, 19 Dec 2023 01:53:33 +0100 Subject: [PATCH 14/43] fix: Destination inheritance API --- .../CombineNavigation/Destinations/StackDestination.swift | 8 +++++--- .../CombineNavigation/Destinations/TreeDestination.swift | 6 +++--- .../Destinations/StackDestinationTests.swift | 2 +- .../Destinations/TreeDestinationTests.swift | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Sources/CombineNavigation/Destinations/StackDestination.swift b/Sources/CombineNavigation/Destinations/StackDestination.swift index a52275c..3d9f977 100644 --- a/Sources/CombineNavigation/Destinations/StackDestination.swift +++ b/Sources/CombineNavigation/Destinations/StackDestination.swift @@ -11,12 +11,14 @@ open class StackDestination< Controller: CocoaViewController >: Weakifiable { private var _controllers: [StackElementID: Weak] = [:] - public var wrappedValue: [StackElementID: Controller] { + + open var wrappedValue: [StackElementID: Controller] { let controllers = _controllers.compactMapValues(\.wrappedValue) _controllers = controllers.mapValues(Weak.init(wrappedValue:)) return controllers } - public var projectedValue: StackDestination { self } + + open var projectedValue: StackDestination { self } private var _initControllerOverride: ((StackElementID) -> Controller)? @@ -45,7 +47,7 @@ open class StackDestination< } } - @_spi(Internals) public class func initController( + @_spi(Internals) open class func initController( for id: StackElementID ) -> Controller { return Controller() diff --git a/Sources/CombineNavigation/Destinations/TreeDestination.swift b/Sources/CombineNavigation/Destinations/TreeDestination.swift index 18e2b17..208b1d1 100644 --- a/Sources/CombineNavigation/Destinations/TreeDestination.swift +++ b/Sources/CombineNavigation/Destinations/TreeDestination.swift @@ -8,8 +8,8 @@ import FoundationExtensions @propertyWrapper open class TreeDestination: Weakifiable { private weak var _controller: Controller? - public var wrappedValue: Controller? { _controller } - public var projectedValue: TreeDestination { self } + open var wrappedValue: Controller? { _controller } + open var projectedValue: TreeDestination { self } private var _initControllerOverride: (() -> Controller)? @@ -36,7 +36,7 @@ open class TreeDestination: Weakifiable { } } - @_spi(Internals) public class func initController() -> Controller { + @_spi(Internals) open class func initController() -> Controller { return Controller() } diff --git a/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift b/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift index 0eea279..5278b9e 100644 --- a/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift +++ b/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift @@ -1,6 +1,6 @@ import XCTest import CocoaAliases -@_spi(Internals) @testable import CombineNavigation +@_spi(Internals) import CombineNavigation #if canImport(UIKit) && !os(watchOS) final class StackDestinationTests: XCTestCase { diff --git a/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift b/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift index b5db001..1ccaf9b 100644 --- a/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift +++ b/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift @@ -1,6 +1,6 @@ import XCTest import CocoaAliases -@_spi(Internals) @testable import CombineNavigation +@_spi(Internals) import CombineNavigation #if canImport(UIKit) && !os(watchOS) final class TreeDestinationTests: XCTestCase { From 844dd5ee82a08b5292e1aeef5ab040ba3677a061 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Tue, 19 Dec 2023 03:47:49 +0100 Subject: [PATCH 15/43] feat: API & Performance Improvements - Optimizations: - Destinations now use strong references to controllers and explicitly removes those references when controllers are popped from the navigation stack, no more just-in-case cleanups in StackDestination on each wrappedValue access - API Improvements - With new navigationStack/Destination methods controllers can be implicitly instantiated from Destination wrappers, all users have to do is provide mappings from Route to a corresponding destination --- .../FeedTabFeature/FeedTabController.swift | 6 +- .../TweetsFeedController.swift | 2 +- README.md | 10 +- .../CocoaViewController+API.swift | 84 +++++++++ .../Destinations/StackDestination.swift | 78 +++++++-- .../Destinations/TreeDestination.swift | 52 +++++- .../CombineNavigationRouter+API.swift | 164 ++++++++++++++++-- .../Internal/CombineNavigationRouter.swift | 11 +- .../CombineNavigation/RoutingController.swift | 37 ++-- .../Destinations/StackDestinationTests.swift | 4 +- .../Destinations/TreeDestinationTests.swift | 6 +- .../NavigationAnimationTests.swift | 4 +- .../RoutingControllerStackTests.swift | 9 +- .../RoutingControllerTests.swift | 10 +- .../RoutingControllerTreeTests.swift | 7 +- 15 files changed, 391 insertions(+), 93 deletions(-) diff --git a/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabController.swift b/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabController.swift index 4e5c032..0a783b7 100644 --- a/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabController.swift +++ b/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabController.swift @@ -58,12 +58,12 @@ public final class FeedTabController: ComposableViewControllerOf publisher.map(\.path).removeDuplicates(by: { $0.ids == $1.ids }), ids: \.ids, route: { $0[id: $1] }, - switch: destinations { destination, route, id in + switch: destinations { destinations, route in switch route { case .feed: - destination.$feedControllers[id] + destinations.$feedControllers case .profile: - destination.$profileControllers[id] + destinations.$profileControllers } }, onPop: capture { _self, ids in diff --git a/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedController.swift b/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedController.swift index 83f18e8..6bb0c48 100644 --- a/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedController.swift +++ b/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedController.swift @@ -45,7 +45,7 @@ public final class TweetsFeedController: ComposableViewControllerOf( + _ publisher: P, + switch destination: @escaping (Route) -> any GrouppedDestinationProtocol, + onPop: @escaping ([C.Index]) -> Void + ) -> Cancellable where + P.Output == C, + P.Failure == Never, + C.Element == Route, + C.Index: Hashable, + C.Indices: Equatable + { + combineNavigationRouter.navigationStack( + publisher, + switch: destination, + onPop: onPop + ) + } + /// Subscribes on publisher of navigation stack state public func navigationStack< P: Publisher, @@ -32,6 +55,32 @@ extension CocoaViewController { ) } + /// Subscribes on publisher of navigation stack state + public func navigationStack< + P: Publisher, + Stack, + IDs: Collection & Equatable, + Route + >( + _ publisher: P, + ids: @escaping (Stack) -> IDs, + route: @escaping (Stack, IDs.Element) -> Route?, + switch destination: @escaping (Route) -> any GrouppedDestinationProtocol, + onPop: @escaping ([IDs.Element]) -> Void + ) -> Cancellable where + P.Output == Stack, + P.Failure == Never, + IDs.Element: Hashable + { + combineNavigationRouter.navigationStack( + publisher, + ids: ids, + route: route, + switch: destination, + onPop: onPop + ) + } + /// Subscribes on publisher of navigation stack state public func navigationStack< P: Publisher, @@ -62,6 +111,24 @@ extension CocoaViewController { // MARK: navigationDestination extension CocoaViewController { + /// Subscribes on publisher of navigation destination state + public func navigationDestination( + _ id: AnyHashable, + isPresented publisher: P, + destination: SingleDestinationProtocol, + onPop: @escaping () -> Void + ) -> AnyCancellable where + P.Output == Bool, + P.Failure == Never + { + combineNavigationRouter.navigationDestination( + id, + isPresented: publisher, + destination: destination, + onPop: onPop + ) + } + /// Subscribes on publisher of navigation destination state public func navigationDestination( _ id: AnyHashable, @@ -80,6 +147,23 @@ extension CocoaViewController { ) } + /// Subscribes on publisher of navigation destination state + public func navigationDestination( + _ publisher: P, + switch destination: @escaping (Route) -> SingleDestinationProtocol, + onPop: @escaping () -> Void + ) -> AnyCancellable where + Route: Hashable, + P.Output == Route?, + P.Failure == Never + { + combineNavigationRouter.navigationDestination( + publisher, + switch: destination, + onPop: onPop + ) + } + /// Subscribes on publisher of navigation destination state public func navigationDestination( _ publisher: P, diff --git a/Sources/CombineNavigation/Destinations/StackDestination.swift b/Sources/CombineNavigation/Destinations/StackDestination.swift index 3d9f977..7ef376e 100644 --- a/Sources/CombineNavigation/Destinations/StackDestination.swift +++ b/Sources/CombineNavigation/Destinations/StackDestination.swift @@ -4,40 +4,54 @@ import CocoaAliases import Combine import FoundationExtensions +public protocol GrouppedDestinationProtocol { + associatedtype DestinationID: Hashable + + @_spi(Internals) + func _initControllerIfNeeded(for id: DestinationID) -> CocoaViewController + + @_spi(Internals) + func _invalidateDestination(for id: DestinationID) +} + /// Wrapper for creating and accessing managed navigation stack controllers @propertyWrapper open class StackDestination< - StackElementID: Hashable, + DestinationID: Hashable, Controller: CocoaViewController ->: Weakifiable { - private var _controllers: [StackElementID: Weak] = [:] +>: Weakifiable, GrouppedDestinationProtocol { + @_spi(Internals) + open var _controllers: [DestinationID: Controller] = [:] - open var wrappedValue: [StackElementID: Controller] { - let controllers = _controllers.compactMapValues(\.wrappedValue) - _controllers = controllers.mapValues(Weak.init(wrappedValue:)) - return controllers + open var wrappedValue: [DestinationID: Controller] { + _controllers } - open var projectedValue: StackDestination { self } + @inlinable + open var projectedValue: StackDestination { self } - private var _initControllerOverride: ((StackElementID) -> Controller)? + @usableFromInline + internal var _initControllerOverride: ((DestinationID) -> Controller)? - private var _configuration: ((Controller, StackElementID) -> Void)? + @usableFromInline + internal var _configuration: ((Controller, DestinationID) -> Void)? /// Sets instance-specific override for creating a new controller /// /// This override has the highest priority when creating a new controller /// /// To disable isntance-specific override pass `nil` to this method + @inlinable public func overrideInitController( - with closure: ((StackElementID) -> Controller)? + with closure: ((DestinationID) -> Controller)? ) { _initControllerOverride = closure } /// Sets instance-specific configuration for controllers + @inlinable public func setConfiguration( - _ closure: ((Controller, StackElementID) -> Void)? + _ closure: ((Controller, DestinationID) -> Void)? ) { _configuration = closure closure.map { configure in @@ -47,15 +61,19 @@ open class StackDestination< } } - @_spi(Internals) open class func initController( - for id: StackElementID + @_spi(Internals) + @inlinable + open class func initController( + for id: DestinationID ) -> Controller { return Controller() } - @_spi(Internals) open func configureController( + @_spi(Internals) + @inlinable + open func configureController( _ controller: Controller, - for id: StackElementID + for id: DestinationID ) {} /// Creates a new instance @@ -70,16 +88,31 @@ open class StackDestination< /// doesn't have a custom init you'll have to use this method or if you have a base controller that /// requires custom init it'll be beneficial for you to create a custom subclass of StackDestination /// and override it's `initController` class method, you can find an example in tests. - public convenience init(_ initControllerOverride: @escaping (StackElementID) -> Controller) { + @inlinable + public convenience init(_ initControllerOverride: @escaping (DestinationID) -> Controller) { self.init() self.overrideInitController(with: initControllerOverride) } + @_spi(Internals) + @inlinable + public func _initControllerIfNeeded( + for id: DestinationID + ) -> CocoaViewController { + return self[id] + } + + @_spi(Internals) + @inlinable + open func _invalidateDestination(for id: DestinationID) { + self._controllers.removeValue(forKey: id) + } + /// Returns `wrappedValue[id]` if present, intializes and configures a new instance otherwise - public subscript(_ id: StackElementID) -> Controller { + public subscript(_ id: DestinationID) -> Controller { let controller = wrappedValue[id] ?? { let controller = _initControllerOverride?(id) ?? Self.initController(for: id) - _controllers[id] = Weak(controller) + _controllers[id] = controller configureController(controller, for: id) _configuration?(controller, id) return controller @@ -89,3 +122,10 @@ open class StackDestination< } } #endif + +/* +- Add erased protocols for Tree/StackDestination with some cleanup function +- Make RoutingController.destinations method return an instance of the protocol +- In CocoaViewController+API.swift add custom navigationStack/Destination methods +- Those methods will inject cleanup function call into onPop handler +*/ diff --git a/Sources/CombineNavigation/Destinations/TreeDestination.swift b/Sources/CombineNavigation/Destinations/TreeDestination.swift index 208b1d1..4c05b0b 100644 --- a/Sources/CombineNavigation/Destinations/TreeDestination.swift +++ b/Sources/CombineNavigation/Destinations/TreeDestination.swift @@ -4,22 +4,40 @@ import CocoaAliases import Combine import FoundationExtensions +public protocol SingleDestinationProtocol { + @_spi(Internals) + func _initControllerIfNeeded() -> CocoaViewController + + @_spi(Internals) + func _invalidateDestination() +} + /// Wrapper for creating and accessing managed navigation destination controller @propertyWrapper -open class TreeDestination: Weakifiable { - private weak var _controller: Controller? +open class TreeDestination: + Weakifiable, + SingleDestinationProtocol +{ + @_spi(Internals) + open var _controller: Controller? + open var wrappedValue: Controller? { _controller } + + @inlinable open var projectedValue: TreeDestination { self } - private var _initControllerOverride: (() -> Controller)? + @usableFromInline + internal var _initControllerOverride: (() -> Controller)? - private var _configuration: ((Controller) -> Void)? + @usableFromInline + internal var _configuration: ((Controller) -> Void)? /// Sets instance-specific override for creating a new controller /// /// This override has the highest priority when creating a new controller /// /// To disable isntance-specific override pass `nil` to this method + @inlinable public func overrideInitController( with closure: (() -> Controller)? ) { @@ -27,6 +45,7 @@ open class TreeDestination: Weakifiable { } /// Sets instance-specific configuration for controllers + @inlinable public func setConfiguration( _ closure: ((Controller) -> Void)? ) { @@ -36,12 +55,16 @@ open class TreeDestination: Weakifiable { } } - @_spi(Internals) open class func initController() -> Controller { + @_spi(Internals) + @inlinable + open class func initController() -> Controller { return Controller() } - @_spi(Internals) open func configureController(_ controller: Controller) {} - + @_spi(Internals) + @inlinable + open func configureController(_ controller: Controller) {} + /// Creates a new instance public init() {} @@ -54,11 +77,24 @@ open class TreeDestination: Weakifiable { /// doesn't have a custom init you'll have to use this method or if you have a base controller that /// requires custom init it'll be beneficial for you to create a custom subclass of TreeDestination /// and override it's `initController` class method, you can find an example in tests. + @inlinable public convenience init(_ initControllerOverride: @escaping () -> Controller) { self.init() self.overrideInitController(with: initControllerOverride) } - + + @_spi(Internals) + @inlinable + public func _initControllerIfNeeded() -> CocoaViewController { + self.callAsFunction() + } + + @_spi(Internals) + @inlinable + open func _invalidateDestination() { + self._controller = nil + } + /// Returns wrappedValue if present, intializes and configures a new instance otherwise public func callAsFunction() -> Controller { let controller = wrappedValue ?? { diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift index df08e05..69bb96a 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift @@ -10,6 +10,33 @@ import FoundationExtensions extension CombineNavigationRouter { /// Subscribes on publisher of navigation stack state + @usableFromInline + func navigationStack< + P: Publisher, + C: Collection & Equatable, + Route: Hashable + >( + _ publisher: P, + switch destination: @escaping (Route) -> any GrouppedDestinationProtocol, + onPop: @escaping ([C.Index]) -> Void + ) -> Cancellable where + P.Output == C, + P.Failure == Never, + C.Element == Route, + C.Index: Hashable, + C.Indices: Equatable + { + navigationStack( + publisher, + ids: \.indices, + route: { $0[$1] }, + switch: destination, + onPop: onPop + ) + } + + /// Subscribes on publisher of navigation stack state + @usableFromInline func navigationStack< P: Publisher, C: Collection & Equatable, @@ -35,8 +62,44 @@ extension CombineNavigationRouter { onPop: onPop ) } + /// Subscribes on publisher of navigation stack state + @usableFromInline + func navigationStack< + P: Publisher, + Stack, + IDs: Collection & Equatable, + Route + >( + _ publisher: P, + ids: @escaping (Stack) -> IDs, + route: @escaping (Stack, IDs.Element) -> Route?, + switch destination: @escaping (Route) -> any GrouppedDestinationProtocol, + onPop: @escaping ([IDs.Element]) -> Void + ) -> Cancellable where + P.Output == Stack, + P.Failure == Never, + IDs.Element: Hashable + { + navigationStack( + publisher: publisher, + routes: capture(orReturn: []) { _self, stack in + ids(stack).compactMap { id in + route(stack, id).map { route in + let destination = destination(route) + return _self.makeNavigationRoute( + for: id, + controller: { destination._initControllerIfNeeded(for: id) }, + invalidationHandler: { destination._invalidateDestination(for: id) } + ) + } + } + }, + onPop: onPop + ) + } /// Subscribes on publisher of navigation stack state + @usableFromInline func navigationStack< P: Publisher, Stack, @@ -53,29 +116,46 @@ extension CombineNavigationRouter { P.Failure == Never, IDs.Element: Hashable { - let getRoutes: (Stack) -> [NavigationRoute] = capture(orReturn: []) { _self, stack in - ids(stack).compactMap { id in - route(stack, id).map { route in - _self.makeNavigationRoute(for: id) { controller(route, id) } + navigationStack( + publisher: publisher, + routes: capture(orReturn: []) { _self, stack in + ids(stack).compactMap { id in + route(stack, id).map { route in + _self.makeNavigationRoute(for: id) { controller(route, id) } + } } - } - } + }, + onPop: onPop + ) + } + /// Subscribes on publisher of navigation stack state + @usableFromInline + func navigationStack< + P: Publisher, + Stack, + DestinationID + >( + publisher: P, + routes: @escaping (Stack) -> [NavigationRoute], + onPop: @escaping ([DestinationID]) -> Void + ) -> Cancellable where + P.Output == Stack, + P.Failure == Never + { return publisher .sinkValues(capture { _self, stack in - let managedRoutes = getRoutes(stack) - + let managedRoutes = routes(stack) + _self.setRoutes( managedRoutes, - onPop: managedRoutes.isNotEmpty + onPop: managedRoutes.isNotEmpty ? { poppedRoutes in - let poppedIDs: [IDs.Element] = poppedRoutes.compactMap { route in + onPop(poppedRoutes.compactMap { route in guard managedRoutes.contains(where: { $0 === route }) else { return nil } - return route.id as? IDs.Element - } - - if poppedIDs.isNotEmpty { onPop(poppedIDs) } - } + return route.id as? DestinationID + }) + } : nil ) }) @@ -86,6 +166,25 @@ extension CombineNavigationRouter { extension CombineNavigationRouter { /// Subscribes on publisher of navigation destination state + @usableFromInline + func navigationDestination( + _ id: AnyHashable, + isPresented publisher: P, + destination: SingleDestinationProtocol, + onPop: @escaping () -> Void + ) -> AnyCancellable where + P.Output == Bool, + P.Failure == Never + { + navigationDestination( + publisher.map { $0 ? id : nil }, + switch: { _ in destination }, + onPop: onPop + ) + } + + /// Subscribes on publisher of navigation destination state + @usableFromInline func navigationDestination( _ id: AnyHashable, isPresented publisher: P, @@ -103,6 +202,41 @@ extension CombineNavigationRouter { } /// Subscribes on publisher of navigation destination state + @usableFromInline + func navigationDestination( + _ publisher: P, + switch destination: @escaping (Route) -> SingleDestinationProtocol, + onPop: @escaping () -> Void + ) -> AnyCancellable where + Route: Hashable, + P.Output == Optional, + P.Failure == Never + { + publisher + .map { [weak self] (route) -> NavigationRoute? in + guard let self, let route else { return nil } + let destination = destination(route) + return self.makeNavigationRoute( + for: route, + controller: destination._initControllerIfNeeded, + invalidationHandler: destination._invalidateDestination + ) + } + .sinkValues(capture { _self, route in + _self.setRoutes( + route.map { [$0] }.or([]), + onPop: route.map { route in + return { poppedRoutes in + let shouldTriggerPopHandler = poppedRoutes.contains(where: { $0 === route }) + if shouldTriggerPopHandler { onPop() } + } + } + ) + }) + } + + /// Subscribes on publisher of navigation destination state + @usableFromInline func navigationDestination( _ publisher: P, switch controller: @escaping (Route) -> CocoaViewController, diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift index a92a071..8bb224a 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift @@ -25,15 +25,18 @@ extension CombineNavigationRouter { let routingControllerID: ObjectIdentifier private(set) var routedControllerID: ObjectIdentifier? private let controller: () -> CocoaViewController? + private let invalidationHandler: (() -> Void)? init( id: AnyHashable, routingControllerID: ObjectIdentifier, - controller: @escaping () -> CocoaViewController? + controller: @escaping () -> CocoaViewController?, + invalidationHandler: (() -> Void)? ) { self.id = id self.routingControllerID = routingControllerID self.controller = controller + self.invalidationHandler = invalidationHandler } func makeController( @@ -81,12 +84,14 @@ final class CombineNavigationRouter: Weakifiable { func makeNavigationRoute( for id: ID, - controller: @escaping () -> CocoaViewController? + controller: @escaping () -> CocoaViewController?, + invalidationHandler: (() -> Void)? = nil ) -> NavigationRoute { NavigationRoute( id: id, routingControllerID: node.objectID, - controller: controller + controller: controller, + invalidationHandler: invalidationHandler ) } } diff --git a/Sources/CombineNavigation/RoutingController.swift b/Sources/CombineNavigation/RoutingController.swift index 0b430b4..46f5db0 100644 --- a/Sources/CombineNavigation/RoutingController.swift +++ b/Sources/CombineNavigation/RoutingController.swift @@ -7,31 +7,24 @@ public protocol RoutingController: CocoaViewController { } extension RoutingController { - private static func _mapNavigationDestinations( - _ destinations: Destinations, - _ mapping: @escaping (Destinations, repeat each Arg) -> Output - ) -> (repeat each Arg) -> Output { - return { (arg: repeat each Arg) in - mapping(destinations, repeat each arg) - } - } - + @inlinable public func destinations( - _ mapping: @escaping (Destinations, Route) -> CocoaViewController - ) -> (Route) -> CocoaViewController { - Self._mapNavigationDestinations( - _makeDestinations(), - mapping - ) + _ mapping: @escaping (Destinations, Route) -> SingleDestinationProtocol + ) -> (Route) -> SingleDestinationProtocol { + let destinations = _makeDestinations() + return { route in + mapping(destinations, route) + } } - public func destinations( - _ mapping: @escaping (Destinations, Route, ID) -> CocoaViewController - ) -> (Route, ID) -> CocoaViewController { - Self._mapNavigationDestinations( - _makeDestinations(), - mapping - ) + @inlinable + public func destinations( + _ mapping: @escaping (Destinations, Route) -> any GrouppedDestinationProtocol + ) -> (Route) -> any GrouppedDestinationProtocol { + let destinations = _makeDestinations() + return { route in + mapping(destinations, route) + } } } #endif diff --git a/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift b/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift index 5278b9e..cc8c995 100644 --- a/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift +++ b/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift @@ -121,7 +121,7 @@ fileprivate final class CustomStackDestination< /// Override this method to apply initial configuration to the controller /// - /// `CombineNavigation` should be imported as `@_spi(Internal) import` + /// `CombineNavigation` should be imported as `@_spi(Internals) import` /// to override this declaration override func configureController( _ controller: Controller, @@ -134,7 +134,7 @@ fileprivate final class CustomStackDestination< /// so you can override wrapper's `initController` method /// to call some specific initializer /// - /// `CombineNavigation` should be imported as `@_spi(Internal) import` + /// `CombineNavigation` should be imported as `@_spi(Internals) import` /// to override this declaration override class func initController( for id: StackElementID diff --git a/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift b/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift index 1ccaf9b..51e7d85 100644 --- a/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift +++ b/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift @@ -66,7 +66,7 @@ fileprivate final class CustomTreeDestination: /// Override this method to apply initial configuration to the controller /// - /// `CombineNavigation` should be imported as `@_spi(Internal) import` + /// `CombineNavigation` should be imported as `@_spi(Internals) import` /// to override this declaration override func configureController(_ controller: Controller) { controller.isConfiguredByCustomNavigationChild = true @@ -76,8 +76,8 @@ fileprivate final class CustomTreeDestination: /// so you can override wrapper's `initController` method /// to call some specific initializer /// - /// `CombineNavigation` should be imported as `@_spi(Internal) import` - /// to override this declaration + /// `CombineNavigation` should be imported as `@_spi(Internals) import` + /// to override this declaration override class func initController() -> Controller { .init(value: 1) } diff --git a/Tests/CombineNavigationTests/NavigationAnimationTests.swift b/Tests/CombineNavigationTests/NavigationAnimationTests.swift index fc275e7..db757ab 100644 --- a/Tests/CombineNavigationTests/NavigationAnimationTests.swift +++ b/Tests/CombineNavigationTests/NavigationAnimationTests.swift @@ -216,9 +216,9 @@ fileprivate class TreeViewController: CocoaViewController { switch: destinations { destinations, route in switch route { case .orderDetail: - destinations.$orderDetailController() + destinations.$orderDetailController case .feedback: - destinations.$feedbackController() + destinations.$feedbackController } }, onPop: capture { _self in diff --git a/Tests/CombineNavigationTests/RoutingControllerStackTests.swift b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift index 396fe4f..75be47b 100644 --- a/Tests/CombineNavigationTests/RoutingControllerStackTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift @@ -6,6 +6,9 @@ import Combine #if canImport(UIKit) && !os(watchOS) +// TODO: Test deinitialization +// Note: Manual check succeed ✅ + final class RoutingControllerStackTests: XCTestCase { static override func setUp() { CombineNavigation.bootstrap() @@ -186,12 +189,12 @@ fileprivate class StackViewController: CocoaViewController { func bind>(_ publisher: P) { navigationStack( publisher.map(\.path).map { $0.map(\.tag) }.removeDuplicates(), - switch: destinations { destinations, route, index in + switch: destinations { destinations, route in switch route { case .orderDetail: - destinations.$orderDetailControllers[index] + destinations.$orderDetailControllers case .feedback: - destinations.$feedbackControllers[index] + destinations.$feedbackControllers } }, onPop: capture { _self, indices in diff --git a/Tests/CombineNavigationTests/RoutingControllerTests.swift b/Tests/CombineNavigationTests/RoutingControllerTests.swift index 70cc8c7..008e87c 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTests.swift @@ -177,9 +177,9 @@ fileprivate class TreeViewController: CocoaViewController { switch: destinations { destinations, route in switch route { case .tree: - destinations.$treeController() + destinations.$treeController case .stack: - destinations.$stackController() + destinations.$stackController } }, onPop: capture { _self in @@ -306,12 +306,12 @@ fileprivate class StackViewController: CocoaViewController { navigationStack( publisher.map(\.path).map { $0.map(\.tag) }.removeDuplicates(), - switch: destinations { destination, route, index in + switch: destinations { destination, route in switch route { case .tree: - destination.$treeControllers[index] + destination.$treeControllers case .stack: - destination.$stackControllers[index] + destination.$stackControllers } }, onPop: capture { _self, indices in diff --git a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift index 1677e88..d75a8d9 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift @@ -6,6 +6,9 @@ import Combine #if canImport(UIKit) && !os(watchOS) +// TODO: Test deinitialization +// Note: Manual check succeed ✅ + final class RoutingControllerTreeTests: XCTestCase { static override func setUp() { CombineNavigation.bootstrap() @@ -105,9 +108,9 @@ fileprivate class TreeViewController: CocoaViewController { switch: destinations { destinations, route in switch route { case .orderDetail: - destinations.$orderDetailController() + destinations.$orderDetailController case .feedback: - destinations.$feedbackController() + destinations.$feedbackController } }, onPop: capture { _self in From 45dda86770e3b12795d9e3b29fed8d042bc64120 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Fri, 22 Dec 2023 04:18:16 +0100 Subject: [PATCH 16/43] feat: API Improvements --- README.md | 4 +- .../CocoaViewController+API.swift | 124 +++++------------- .../Internal/CombineNavigationRouter.swift | 11 +- .../CombineNavigation/RoutingController.swift | 22 ---- .../NavigationAnimationTests.swift | 2 +- .../RoutingControllerStackTests.swift | 2 +- .../RoutingControllerTests.swift | 9 +- .../RoutingControllerTreeTests.swift | 2 +- 8 files changed, 53 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index aa0d1c0..46d7d4b 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ final class MyViewController: UIViewController { func bindViewModel() { navigationDestination( viewModel.publisher(for: \.state.route), - switch: destinations { destinations, route in + switch: { destinations, route in switch route { case .details: destinations.$detailsController @@ -110,7 +110,7 @@ final class MyViewController: UIViewController { func bindViewModel() { navigationStack( viewModel.publisher(for: \.state.path), - switch: destinations { destinations, route in + switch: { destinations, route in switch route { case .featureA: destinations.$featureAControllers diff --git a/Sources/CombineNavigation/CocoaViewController+API.swift b/Sources/CombineNavigation/CocoaViewController+API.swift index 34989b8..808c74e 100644 --- a/Sources/CombineNavigation/CocoaViewController+API.swift +++ b/Sources/CombineNavigation/CocoaViewController+API.swift @@ -8,15 +8,16 @@ import FoundationExtensions // MARK: navigationStack -extension CocoaViewController { +extension RoutingController { /// Subscribes on publisher of navigation stack state + @inlinable public func navigationStack< P: Publisher, C: Collection & Equatable, Route: Hashable >( _ publisher: P, - switch destination: @escaping (Route) -> any GrouppedDestinationProtocol, + switch destination: @escaping (Destinations, Route) -> any GrouppedDestinationProtocol, onPop: @escaping ([C.Index]) -> Void ) -> Cancellable where P.Output == C, @@ -27,61 +28,13 @@ extension CocoaViewController { { combineNavigationRouter.navigationStack( publisher, - switch: destination, - onPop: onPop - ) - } - - /// Subscribes on publisher of navigation stack state - public func navigationStack< - P: Publisher, - C: Collection & Equatable, - Route: Hashable - >( - _ publisher: P, - switch controller: @escaping (Route, C.Index) -> CocoaViewController, - onPop: @escaping ([C.Index]) -> Void - ) -> Cancellable where - P.Output == C, - P.Failure == Never, - C.Element == Route, - C.Index: Hashable, - C.Indices: Equatable - { - combineNavigationRouter.navigationStack( - publisher, - switch: controller, - onPop: onPop - ) - } - - /// Subscribes on publisher of navigation stack state - public func navigationStack< - P: Publisher, - Stack, - IDs: Collection & Equatable, - Route - >( - _ publisher: P, - ids: @escaping (Stack) -> IDs, - route: @escaping (Stack, IDs.Element) -> Route?, - switch destination: @escaping (Route) -> any GrouppedDestinationProtocol, - onPop: @escaping ([IDs.Element]) -> Void - ) -> Cancellable where - P.Output == Stack, - P.Failure == Never, - IDs.Element: Hashable - { - combineNavigationRouter.navigationStack( - publisher, - ids: ids, - route: route, - switch: destination, + switch: destinations(destination), onPop: onPop ) } /// Subscribes on publisher of navigation stack state + @inlinable public func navigationStack< P: Publisher, Stack, @@ -91,7 +44,7 @@ extension CocoaViewController { _ publisher: P, ids: @escaping (Stack) -> IDs, route: @escaping (Stack, IDs.Element) -> Route?, - switch controller: @escaping (Route, IDs.Element) -> CocoaViewController, + switch destination: @escaping (Destinations, Route) -> any GrouppedDestinationProtocol, onPop: @escaping ([IDs.Element]) -> Void ) -> Cancellable where P.Output == Stack, @@ -102,7 +55,7 @@ extension CocoaViewController { publisher, ids: ids, route: route, - switch: controller, + switch: destinations(destination), onPop: onPop ) } @@ -110,8 +63,9 @@ extension CocoaViewController { // MARK: navigationDestination -extension CocoaViewController { +extension RoutingController { /// Subscribes on publisher of navigation destination state + @inlinable public func navigationDestination( _ id: AnyHashable, isPresented publisher: P, @@ -130,27 +84,10 @@ extension CocoaViewController { } /// Subscribes on publisher of navigation destination state - public func navigationDestination( - _ id: AnyHashable, - isPresented publisher: P, - controller: @escaping () -> CocoaViewController, - onPop: @escaping () -> Void - ) -> AnyCancellable where - P.Output == Bool, - P.Failure == Never - { - combineNavigationRouter.navigationDestination( - id, - isPresented: publisher, - controller: controller, - onPop: onPop - ) - } - - /// Subscribes on publisher of navigation destination state + @inlinable public func navigationDestination( _ publisher: P, - switch destination: @escaping (Route) -> SingleDestinationProtocol, + switch destination: @escaping (Destinations, Route) -> SingleDestinationProtocol, onPop: @escaping () -> Void ) -> AnyCancellable where Route: Hashable, @@ -159,26 +96,33 @@ extension CocoaViewController { { combineNavigationRouter.navigationDestination( publisher, - switch: destination, + switch: destinations(destination), onPop: onPop ) } +} - /// Subscribes on publisher of navigation destination state - public func navigationDestination( - _ publisher: P, - switch controller: @escaping (Route) -> CocoaViewController, - onPop: @escaping () -> Void - ) -> AnyCancellable where - Route: Hashable, - P.Output == Route?, - P.Failure == Never - { - combineNavigationRouter.navigationDestination( - publisher, - switch: controller, - onPop: onPop - ) +// MARK: - Internal helpers + +extension RoutingController { + @usableFromInline + internal func destinations( + _ mapping: @escaping (Destinations, Route) -> SingleDestinationProtocol + ) -> (Route) -> SingleDestinationProtocol { + let destinations = _makeDestinations() + return { route in + mapping(destinations, route) + } + } + + @usableFromInline + internal func destinations( + _ mapping: @escaping (Destinations, Route) -> any GrouppedDestinationProtocol + ) -> (Route) -> any GrouppedDestinationProtocol { + let destinations = _makeDestinations() + return { route in + mapping(destinations, route) + } } } #endif diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift index 8bb224a..06ee658 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift @@ -5,7 +5,8 @@ import Combine import FoundationExtensions extension CocoaViewController { - var combineNavigationRouter: CombineNavigationRouter { + @usableFromInline + internal var combineNavigationRouter: CombineNavigationRouter { getAssociatedObject(forKey: #function) ?? { let router = CombineNavigationRouter(self) setAssociatedObject(router, forKey: #function) @@ -13,6 +14,7 @@ extension CocoaViewController { }() } + @inlinable public func addRoutedChild(_ controller: CocoaViewController) { combineNavigationRouter.addChild(controller.combineNavigationRouter) addChild(controller) @@ -20,8 +22,11 @@ extension CocoaViewController { } extension CombineNavigationRouter { + @usableFromInline class NavigationRoute: Identifiable { + @usableFromInline let id: AnyHashable + let routingControllerID: ObjectIdentifier private(set) var routedControllerID: ObjectIdentifier? private let controller: () -> CocoaViewController? @@ -50,6 +55,7 @@ extension CombineNavigationRouter { } } +@usableFromInline final class CombineNavigationRouter: Weakifiable { fileprivate weak var parent: CombineNavigationRouter? fileprivate weak var node: CocoaViewController! @@ -67,7 +73,8 @@ final class CombineNavigationRouter: Weakifiable { self.node = node } - fileprivate func addChild(_ router: CombineNavigationRouter) { + @usableFromInline + internal func addChild(_ router: CombineNavigationRouter) { router.parent = self directChildren.removeAll(where: { $0.object === router }) directChildren.append(.init(router)) diff --git a/Sources/CombineNavigation/RoutingController.swift b/Sources/CombineNavigation/RoutingController.swift index 46f5db0..0505eed 100644 --- a/Sources/CombineNavigation/RoutingController.swift +++ b/Sources/CombineNavigation/RoutingController.swift @@ -5,26 +5,4 @@ public protocol RoutingController: CocoaViewController { associatedtype Destinations func _makeDestinations() -> Destinations } - -extension RoutingController { - @inlinable - public func destinations( - _ mapping: @escaping (Destinations, Route) -> SingleDestinationProtocol - ) -> (Route) -> SingleDestinationProtocol { - let destinations = _makeDestinations() - return { route in - mapping(destinations, route) - } - } - - @inlinable - public func destinations( - _ mapping: @escaping (Destinations, Route) -> any GrouppedDestinationProtocol - ) -> (Route) -> any GrouppedDestinationProtocol { - let destinations = _makeDestinations() - return { route in - mapping(destinations, route) - } - } -} #endif diff --git a/Tests/CombineNavigationTests/NavigationAnimationTests.swift b/Tests/CombineNavigationTests/NavigationAnimationTests.swift index db757ab..50e5493 100644 --- a/Tests/CombineNavigationTests/NavigationAnimationTests.swift +++ b/Tests/CombineNavigationTests/NavigationAnimationTests.swift @@ -213,7 +213,7 @@ fileprivate class TreeViewController: CocoaViewController { func bind>(_ publisher: P) { navigationDestination( publisher.map(\.destination?.tag).removeDuplicates(), - switch: destinations { destinations, route in + switch: { destinations, route in switch route { case .orderDetail: destinations.$orderDetailController diff --git a/Tests/CombineNavigationTests/RoutingControllerStackTests.swift b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift index 75be47b..9edfc85 100644 --- a/Tests/CombineNavigationTests/RoutingControllerStackTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift @@ -189,7 +189,7 @@ fileprivate class StackViewController: CocoaViewController { func bind>(_ publisher: P) { navigationStack( publisher.map(\.path).map { $0.map(\.tag) }.removeDuplicates(), - switch: destinations { destinations, route in + switch: { destinations, route in switch route { case .orderDetail: destinations.$orderDetailControllers diff --git a/Tests/CombineNavigationTests/RoutingControllerTests.swift b/Tests/CombineNavigationTests/RoutingControllerTests.swift index 008e87c..9be1f6b 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTests.swift @@ -174,7 +174,7 @@ fileprivate class TreeViewController: CocoaViewController { navigationDestination( publisher.map(\.destination?.tag).removeDuplicates(), - switch: destinations { destinations, route in + switch: { destinations, route in switch route { case .tree: destinations.$treeController @@ -306,12 +306,12 @@ fileprivate class StackViewController: CocoaViewController { navigationStack( publisher.map(\.path).map { $0.map(\.tag) }.removeDuplicates(), - switch: destinations { destination, route in + switch: { destinations, route in switch route { case .tree: - destination.$treeControllers + destinations.$treeControllers case .stack: - destination.$stackControllers + destinations.$stackControllers } }, onPop: capture { _self, indices in @@ -320,5 +320,6 @@ fileprivate class StackViewController: CocoaViewController { ) .store(in: &cancellables) } + } #endif diff --git a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift index d75a8d9..8bb8b18 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift @@ -105,7 +105,7 @@ fileprivate class TreeViewController: CocoaViewController { func bind>(_ publisher: P) { navigationDestination( publisher.map(\.destination?.tag).removeDuplicates(), - switch: destinations { destinations, route in + switch: { destinations, route in switch route { case .orderDetail: destinations.$orderDetailController From 2a3ef1ca742a6fde1b57a674b6ac32a62fd5f930 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Fri, 22 Dec 2023 04:52:43 +0100 Subject: [PATCH 17/43] feat: Update Example - Use `composable-navigation-extensions` branch `observation-beta` - Start basic modularization --- Example/Example.xcodeproj/project.pbxproj | 10 +- Example/Example/SceneDelegate.swift | 19 +- Example/Package.swift | 286 ++++++++++++++++-- Example/README.md | 3 + .../CurrentUserProfileFeature.swift | 15 +- .../CurrentUserProfileView.swift | 48 ++- .../FeedAndProfileViewController.swift | 0 .../FeedTabFeature/FeedTabController.swift | 47 ++- .../FeedTabFeature/FeedTabFeature.swift | 6 +- .../MainFeature/MainFeature.swift | 2 +- .../Model/FollowerModel.swift | 0 .../Model/TweetModel.swift | 0 .../Model/UserModel.swift | 0 .../Model}/_Mock/MockThreads.swift | 2 +- .../ProfileFeature/ProfileFeature.swift | 8 +- .../ProfileFeature/ProfileView.swift | 27 +- .../ProfileFeedFeature.swift | 21 +- .../ProfileFeedFeature/ProfileFeedView.swift | 10 +- .../ProfileTabFeature/ProfileTabFeature.swift | 11 +- .../TweetDetailController.swift | 30 +- .../TweetDetailFeature.swift | 14 +- .../TweetDetailFeature/TweetDetailView.swift | 22 +- .../TweetFeature/TweetFeature.swift | 96 ++++++ .../AppFeature/TweetFeature/TweetView.swift | 44 +++ .../TweetsFeedController.swift | 36 ++- .../TweetsFeedFeature/TweetsFeedFeature.swift | 13 +- .../TweetsListFeature/TweetsListFeature.swift | 17 +- .../TweetsListFeature/TweetsListView.swift | 12 +- .../UserProfileFeature.swift | 17 +- .../UserProfileFeature/UserProfileView.swift | 59 ++++ .../UserSettingsFeature.swift | 7 +- .../UserSettingsView.swift | 8 +- .../TweetFeature/TweetFeature.swift | 97 ------ .../TweetFeature/TweetView.swift | 46 --- .../UserProfileFeature/UserProfileView.swift | 65 ---- .../_Core/ComposableViewController.swift | 81 ----- .../_ComposableArchitecture/Exports.swift | 12 + .../_Extensions/LocalExtensions/Exports.swift | 1 + 38 files changed, 681 insertions(+), 511 deletions(-) create mode 100644 Example/README.md rename Example/Sources/{CombineNavigationExample => AppFeature}/CurrentUserProfileFeature/CurrentUserProfileFeature.swift (86%) rename Example/Sources/{CombineNavigationExample => AppFeature}/CurrentUserProfileFeature/CurrentUserProfileView.swift (50%) rename Example/Sources/{CombineNavigationExample => AppFeature}/FeedAndProfileView/FeedAndProfileViewController.swift (100%) rename Example/Sources/{CombineNavigationExample => AppFeature}/FeedTabFeature/FeedTabController.swift (51%) rename Example/Sources/{CombineNavigationExample => AppFeature}/FeedTabFeature/FeedTabFeature.swift (92%) rename Example/Sources/{CombineNavigationExample => AppFeature}/MainFeature/MainFeature.swift (84%) rename Example/Sources/{CombineNavigationExample => AppFeature}/Model/FollowerModel.swift (100%) rename Example/Sources/{CombineNavigationExample => AppFeature}/Model/TweetModel.swift (100%) rename Example/Sources/{CombineNavigationExample => AppFeature}/Model/UserModel.swift (100%) rename Example/Sources/{CombineNavigationExample => AppFeature/Model}/_Mock/MockThreads.swift (99%) rename Example/Sources/{CombineNavigationExample => AppFeature}/ProfileFeature/ProfileFeature.swift (84%) rename Example/Sources/{CombineNavigationExample => AppFeature}/ProfileFeature/ProfileView.swift (65%) rename Example/Sources/{CombineNavigationExample => AppFeature}/ProfileFeedFeature/ProfileFeedFeature.swift (50%) rename Example/Sources/{CombineNavigationExample => AppFeature}/ProfileFeedFeature/ProfileFeedView.swift (78%) rename Example/Sources/{CombineNavigationExample => AppFeature}/ProfileTabFeature/ProfileTabFeature.swift (88%) rename Example/Sources/{CombineNavigationExample => AppFeature}/TweetDetailFeature/TweetDetailController.swift (58%) rename Example/Sources/{CombineNavigationExample => AppFeature}/TweetDetailFeature/TweetDetailFeature.swift (87%) rename Example/Sources/{CombineNavigationExample => AppFeature}/TweetDetailFeature/TweetDetailView.swift (59%) create mode 100644 Example/Sources/AppFeature/TweetFeature/TweetFeature.swift create mode 100644 Example/Sources/AppFeature/TweetFeature/TweetView.swift rename Example/Sources/{CombineNavigationExample => AppFeature}/TweetsFeedFeature/TweetsFeedController.swift (58%) rename Example/Sources/{CombineNavigationExample => AppFeature}/TweetsFeedFeature/TweetsFeedFeature.swift (82%) rename Example/Sources/{CombineNavigationExample => AppFeature}/TweetsListFeature/TweetsListFeature.swift (64%) rename Example/Sources/{CombineNavigationExample => AppFeature}/TweetsListFeature/TweetsListView.swift (73%) rename Example/Sources/{CombineNavigationExample => AppFeature}/UserProfileFeature/UserProfileFeature.swift (81%) create mode 100644 Example/Sources/AppFeature/UserProfileFeature/UserProfileView.swift rename Example/Sources/{CombineNavigationExample => AppFeature}/UserSettingsFeature/UserSettingsFeature.swift (73%) rename Example/Sources/{CombineNavigationExample => AppFeature}/UserSettingsFeature/UserSettingsView.swift (64%) delete mode 100644 Example/Sources/CombineNavigationExample/TweetFeature/TweetFeature.swift delete mode 100644 Example/Sources/CombineNavigationExample/TweetFeature/TweetView.swift delete mode 100644 Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileView.swift delete mode 100644 Example/Sources/CombineNavigationExample/_Core/ComposableViewController.swift create mode 100644 Example/Sources/_Dependencies/_ComposableArchitecture/Exports.swift create mode 100644 Example/Sources/_Extensions/LocalExtensions/Exports.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 269adc4..02259df 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - E17092122B048C170026D033 /* CombineNavigationExample in Frameworks */ = {isa = PBXBuildFile; productRef = E17092112B048C170026D033 /* CombineNavigationExample */; }; + E11363F22B32984100915F38 /* AppFeature in Frameworks */ = {isa = PBXBuildFile; productRef = E11363F12B32984100915F38 /* AppFeature */; }; E1A59CDA2AFFF5D400E08FF8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A59CD92AFFF5D400E08FF8 /* AppDelegate.swift */; }; E1A59CDC2AFFF5D400E08FF8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A59CDB2AFFF5D400E08FF8 /* SceneDelegate.swift */; }; E1A59CE32AFFF5D600E08FF8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E1A59CE22AFFF5D600E08FF8 /* Assets.xcassets */; }; @@ -28,7 +28,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E17092122B048C170026D033 /* CombineNavigationExample in Frameworks */, + E11363F22B32984100915F38 /* AppFeature in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -88,7 +88,7 @@ ); name = Example; packageProductDependencies = ( - E17092112B048C170026D033 /* CombineNavigationExample */, + E11363F12B32984100915F38 /* AppFeature */, ); productName = Example; productReference = E1A59CD62AFFF5D400E08FF8 /* Example.app */; @@ -360,9 +360,9 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ - E17092112B048C170026D033 /* CombineNavigationExample */ = { + E11363F12B32984100915F38 /* AppFeature */ = { isa = XCSwiftPackageProductDependency; - productName = CombineNavigationExample; + productName = AppFeature; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Example/Example/SceneDelegate.swift b/Example/Example/SceneDelegate.swift index adaa2be..9f0b5cc 100644 --- a/Example/Example/SceneDelegate.swift +++ b/Example/Example/SceneDelegate.swift @@ -5,18 +5,25 @@ // Created by Maxim Krouk on 11.11.2023. // +import _ComposableArchitecture import UIKit -import ComposableArchitecture -import CombineNavigationExample import CombineNavigation +import AppFeature class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? let store = Store( - initialState: FeedTabFeature.State(feed: .init(list: .init( - uncheckedUniqueElements: TweetModel.mockTweets.filter(\.replyTo.isNil) - .map { .mock(model: $0) } - ))), + initialState: FeedTabFeature.State( + feed: .init( + list: .init( + tweets: .init( + uncheckedUniqueElements: TweetModel + .mockTweets.filter(\.replyTo.isNil) + .map { .mock(model: $0) } + ) + ) + ) + ), reducer: { FeedTabFeature()._printChanges() } diff --git a/Example/Package.swift b/Example/Package.swift index 6b435cd..2d2f9d4 100644 --- a/Example/Package.swift +++ b/Example/Package.swift @@ -5,34 +5,286 @@ import PackageDescription let package = Package( name: "CombineNavigationExample", platforms: [ - .iOS(.v16) - ], - products: [ - .library( - name: "CombineNavigationExample", - targets: ["CombineNavigationExample"] - ), + .iOS(.v17) ], dependencies: [ - .package(path: ".."), .package( - url: "https://github.com/pointfreeco/swift-composable-architecture.git", - .upToNextMajor(from: "1.5.0") + url: "https://github.com/capturecontext/composable-architecture-extensions.git", + branch: "observation-beta" + ), + .package( + url: "https://github.com/capturecontext/swift-foundation-extensions.git", + .upToNextMinor(from: "0.4.0") ), ], - targets: [ + producibleTargets: [ + // MARK: - Utils + // Basic extensions for every module + // Ideally should be extracted to a separate `Extensions` package + // See https://github.com/capturecontext/basic-ios-template + // - Should not import compex dependencies + // - Must not import targets from other sections + .target( - name: "CombineNavigationExample", + name: "LocalExtensions", + product: .library(.static), dependencies: [ .product( - name: "ComposableArchitecture", - package: "swift-composable-architecture" - ), + name: "FoundationExtensions", + package: "swift-foundation-extensions" + ) + ], + path: ._extensions("LocalExtensions") + ), + + // MARK: - Dependencies + // Separate target for each dependency + // Ideally should be extracted to a separate `Dependencies` package + // See https://github.com/capturecontext/basic-ios-template + // - Can import targets from `Utils` section + + // Meant to locally extend ComposableExtensions + .target( + name: "_ComposableArchitecture", + product: .library(.static), + dependencies: [ + .localExtensions, .product( - name: "CombineNavigation", - package: "combine-cocoa-navigation" + name: "ComposableExtensions", + package: "composable-architecture-extensions" ) + ], + path: ._dependencies("_ComposableArchitecture") + ), + + // MARK: - Modules + // Application modules + // - Feature modules have suffix `Feature` + // - Service and Model modules have no suffix + + .target( + name: "AppFeature", + product: .library(.static), + dependencies: [ + .localExtensions, + .dependency("_ComposableArchitecture"), ] ) ] ) + +// MARK: - Helpers + +extension Target.Dependency { + static var localExtensions: Target.Dependency { + // .product(name: "LocalExtensions", package: "Extensions") + return .target("LocalExtensions") + } + + static func dependency(_ name: String) -> Target.Dependency { + // .product(name: name, package: "Dependencies") + return .target(name) + } + + static func target(_ name: String) -> Target.Dependency { + .target(name: name) + } +} + +extension CustomTargetPathBuilder { + static func _dependencies(_ module: String) -> Self { + .init(module).nested(in: "_Dependencies").nested(in: "Sources") + } + + static func _extensions(_ module: String) -> Self { + .init(module).nested(in: "_Extensions").nested(in: "Sources") + } +} + +struct CustomTargetPathBuilder: ExpressibleByStringLiteral { + private let build: (String) -> String + + func build(for targetName: String) -> String { + build(targetName) + } + + init(_ build: @escaping (String) -> String) { + self.build = build + } + + init(_ value: String) { + self.init { _ in value } + } + + init(stringLiteral value: String) { + self.init(value) + } + + static var targetName: Self { + return .init { $0 } + } + + func map(_ transform: @escaping (String) -> String) -> Self { + return .init { transform(self.build(for: $0)) } + } + + func nestedInSources() -> Self { + return nested(in: "Sources") + } + + func nested(in parent: String) -> Self { + return map { "\(parent)/\($0)" } + } + + func suffixed(by suffix: String) -> Self { + return map { "\($0)\(suffix)" } + } + + func prefixed(by prefix: String) -> Self { + return map { "\(prefix)\($0)" } + } +} + +enum ProductType: Equatable { + case executable + case library(PackageDescription.Product.Library.LibraryType? = .static) +} + +struct ProducibleTarget { + init( + target: Target, + productType: ProductType? = .none + ) { + self.target = target + self.productType = productType + } + + var target: Target + var productType: ProductType? + + var product: PackageDescription.Product? { + switch productType { + case .executable: + // return .executable(name: target.name, targets: [target.name]) + return nil + case .library(let type): + return .library(name: target.name, type: type, targets: [target.name]) + case .none: + return nil + } + } + + static func target( + name: String, + product productType: ProductType? = nil, + dependencies: [Target.Dependency] = [], + path: CustomTargetPathBuilder? = nil, + exclude: [String] = [], + sources: [String]? = nil, + resources: [Resource]? = nil, + publicHeadersPath: String? = nil, + packageAccess: Bool = true, + cSettings: [CSetting]? = nil, + cxxSettings: [CXXSetting]? = nil, + swiftSettings: [SwiftSetting]? = nil, + linkerSettings: [LinkerSetting]? = nil, + plugins: [Target.PluginUsage]? = nil + ) -> ProducibleTarget { + ProducibleTarget( + target: productType == .executable + ? .executableTarget( + name: name, + dependencies: dependencies, + path: path?.build(for: name), + exclude: exclude, + sources: sources, + resources: resources, + publicHeadersPath: publicHeadersPath, + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins + ) + : .target( + name: name, + dependencies: dependencies, + path: path?.build(for: name), + exclude: exclude, + sources: sources, + resources: resources, + publicHeadersPath: publicHeadersPath, + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins + ), + productType: productType + ) + } + + static func testTarget( + name: String, + dependencies: [Target.Dependency] = [], + path: CustomTargetPathBuilder? = nil, + exclude: [String] = [], + sources: [String]? = nil, + resources: [Resource]? = nil, + packageAccess: Bool = true, + cSettings: [CSetting]? = nil, + cxxSettings: [CXXSetting]? = nil, + swiftSettings: [SwiftSetting]? = nil, + linkerSettings: [LinkerSetting]? = nil, + plugins: [Target.PluginUsage]? = nil + ) -> ProducibleTarget { + ProducibleTarget( + target: .testTarget( + name: name, + dependencies: dependencies, + path: path?.build(for: name), + exclude: exclude, + sources: sources, + resources: resources, + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins + ), + productType: .none + ) + } +} + +extension Package { + convenience init( + name: String, + defaultLocalization: LanguageTag? = nil, + platforms: [SupportedPlatform]? = nil, + pkgConfig: String? = nil, + providers: [SystemPackageProvider]? = nil, + dependencies: [Dependency] = [], + producibleTargets: [ProducibleTarget], + swiftLanguageVersions: [SwiftVersion]? = nil, + cLanguageStandard: CLanguageStandard? = nil, + cxxLanguageStandard: CXXLanguageStandard? = nil + ) { + self.init( + name: name, + defaultLocalization: defaultLocalization, + platforms: platforms, + pkgConfig: pkgConfig, + providers: providers, + products: producibleTargets.compactMap(\.product), + dependencies: dependencies, + targets: producibleTargets.map(\.target), + swiftLanguageVersions: swiftLanguageVersions, + cLanguageStandard: cLanguageStandard, + cxxLanguageStandard: cxxLanguageStandard + ) + } +} diff --git a/Example/README.md b/Example/README.md new file mode 100644 index 0000000..08f0e27 --- /dev/null +++ b/Example/README.md @@ -0,0 +1,3 @@ +# Example + + diff --git a/Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileFeature.swift b/Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileFeature.swift similarity index 86% rename from Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileFeature.swift rename to Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileFeature.swift index 0439964..4690b9a 100644 --- a/Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileFeature.swift +++ b/Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileFeature.swift @@ -1,4 +1,4 @@ -import ComposableArchitecture +import _ComposableArchitecture import FoundationExtensions @Reducer @@ -7,6 +7,7 @@ public struct CurrentUserProfileFeature { @Reducer public struct Destination { + @ObservableState public enum State: Equatable { case avatarPreivew(URL) case userSettings(UserSettingsFeature.State) @@ -31,25 +32,25 @@ public struct CurrentUserProfileFeature { } } + @ObservableState public struct State: Equatable { public var model: UserModel - public var tweets: TweetsListFeature.State + public var tweetsList: TweetsListFeature.State - @PresentationState + @Presents public var destination: Destination.State? public init( model: UserModel, - tweets: TweetsListFeature.State = [], + tweetsList: TweetsListFeature.State = .init(), destination: Destination.State? = nil ) { self.model = model - self.tweets = tweets + self.tweetsList = tweetsList self.destination = destination } } - @CasePathable public enum Action: Equatable { case destination(PresentationAction) case tweetsList(TweetsListFeature.Action) @@ -76,7 +77,7 @@ public struct CurrentUserProfileFeature { ) Scope( - state: \State.tweets, + state: \State.tweetsList, action: \.tweetsList, child: TweetsListFeature.init ) diff --git a/Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileView.swift b/Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileView.swift similarity index 50% rename from Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileView.swift rename to Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileView.swift index ce673af..fb2a8bd 100644 --- a/Example/Sources/CombineNavigationExample/CurrentUserProfileFeature/CurrentUserProfileView.swift +++ b/Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileView.swift @@ -1,7 +1,7 @@ +import _ComposableArchitecture import SwiftUI -import ComposableArchitecture -public struct CurrentUserProfileView: View { +public struct CurrentUserProfileView: ComposableView { let store: StoreOf public init(_ store: StoreOf) { @@ -21,19 +21,15 @@ public struct CurrentUserProfileView: View { @ViewBuilder var headerView: some View { VStack(spacing: 24) { - WithViewStore(store, observe: \.model.avatarURL) { viewStore in - Circle() - .fill(Color(.label).opacity(0.3)) - .frame(width: 86, height: 86) - .onTapGesture { - viewStore.send(.tapOnAvatar) - } - } - WithViewStore(store, observe: \.model.username) { viewStore in - Text("@" + viewStore.state.lowercased()) - .monospaced() - .bold() - } + Circle() + .fill(Color(.label).opacity(0.3)) + .frame(width: 86, height: 86) + .onTapGesture { + store.send(.tapOnAvatar) + } + Text("@" + store.model.username.lowercased()) + .monospaced() + .bold() } } @@ -42,18 +38,16 @@ public struct CurrentUserProfileView: View { LazyVStack(spacing: 32) { ForEachStore( store.scope( - state: \.tweets, - action: { .tweetsList(.tweets($0)) } + state: \.tweetsList.tweets, + action: \.tweetsList.tweets ), content: { store in - WithViewStore(store, observe: \.text) { viewStore in - Text(viewStore.state) - .padding(.horizontal) - .contentShape(Rectangle()) - .onTapGesture { - viewStore.send(.tap) - } - } + Text(store.text) + .padding(.horizontal) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.tap) + } } ) } @@ -65,13 +59,13 @@ public struct CurrentUserProfileView: View { CurrentUserProfileView(Store( initialState: .init( model: .mock(), - tweets: [ + tweetsList: .init(tweets: [ .mock(), .mock(), .mock(), .mock(), .mock() - ] + ]) ), reducer: CurrentUserProfileFeature.init )) diff --git a/Example/Sources/CombineNavigationExample/FeedAndProfileView/FeedAndProfileViewController.swift b/Example/Sources/AppFeature/FeedAndProfileView/FeedAndProfileViewController.swift similarity index 100% rename from Example/Sources/CombineNavigationExample/FeedAndProfileView/FeedAndProfileViewController.swift rename to Example/Sources/AppFeature/FeedAndProfileView/FeedAndProfileViewController.swift diff --git a/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabController.swift b/Example/Sources/AppFeature/FeedTabFeature/FeedTabController.swift similarity index 51% rename from Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabController.swift rename to Example/Sources/AppFeature/FeedTabFeature/FeedTabController.swift index 0a783b7..9b05c80 100644 --- a/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabController.swift +++ b/Example/Sources/AppFeature/FeedTabFeature/FeedTabController.swift @@ -1,20 +1,23 @@ +import _ComposableArchitecture import UIKit import SwiftUI -import ComposableArchitecture import Combine import CombineExtensions import Capture import CombineNavigation @RoutingController -public final class FeedTabController: ComposableViewControllerOf { +public final class FeedTabController: ComposableViewController< + FeedTabFeature.State, + FeedTabFeature.Action +> { let contentController: TweetsFeedController = .init() - @StackDestination + @ComposableStackDestination var feedControllers: [StackElementID: TweetsFeedController] - @StackDestination({ _ in .init(rootView: nil) }) - var profileControllers: [StackElementID: UIHostingController] + @ComposableStackDestination({ _ in .init(rootView: nil) }) + var profileControllers: [StackElementID: ComposableHostingController] public override func viewDidLoad() { super.viewDidLoad() @@ -30,23 +33,21 @@ public final class FeedTabController: ComposableViewControllerOf public override func scope(_ store: Store?) { contentController.setStore(store?.scope( state: \.feed, - action: { .feed($0)} + action: \.feed )) - _feedControllers.setConfiguration { controller, id in - controller.setStore(store?.scope( - state: { $0.path[id: id, case: \.feed] }, - action: { .path(.element(id: id, action: .feed($0))) } - )) + _feedControllers.setStore { id in + store?.scope( + state: \.path[id: id]?.feed, + action: \.path[id: id].feed + ) } - _profileControllers.setConfiguration { controller, id in - controller.rootView = store.map { store in - ProfileView.IfLetView(store.scope( - state: { $0.path[id: id, case: \.profile] }, - action: { .path(.element(id: id, action: .profile($0))) } - )) - } + _profileControllers.setStore { id in + store?.scope( + state: \.path[id: id]?.profile, + action: \.path[id: id].profile + ) } } @@ -55,19 +56,15 @@ public final class FeedTabController: ComposableViewControllerOf into cancellables: inout Set ) { navigationStack( - publisher.map(\.path).removeDuplicates(by: { $0.ids == $1.ids }), - ids: \.ids, - route: { $0[id: $1] }, - switch: destinations { destinations, route in + state: \.path, + action: \.path, + switch: { destinations, route in switch route { case .feed: destinations.$feedControllers case .profile: destinations.$profileControllers } - }, - onPop: capture { _self, ids in - _self.sendPop(ids, from: \.path) } ) .store(in: &cancellables) diff --git a/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabFeature.swift b/Example/Sources/AppFeature/FeedTabFeature/FeedTabFeature.swift similarity index 92% rename from Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabFeature.swift rename to Example/Sources/AppFeature/FeedTabFeature/FeedTabFeature.swift index 00dcb78..86f168f 100644 --- a/Example/Sources/CombineNavigationExample/FeedTabFeature/FeedTabFeature.swift +++ b/Example/Sources/AppFeature/FeedTabFeature/FeedTabFeature.swift @@ -1,4 +1,4 @@ -import ComposableArchitecture +import _ComposableArchitecture @Reducer public struct FeedTabFeature { @@ -68,13 +68,13 @@ public struct FeedTabFeature { case .user(.tweetsList(.tweets(.element(_, .tap)))): guard case let .profile(.user(profile)) = state.path[id: stackID] else { return .none } - state.path.append(.feed(.init(list: profile.tweets))) + state.path.append(.feed(.init(list: profile.tweetsList))) return .none case .currentUser(.tweetsList(.tweets(.element(_, .tap)))): guard case let .profile(.currentUser(profile)) = state.path[id: stackID] else { return .none } - state.path.append(.feed(.init(list: profile.tweets))) + state.path.append(.feed(.init(list: profile.tweetsList))) return .none default: diff --git a/Example/Sources/CombineNavigationExample/MainFeature/MainFeature.swift b/Example/Sources/AppFeature/MainFeature/MainFeature.swift similarity index 84% rename from Example/Sources/CombineNavigationExample/MainFeature/MainFeature.swift rename to Example/Sources/AppFeature/MainFeature/MainFeature.swift index 1816f84..1ee01ea 100644 --- a/Example/Sources/CombineNavigationExample/MainFeature/MainFeature.swift +++ b/Example/Sources/AppFeature/MainFeature/MainFeature.swift @@ -1,4 +1,4 @@ -//import ComposableArchitecture +//import _ComposableArchitecture // //public struct MainFeature: Reducer { // public struct State: Equatable { diff --git a/Example/Sources/CombineNavigationExample/Model/FollowerModel.swift b/Example/Sources/AppFeature/Model/FollowerModel.swift similarity index 100% rename from Example/Sources/CombineNavigationExample/Model/FollowerModel.swift rename to Example/Sources/AppFeature/Model/FollowerModel.swift diff --git a/Example/Sources/CombineNavigationExample/Model/TweetModel.swift b/Example/Sources/AppFeature/Model/TweetModel.swift similarity index 100% rename from Example/Sources/CombineNavigationExample/Model/TweetModel.swift rename to Example/Sources/AppFeature/Model/TweetModel.swift diff --git a/Example/Sources/CombineNavigationExample/Model/UserModel.swift b/Example/Sources/AppFeature/Model/UserModel.swift similarity index 100% rename from Example/Sources/CombineNavigationExample/Model/UserModel.swift rename to Example/Sources/AppFeature/Model/UserModel.swift diff --git a/Example/Sources/CombineNavigationExample/_Mock/MockThreads.swift b/Example/Sources/AppFeature/Model/_Mock/MockThreads.swift similarity index 99% rename from Example/Sources/CombineNavigationExample/_Mock/MockThreads.swift rename to Example/Sources/AppFeature/Model/_Mock/MockThreads.swift index dfe0eca..938bff4 100644 --- a/Example/Sources/CombineNavigationExample/_Mock/MockThreads.swift +++ b/Example/Sources/AppFeature/Model/_Mock/MockThreads.swift @@ -1,4 +1,4 @@ -import ComposableArchitecture +import _ComposableArchitecture import FoundationExtensions extension TweetModel { diff --git a/Example/Sources/CombineNavigationExample/ProfileFeature/ProfileFeature.swift b/Example/Sources/AppFeature/ProfileFeature/ProfileFeature.swift similarity index 84% rename from Example/Sources/CombineNavigationExample/ProfileFeature/ProfileFeature.swift rename to Example/Sources/AppFeature/ProfileFeature/ProfileFeature.swift index 6fba4d6..81bf714 100644 --- a/Example/Sources/CombineNavigationExample/ProfileFeature/ProfileFeature.swift +++ b/Example/Sources/AppFeature/ProfileFeature/ProfileFeature.swift @@ -1,15 +1,15 @@ -import ComposableArchitecture +import _ComposableArchitecture -public struct ProfileFeature: Reducer { +@Reducer +public struct ProfileFeature { public init() {} - @CasePathable + @ObservableState public enum State: Equatable { case user(UserProfileFeature.State) case currentUser(CurrentUserProfileFeature.State) } - @CasePathable public enum Action: Equatable { case user(UserProfileFeature.Action) case currentUser(CurrentUserProfileFeature.Action) diff --git a/Example/Sources/CombineNavigationExample/ProfileFeature/ProfileView.swift b/Example/Sources/AppFeature/ProfileFeature/ProfileView.swift similarity index 65% rename from Example/Sources/CombineNavigationExample/ProfileFeature/ProfileView.swift rename to Example/Sources/AppFeature/ProfileFeature/ProfileView.swift index 13eef72..a7365d7 100644 --- a/Example/Sources/CombineNavigationExample/ProfileFeature/ProfileView.swift +++ b/Example/Sources/AppFeature/ProfileFeature/ProfileView.swift @@ -1,7 +1,7 @@ +import _ComposableArchitecture import SwiftUI -import ComposableArchitecture -public struct ProfileView: View { +public struct ProfileView: ComposableView { let store: StoreOf public init(_ store: StoreOf) { @@ -26,25 +26,6 @@ public struct ProfileView: View { } } } - - public struct IfLetView: View { - let store: Store< - ProfileFeature.State?, - ProfileFeature.Action - > - - public init( - _ store: Store< - ProfileFeature.State?, - ProfileFeature.Action - >) { - self.store = store - } - - public var body: some View { - IfLetStore(store, then: ProfileView.init) - } - } } #Preview { @@ -52,13 +33,13 @@ public struct ProfileView: View { ProfileView(Store( initialState: .user(.init( model: .mock(), - tweets: [ + tweetsList: .init(tweets: [ .mock(), .mock(), .mock(), .mock(), .mock() - ] + ]) )), reducer: ProfileFeature.init )) diff --git a/Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedFeature.swift b/Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedFeature.swift similarity index 50% rename from Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedFeature.swift rename to Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedFeature.swift index ffc34f8..0919655 100644 --- a/Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedFeature.swift +++ b/Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedFeature.swift @@ -1,29 +1,30 @@ -import ComposableArchitecture +import _ComposableArchitecture import Foundation -public struct ProfileFeedFeature: Reducer { +@Reducer +public struct ProfileFeedFeature { public init() {} + @ObservableState public struct State: Equatable { - public var items: IdentifiedArrayOf + public var tweets: IdentifiedArrayOf public init( - items: IdentifiedArrayOf = [] + tweets: IdentifiedArrayOf = [] ) { - self.items = items + self.tweets = tweets } } - @CasePathable public enum Action: Equatable { - case items(IdentifiedActionOf) + case tweets(IdentifiedActionOf) case openProfile(UUID) } public var body: some ReducerOf { Reduce { state, action in switch action { - case let .items(.element(id, action: .tapOnAuthor)): + case let .tweets(.element(id, action: .tapOnAuthor)): return .send(.openProfile(id)) default: @@ -31,8 +32,8 @@ public struct ProfileFeedFeature: Reducer { } } .forEach( - \State.items, - action: \.items, + \State.tweets, + action: \.tweets, element: TweetFeature.init ) } diff --git a/Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedView.swift b/Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedView.swift similarity index 78% rename from Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedView.swift rename to Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedView.swift index 480e91f..d56a09a 100644 --- a/Example/Sources/CombineNavigationExample/ProfileFeedFeature/ProfileFeedView.swift +++ b/Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedView.swift @@ -1,7 +1,7 @@ +import _ComposableArchitecture import SwiftUI -import ComposableArchitecture -public struct ProfileFeedView: View { +public struct ProfileFeedView: ComposableView { let store: StoreOf public init(_ store: StoreOf) { @@ -13,8 +13,8 @@ public struct ProfileFeedView: View { LazyVStack(spacing: 24) { ForEachStore( store.scope( - state: \.items, - action: { .items($0) } + state: \.tweets, + action: \.tweets ), content: TweetView.init ) @@ -27,7 +27,7 @@ public struct ProfileFeedView: View { NavigationStack { ProfileFeedView(Store( initialState: .init( - items: [ + tweets: [ .mock(), .mock(), .mock(), diff --git a/Example/Sources/CombineNavigationExample/ProfileTabFeature/ProfileTabFeature.swift b/Example/Sources/AppFeature/ProfileTabFeature/ProfileTabFeature.swift similarity index 88% rename from Example/Sources/CombineNavigationExample/ProfileTabFeature/ProfileTabFeature.swift rename to Example/Sources/AppFeature/ProfileTabFeature/ProfileTabFeature.swift index 0d162a4..19129b0 100644 --- a/Example/Sources/CombineNavigationExample/ProfileTabFeature/ProfileTabFeature.swift +++ b/Example/Sources/AppFeature/ProfileTabFeature/ProfileTabFeature.swift @@ -1,15 +1,17 @@ -import ComposableArchitecture +import _ComposableArchitecture -public struct ProfileTabFeature: Reducer { +@Reducer +public struct ProfileTabFeature { public init() {} + @Reducer public struct Path: Reducer { + @ObservableState public enum State: Equatable { case feed(TweetsFeedFeature.State) case profile(UserProfileFeature.State) } - @CasePathable public enum Action: Equatable { case feed(TweetsFeedFeature.Action) case profile(UserProfileFeature.Action) @@ -29,6 +31,7 @@ public struct ProfileTabFeature: Reducer { } } + @ObservableState public struct State: Equatable { public var path: StackState @@ -56,7 +59,7 @@ public struct ProfileTabFeature: Reducer { guard case let .profile(profile) = state.path[id: stackID] else { return .none } - state.path.append(.feed(.init(list: profile.tweets))) + state.path.append(.feed(.init(list: profile.tweetsList))) return .none default: diff --git a/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailController.swift b/Example/Sources/AppFeature/TweetDetailFeature/TweetDetailController.swift similarity index 58% rename from Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailController.swift rename to Example/Sources/AppFeature/TweetDetailFeature/TweetDetailController.swift index 663c7b8..2f77593 100644 --- a/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailController.swift +++ b/Example/Sources/AppFeature/TweetDetailFeature/TweetDetailController.swift @@ -1,16 +1,19 @@ +import _ComposableArchitecture import UIKit import SwiftUI -import ComposableArchitecture import Combine import CombineExtensions import Capture import CombineNavigation @RoutingController -public final class TweetDetailController: ComposableViewControllerOf { - let host = UIHostingController(rootView: nil) +public final class TweetDetailController: ComposableViewController< + TweetDetailFeature.State, + TweetDetailFeature.Action +> { + let host = ComposableHostingController(rootView: nil) - @TreeDestination + @ComposableTreeDestination var detailController: TweetDetailController? public override func viewDidLoad() { @@ -23,14 +26,12 @@ public final class TweetDetailController: ComposableViewControllerOf ) { navigationDestination( - "reply_detail", - isPresented: publisher.detail.isNotNil, - controller: _detailController.callAsFunction, - onPop: captureSend(.detail(.dismiss)) + isPresented: \.detail.isNotNil, + destination: $detailController, + popAction: .detail(.dismiss) ) .store(in: &cancellables) } diff --git a/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailFeature.swift b/Example/Sources/AppFeature/TweetDetailFeature/TweetDetailFeature.swift similarity index 87% rename from Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailFeature.swift rename to Example/Sources/AppFeature/TweetDetailFeature/TweetDetailFeature.swift index a51e592..2813947 100644 --- a/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailFeature.swift +++ b/Example/Sources/AppFeature/TweetDetailFeature/TweetDetailFeature.swift @@ -1,15 +1,16 @@ -import ComposableArchitecture +import _ComposableArchitecture import Foundation @Reducer public struct TweetDetailFeature { public init() {} + @ObservableState public struct State: Equatable { public var source: TweetFeature.State public var replies: TweetsListFeature.State - @PresentationState + @Presents public var detail: TweetDetailFeature.State? public init( @@ -28,9 +29,12 @@ public struct TweetDetailFeature { TweetModel.mockTweets[id: id].map { source in return .init( source: .mock(model: source), - replies: IdentifiedArray( - uncheckedUniqueElements: TweetModel.mockReplies(for: source.id) - .map { .mock(model: $0) } + replies: .init( + tweets: IdentifiedArray( + uncheckedUniqueElements: TweetModel + .mockReplies(for: source.id) + .map { .mock(model: $0) } + ) ) ) } diff --git a/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailView.swift b/Example/Sources/AppFeature/TweetDetailFeature/TweetDetailView.swift similarity index 59% rename from Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailView.swift rename to Example/Sources/AppFeature/TweetDetailFeature/TweetDetailView.swift index cc80336..bf2ed37 100644 --- a/Example/Sources/CombineNavigationExample/TweetDetailFeature/TweetDetailView.swift +++ b/Example/Sources/AppFeature/TweetDetailFeature/TweetDetailView.swift @@ -1,7 +1,7 @@ +import _ComposableArchitecture import SwiftUI -import ComposableArchitecture -public struct TweetDetailView: View { +public struct TweetDetailView: ComposableView { let store: StoreOf public init(_ store: StoreOf) { @@ -13,19 +13,17 @@ public struct TweetDetailView: View { LazyVStack(spacing: 24) { TweetView(store.scope( state: \.source, - action: { .source($0) } + action: \.source )) HStack(spacing: 0) { - WithViewStore(store, observe: \.replies.isEmpty) { isEmpty in - if !isEmpty.state { - RoundedRectangle(cornerRadius: 1, style: .circular) - .fill(Color(.label).opacity(0.3)) - .frame(maxWidth: 2, maxHeight: .infinity) - } + if !store.replies.tweets.isEmpty { + RoundedRectangle(cornerRadius: 1, style: .circular) + .fill(Color(.label).opacity(0.3)) + .frame(maxWidth: 2, maxHeight: .infinity) } TweetsListView(store.scope( state: \.replies, - action: { .replies($0) } + action: \.replies )) .padding(.top) } @@ -39,10 +37,10 @@ public struct TweetDetailView: View { TweetDetailView(Store( initialState: .init( source: .mock(), - replies: [ + replies: .init(tweets: [ .mock(), .mock() - ] + ]) ), reducer: TweetDetailFeature.init )) diff --git a/Example/Sources/AppFeature/TweetFeature/TweetFeature.swift b/Example/Sources/AppFeature/TweetFeature/TweetFeature.swift new file mode 100644 index 0000000..19d27b3 --- /dev/null +++ b/Example/Sources/AppFeature/TweetFeature/TweetFeature.swift @@ -0,0 +1,96 @@ +import _ComposableArchitecture +import Foundation + +@Reducer +public struct TweetFeature { + public init() {} + + @ObservableState + public struct State: Equatable, Identifiable { + public var id: UUID + public var replyTo: UUID? + public var author: UserModel + public var text: String + + public init( + id: UUID, + replyTo: UUID? = nil, + author: UserModel, + text: String + ) { + self.id = id + self.replyTo = replyTo + self.author = author + self.text = text + } + + public static func mock( + model: TweetModel + ) -> Self { + .mock( + id: model.id, + replyTo: model.replyTo, + author: .mock(id: model.authorID), + text: model.text + ) + } + + public static func mock( + id: UUID = .init(), + replyTo: UUID? = nil, + author: UserModel = .mock(), + text: String = """ + Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ + Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ + In ad laboris labore irure ea ea officia. + """ + ) -> Self { + .init( + id: id, + replyTo: replyTo, + author: author, + text: text + ) + } + + public func mockReply( + id: UUID = .init(), + author: UserModel = .mock(), + text: String = """ + Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ + Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ + In ad laboris labore irure ea ea officia. + """ + ) -> Self { + .init( + id: id, + replyTo: self.id, + author: author, + text: text + ) + } + } + + public enum Action: Equatable { + case tap + case tapOnAuthor + case openDetail(for: UUID) + case openProfile(UUID) + } + + public func reduce( + into state: inout State, + action: Action + ) -> Effect { + switch action { + case .tap: + return .send(.openDetail(for: state.id)) + + case .tapOnAuthor: + return.send(.openProfile(state.author.id)) + + default: + return .none + } + } +} diff --git a/Example/Sources/AppFeature/TweetFeature/TweetView.swift b/Example/Sources/AppFeature/TweetFeature/TweetView.swift new file mode 100644 index 0000000..22a2dd2 --- /dev/null +++ b/Example/Sources/AppFeature/TweetFeature/TweetView.swift @@ -0,0 +1,44 @@ +import _ComposableArchitecture +import SwiftUI + +public struct TweetView: ComposableView { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 16) { + Circle() // Avatar + .fill(Color(.label).opacity(0.3)) + .frame(width: 54, height: 54) + Text("@" + store.author.username.lowercased()).bold() + Spacer() + } + .contentShape(Rectangle()) + .onTapGesture { + store.send(.tapOnAuthor) + } + Text(store.text) + .onTapGesture { + store.send(.tap) + } + } + .padding(.horizontal) + .background( + Color(.systemBackground) + .onTapGesture { + store.send(.tap) + } + ) + } +} + +#Preview { + TweetView(Store( + initialState: .mock(), + reducer: TweetFeature.init + )) +} diff --git a/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedController.swift b/Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedController.swift similarity index 58% rename from Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedController.swift rename to Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedController.swift index 6bb0c48..2fd0d85 100644 --- a/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedController.swift +++ b/Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedController.swift @@ -1,16 +1,19 @@ +import _ComposableArchitecture import UIKit import SwiftUI -import ComposableArchitecture import Combine import CombineExtensions import Capture import CombineNavigation @RoutingController -public final class TweetsFeedController: ComposableViewControllerOf { - let host = UIHostingController(rootView: nil) +public final class TweetsFeedController: ComposableViewController< + TweetsFeedFeature.State, + TweetsFeedFeature.Action +> { + let host = ComposableHostingController(rootView: nil) - @TreeDestination + @ComposableTreeDestination var detailController: TweetDetailController? public override func viewDidLoad() { @@ -23,19 +26,15 @@ public final class TweetsFeedController: ComposableViewControllerOf ) { navigationDestination( - "reply_detail", - isPresented: publisher.detail.isNotNil, + isPresented: \.detail.isNotNil, destination: $detailController, - onPop: captureSend(.detail(.dismiss)) + popAction: .detail(.dismiss) ) .store(in: &cancellables) } diff --git a/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedFeature.swift b/Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedFeature.swift similarity index 82% rename from Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedFeature.swift rename to Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedFeature.swift index c8dfa33..ce861ca 100644 --- a/Example/Sources/CombineNavigationExample/TweetsFeedFeature/TweetsFeedFeature.swift +++ b/Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedFeature.swift @@ -1,17 +1,19 @@ -import ComposableArchitecture +import _ComposableArchitecture import Foundation -public struct TweetsFeedFeature: Reducer { +@Reducer +public struct TweetsFeedFeature { public init() {} + @ObservableState public struct State: Equatable { public var list: TweetsListFeature.State - @PresentationState + @Presents public var detail: TweetDetailFeature.State? public init( - list: TweetsListFeature.State = [], + list: TweetsListFeature.State = .init(), detail: TweetDetailFeature.State? = nil ) { self.list = list @@ -19,7 +21,6 @@ public struct TweetsFeedFeature: Reducer { } } - @CasePathable public enum Action: Equatable { case list(TweetsListFeature.Action) case detail(PresentationAction) @@ -35,7 +36,7 @@ public struct TweetsFeedFeature: Reducer { return .send(.openProfile(id)) case let .list(.tweets(.element(itemID, .openDetail))): - state.detail = state.list[id: itemID].flatMap { tweet in + state.detail = state.list.tweets[id: itemID].flatMap { tweet in .collectMock(for: tweet.id) } return .none diff --git a/Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListFeature.swift b/Example/Sources/AppFeature/TweetsListFeature/TweetsListFeature.swift similarity index 64% rename from Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListFeature.swift rename to Example/Sources/AppFeature/TweetsListFeature/TweetsListFeature.swift index 5e09ff3..657f77f 100644 --- a/Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListFeature.swift +++ b/Example/Sources/AppFeature/TweetsListFeature/TweetsListFeature.swift @@ -1,12 +1,19 @@ -import ComposableArchitecture +import _ComposableArchitecture import Foundation -public struct TweetsListFeature: Reducer { +@Reducer +public struct TweetsListFeature { public init() {} - public typealias State = IdentifiedArrayOf + @ObservableState + public struct State: Equatable { + public init(tweets: IdentifiedArrayOf = []) { + self.tweets = tweets + } + + public var tweets: IdentifiedArrayOf + } - @CasePathable public enum Action: Equatable { case tweets(IdentifiedActionOf) case openDetail(for: UUID) @@ -27,7 +34,7 @@ public struct TweetsListFeature: Reducer { } } .forEach( - \State.self, + \State.tweets, action: \.tweets, element: TweetFeature.init ) diff --git a/Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListView.swift b/Example/Sources/AppFeature/TweetsListFeature/TweetsListView.swift similarity index 73% rename from Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListView.swift rename to Example/Sources/AppFeature/TweetsListFeature/TweetsListView.swift index 76f562c..441b30e 100644 --- a/Example/Sources/CombineNavigationExample/TweetsListFeature/TweetsListView.swift +++ b/Example/Sources/AppFeature/TweetsListFeature/TweetsListView.swift @@ -1,7 +1,7 @@ +import _ComposableArchitecture import SwiftUI -import ComposableArchitecture -public struct TweetsListView: View { +public struct TweetsListView: ComposableView { let store: StoreOf public init(_ store: StoreOf) { @@ -13,8 +13,8 @@ public struct TweetsListView: View { LazyVStack(spacing: 24) { ForEachStore( store.scope( - state: { $0 }, - action: { .tweets($0) } + state: \.tweets, + action: \.tweets ), content: TweetView.init ) @@ -26,10 +26,10 @@ public struct TweetsListView: View { #Preview { NavigationStack { TweetsListView(Store( - initialState: [ + initialState: .init(tweets: [ .mock(), .mock() - ], + ]), reducer: TweetsListFeature.init )) .navigationTitle("Preview") diff --git a/Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileFeature.swift b/Example/Sources/AppFeature/UserProfileFeature/UserProfileFeature.swift similarity index 81% rename from Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileFeature.swift rename to Example/Sources/AppFeature/UserProfileFeature/UserProfileFeature.swift index ecf7b1a..8687776 100644 --- a/Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileFeature.swift +++ b/Example/Sources/AppFeature/UserProfileFeature/UserProfileFeature.swift @@ -1,26 +1,27 @@ -import ComposableArchitecture +import _ComposableArchitecture import FoundationExtensions -public struct UserProfileFeature: Reducer { +@Reducer +public struct UserProfileFeature { public init() {} + @ObservableState public struct State: Equatable { public var model: FollowerModel - public var tweets: TweetsListFeature.State + public var tweetsList: TweetsListFeature.State - @PresentationState + @Presents public var avatarPreview: URL? public init( model: FollowerModel, - tweets: TweetsListFeature.State = .init() + tweetsList: TweetsListFeature.State = .init() ) { self.model = model - self.tweets = tweets + self.tweetsList = tweetsList } } - @CasePathable public enum Action: Equatable { case avatarPreview(PresentationAction) case tweetsList(TweetsListFeature.Action) @@ -57,7 +58,7 @@ public struct UserProfileFeature: Reducer { destination: {} ) Scope( - state: \.tweets, + state: \.tweetsList, action: \.tweetsList, child: TweetsListFeature.init ) diff --git a/Example/Sources/AppFeature/UserProfileFeature/UserProfileView.swift b/Example/Sources/AppFeature/UserProfileFeature/UserProfileView.swift new file mode 100644 index 0000000..6766a58 --- /dev/null +++ b/Example/Sources/AppFeature/UserProfileFeature/UserProfileView.swift @@ -0,0 +1,59 @@ +import _ComposableArchitecture +import SwiftUI + +public struct UserProfileView: ComposableView { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView(.vertical) { + headerView + .padding(.vertical, 32) + Divider() + .padding(.bottom, 32) + TweetsListView(store.scope( + state: \.tweetsList, + action: \.tweetsList + )) + } + } + + @ViewBuilder + var headerView: some View { + VStack(spacing: 24) { + Circle() + .fill(Color(.label).opacity(0.3)) + .frame(width: 86, height: 86) + .onTapGesture { + store.send(.tapOnAvatar) + } + Text("@" + store.model.user.username.lowercased()) + .monospaced() + .bold() + Button(action: { store.send(.tapFollow) }) { + Text(store.model.isFollowedByYou ? "Unfollow" : "Follow") + } + } + } +} + +#Preview { + NavigationStack { + UserProfileView(Store( + initialState: .init( + model: .mock(), + tweetsList: .init(tweets: [ + .mock(), + .mock(), + .mock(), + .mock(), + .mock() + ]) + ), + reducer: UserProfileFeature.init + )) + } +} diff --git a/Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsFeature.swift b/Example/Sources/AppFeature/UserSettingsFeature/UserSettingsFeature.swift similarity index 73% rename from Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsFeature.swift rename to Example/Sources/AppFeature/UserSettingsFeature/UserSettingsFeature.swift index dde8123..513c322 100644 --- a/Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsFeature.swift +++ b/Example/Sources/AppFeature/UserSettingsFeature/UserSettingsFeature.swift @@ -1,9 +1,11 @@ -import ComposableArchitecture +import _ComposableArchitecture import Foundation -public struct UserSettingsFeature: Reducer { +@Reducer +public struct UserSettingsFeature { public init() {} + @ObservableState public struct State: Equatable, Identifiable { public var id: UUID @@ -14,7 +16,6 @@ public struct UserSettingsFeature: Reducer { } } - @CasePathable public enum Action: Equatable {} public var body: some ReducerOf { diff --git a/Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsView.swift b/Example/Sources/AppFeature/UserSettingsFeature/UserSettingsView.swift similarity index 64% rename from Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsView.swift rename to Example/Sources/AppFeature/UserSettingsFeature/UserSettingsView.swift index b9671ff..2c68f3e 100644 --- a/Example/Sources/CombineNavigationExample/UserSettingsFeature/UserSettingsView.swift +++ b/Example/Sources/AppFeature/UserSettingsFeature/UserSettingsView.swift @@ -1,7 +1,7 @@ +import _ComposableArchitecture import SwiftUI -import ComposableArchitecture -public struct UserSettingsView: View { +public struct UserSettingsView: ComposableView { let store: StoreOf public init(_ store: StoreOf) { @@ -9,9 +9,7 @@ public struct UserSettingsView: View { } public var body: some View { - WithViewStore(store, observe: \.id) { viewStore in - Text(viewStore.uuidString) - } + Text(store.id.uuidString) } } diff --git a/Example/Sources/CombineNavigationExample/TweetFeature/TweetFeature.swift b/Example/Sources/CombineNavigationExample/TweetFeature/TweetFeature.swift deleted file mode 100644 index 732870a..0000000 --- a/Example/Sources/CombineNavigationExample/TweetFeature/TweetFeature.swift +++ /dev/null @@ -1,97 +0,0 @@ -import ComposableArchitecture -import Foundation - -public struct TweetFeature: Reducer { - public init() {} - - public struct State: Equatable, Identifiable { - public var id: UUID - public var replyTo: UUID? - public var author: UserModel - public var text: String - - public init( - id: UUID, - replyTo: UUID? = nil, - author: UserModel, - text: String - ) { - self.id = id - self.replyTo = replyTo - self.author = author - self.text = text - } - } - - @CasePathable - public enum Action: Equatable { - case tap - case tapOnAuthor - case openDetail(for: UUID) - case openProfile(UUID) - } - - public func reduce( - into state: inout State, - action: Action - ) -> Effect { - switch action { - case .tap: - return .send(.openDetail(for: state.id)) - - case .tapOnAuthor: - return.send(.openProfile(state.author.id)) - - default: - return .none - } - } -} - -extension TweetFeature.State { - public static func mock( - model: TweetModel - ) -> Self { - .mock( - id: model.id, - replyTo: model.replyTo, - author: .mock(id: model.authorID), - text: model.text - ) - } - - public static func mock( - id: UUID = .init(), - replyTo: UUID? = nil, - author: UserModel = .mock(), - text: String = """ - Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ - Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ - In ad laboris labore irure ea ea officia. - """ - ) -> Self { - .init( - id: id, - replyTo: replyTo, - author: author, - text: text - ) - } - - public func mockReply( - id: UUID = .init(), - author: UserModel = .mock(), - text: String = """ - Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ - Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ - In ad laboris labore irure ea ea officia. - """ - ) -> Self { - .init( - id: id, - replyTo: self.id, - author: author, - text: text - ) - } -} diff --git a/Example/Sources/CombineNavigationExample/TweetFeature/TweetView.swift b/Example/Sources/CombineNavigationExample/TweetFeature/TweetView.swift deleted file mode 100644 index 266dcb5..0000000 --- a/Example/Sources/CombineNavigationExample/TweetFeature/TweetView.swift +++ /dev/null @@ -1,46 +0,0 @@ -import SwiftUI -import ComposableArchitecture - -public struct TweetView: View { - let store: StoreOf - - public init(_ store: StoreOf) { - self.store = store - } - - public var body: some View { - WithViewStore(store, observe: { $0 }) { viewStore in - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 16) { - Circle() // Avatar - .fill(Color(.label).opacity(0.3)) - .frame(width: 54, height: 54) - Text("@" + viewStore.author.username.lowercased()).bold() - Spacer() - } - .contentShape(Rectangle()) - .onTapGesture { - viewStore.send(.tapOnAuthor) - } - Text(viewStore.text) - .onTapGesture { - viewStore.send(.tap) - } - } - .padding(.horizontal) - .background( - Color(.systemBackground) - .onTapGesture { - viewStore.send(.tap) - } - ) - } - } -} - -#Preview { - TweetView(Store( - initialState: .mock(), - reducer: TweetFeature.init - )) -} diff --git a/Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileView.swift b/Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileView.swift deleted file mode 100644 index 73ce049..0000000 --- a/Example/Sources/CombineNavigationExample/UserProfileFeature/UserProfileView.swift +++ /dev/null @@ -1,65 +0,0 @@ -import SwiftUI -import ComposableArchitecture - -public struct UserProfileView: View { - let store: StoreOf - - public init(_ store: StoreOf) { - self.store = store - } - - public var body: some View { - ScrollView(.vertical) { - headerView - .padding(.vertical, 32) - Divider() - .padding(.bottom, 32) - TweetsListView(store.scope( - state: \.tweets, - action: { .tweetsList($0) } - )) - } - } - - @ViewBuilder - var headerView: some View { - VStack(spacing: 24) { - WithViewStore(store, observe: \.model.user.avatarURL) { viewStore in - Circle() - .fill(Color(.label).opacity(0.3)) - .frame(width: 86, height: 86) - .onTapGesture { - viewStore.send(.tapOnAvatar) - } - } - WithViewStore(store, observe: \.model.user.username) { viewStore in - Text("@" + viewStore.state.lowercased()) - .monospaced() - .bold() - } - WithViewStore(store, observe: \.model.isFollowedByYou) { viewStore in - Button(action: { viewStore.send(.tapFollow) }) { - Text(viewStore.state ? "Unfollow" : "Follow") - } - } - } - } -} - -#Preview { - NavigationStack { - UserProfileView(Store( - initialState: .init( - model: .mock(), - tweets: [ - .mock(), - .mock(), - .mock(), - .mock(), - .mock() - ] - ), - reducer: UserProfileFeature.init - )) - } -} diff --git a/Example/Sources/CombineNavigationExample/_Core/ComposableViewController.swift b/Example/Sources/CombineNavigationExample/_Core/ComposableViewController.swift deleted file mode 100644 index fa90b53..0000000 --- a/Example/Sources/CombineNavigationExample/_Core/ComposableViewController.swift +++ /dev/null @@ -1,81 +0,0 @@ -import ComposableArchitecture -import UIKit -import Combine -import Capture - -public typealias ComposableViewControllerOf = ComposableViewController< - R.State, - R.Action -> where R.State: Equatable, R.Action: Equatable - -open class ComposableViewController< - State: Equatable, - Action: Equatable ->: UIViewController { - public typealias Store = ComposableArchitecture.Store - public typealias StorePublisher = ComposableArchitecture.StorePublisher - - private var stateCancellables: Set = [] - private var storeCancellable: Cancellable? - private var store: Store? - private var viewStore: ViewStore? - - private func releaseStore() { - stateCancellables = [] - storeCancellable = nil - store = nil - viewStore = nil - scope(nil) - } - - public func setStore(_ store: ComposableArchitecture.Store?) { - guard let store else { return releaseStore() } - storeCancellable = store.ifLet( - then: capture { _self, store in - _self.setStore(store) - }, - else: capture { _self in - _self.releaseStore() - } - ) - } - - public func setStore(_ store: Store?) { - guard let store else { return releaseStore() } - self.store = store - - let viewStore = ViewStore(store, observe: { $0 }) - self.viewStore = viewStore - - self.scope(store) - self.bind(viewStore.publisher, into: &stateCancellables) - } - - open func scope(_ store: Store?) {} - - open func bind( - _ publisher: StorePublisher, - into cancellables: inout Set - ) {} - - public func withViewStore(_ action: (ViewStore) -> Void) { - viewStore.map(action) - } - - public func send(_ action: Action) { - viewStore?.send(action) - } - - public func sendPop( - _ ids: [StackElementID], - from actionPath: CaseKeyPath> - ) { - ids.first.map { id in - send(actionPath.callAsFunction(.popFrom(id: id))) - } - } - - public func captureSend(_ action: Action) -> () -> Void { - return capture { _self in _self.send(action) } - } -} diff --git a/Example/Sources/_Dependencies/_ComposableArchitecture/Exports.swift b/Example/Sources/_Dependencies/_ComposableArchitecture/Exports.swift new file mode 100644 index 0000000..e73d35f --- /dev/null +++ b/Example/Sources/_Dependencies/_ComposableArchitecture/Exports.swift @@ -0,0 +1,12 @@ +@_exported import ComposableExtensions + +extension ComposableViewController { + public func sendPop( + _ ids: [StackElementID], + from actionPath: CaseKeyPath> + ) { + ids.first.map { id in + _ = store?.send(actionPath.callAsFunction(.popFrom(id: id))) + } + } +} diff --git a/Example/Sources/_Extensions/LocalExtensions/Exports.swift b/Example/Sources/_Extensions/LocalExtensions/Exports.swift new file mode 100644 index 0000000..adcc18e --- /dev/null +++ b/Example/Sources/_Extensions/LocalExtensions/Exports.swift @@ -0,0 +1 @@ +@_exported import FoundationExtensions From 99565b20fa36faeec3dd59a755ad967b6ee7defb Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 23 Dec 2023 03:34:27 +0100 Subject: [PATCH 18/43] feat(Example): Modularization --- Example/Example.xcodeproj/project.pbxproj | 28 +-- Example/Example/Info.plist | 2 +- Example/Example/SceneDelegate.swift | 79 --------- Example/Example/main.swift | 12 ++ Example/Package.swift | 164 +++++++++++++++++- .../AppFeature/Bootstrap}/AppDelegate.swift | 18 +- .../AppFeature/Bootstrap/SceneDelegate.swift | 57 ++++++ .../Model => AppModels}/FollowerModel.swift | 2 +- Example/Sources/AppModels/TweetModel.swift | 51 ++++++ .../Model => AppModels}/UserModel.swift | 2 +- .../_Mock/MockThreads.swift | 3 +- Example/Sources/AppUI/Exports.swift | 1 + .../CurrentUserProfileFeature.swift | 5 +- .../CurrentUserProfileView.swift | 3 + .../FeedAndProfileViewController.swift | 0 .../FeedTabFeature/FeedTabController.swift | 7 +- .../FeedTabFeature/FeedTabFeature.swift | 2 + .../MainFeature/MainFeature.swift | 0 .../ProfileFeature/ProfileFeature.swift | 2 + .../ProfileFeature/ProfileView.swift | 3 + .../ProfileFeedFeature.swift | 4 +- .../ProfileFeedFeature/ProfileFeedView.swift | 2 + .../ProfileTabFeature/ProfileTabFeature.swift | 0 .../TweetDetailController.swift | 0 .../TweetDetailFeature.swift | 3 + .../TweetDetailFeature/TweetDetailView.swift | 3 + .../TweetFeature/TweetFeature.swift | 1 + .../TweetFeature/TweetView.swift | 0 .../TweetsFeedController.swift | 7 +- .../TweetsFeedFeature/TweetsFeedFeature.swift | 2 + .../TweetsListFeature/TweetsListFeature.swift | 1 + .../TweetsListFeature/TweetsListView.swift | 1 + .../UserProfileFeature.swift | 4 +- .../UserProfileFeature/UserProfileView.swift | 2 + .../UserSettingsFeature.swift | 0 .../UserSettingsView.swift | 0 .../LocalExtensions/ArrayBuilder.swift} | 52 ------ .../_Extensions/LocalExtensions/Exports.swift | 1 + 38 files changed, 351 insertions(+), 173 deletions(-) delete mode 100644 Example/Example/SceneDelegate.swift create mode 100644 Example/Example/main.swift rename Example/{Example => Sources/AppFeature/Bootstrap}/AppDelegate.swift (61%) create mode 100644 Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift rename Example/Sources/{AppFeature/Model => AppModels}/FollowerModel.swift (96%) create mode 100644 Example/Sources/AppModels/TweetModel.swift rename Example/Sources/{AppFeature/Model => AppModels}/UserModel.swift (97%) rename Example/Sources/{AppFeature/Model => AppModels}/_Mock/MockThreads.swift (99%) create mode 100644 Example/Sources/AppUI/Exports.swift rename Example/Sources/{AppFeature => }/CurrentUserProfileFeature/CurrentUserProfileFeature.swift (95%) rename Example/Sources/{AppFeature => }/CurrentUserProfileFeature/CurrentUserProfileView.swift (95%) rename Example/Sources/{AppFeature/FeedAndProfileView => FeedAndProfileFeature}/FeedAndProfileViewController.swift (100%) rename Example/Sources/{AppFeature => }/FeedTabFeature/FeedTabController.swift (95%) rename Example/Sources/{AppFeature => }/FeedTabFeature/FeedTabFeature.swift (97%) rename Example/Sources/{AppFeature => }/MainFeature/MainFeature.swift (100%) rename Example/Sources/{AppFeature => }/ProfileFeature/ProfileFeature.swift (91%) rename Example/Sources/{AppFeature => }/ProfileFeature/ProfileView.swift (91%) rename Example/Sources/{AppFeature => }/ProfileFeedFeature/ProfileFeedFeature.swift (92%) rename Example/Sources/{AppFeature => }/ProfileFeedFeature/ProfileFeedView.swift (94%) rename Example/Sources/{AppFeature => }/ProfileTabFeature/ProfileTabFeature.swift (100%) rename Example/Sources/{AppFeature => }/TweetDetailFeature/TweetDetailController.swift (100%) rename Example/Sources/{AppFeature => }/TweetDetailFeature/TweetDetailFeature.swift (96%) rename Example/Sources/{AppFeature => }/TweetDetailFeature/TweetDetailView.swift (93%) rename Example/Sources/{AppFeature => }/TweetFeature/TweetFeature.swift (99%) rename Example/Sources/{AppFeature => }/TweetFeature/TweetView.swift (100%) rename Example/Sources/{AppFeature => }/TweetsFeedFeature/TweetsFeedController.swift (93%) rename Example/Sources/{AppFeature => }/TweetsFeedFeature/TweetsFeedFeature.swift (96%) rename Example/Sources/{AppFeature => }/TweetsListFeature/TweetsListFeature.swift (97%) rename Example/Sources/{AppFeature => }/TweetsListFeature/TweetsListView.swift (96%) rename Example/Sources/{AppFeature => }/UserProfileFeature/UserProfileFeature.swift (95%) rename Example/Sources/{AppFeature => }/UserProfileFeature/UserProfileView.swift (96%) rename Example/Sources/{AppFeature => }/UserSettingsFeature/UserSettingsFeature.swift (100%) rename Example/Sources/{AppFeature => }/UserSettingsFeature/UserSettingsView.swift (100%) rename Example/Sources/{AppFeature/Model/TweetModel.swift => _Extensions/LocalExtensions/ArrayBuilder.swift} (61%) diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 02259df..f22c563 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -8,16 +8,14 @@ /* Begin PBXBuildFile section */ E11363F22B32984100915F38 /* AppFeature in Frameworks */ = {isa = PBXBuildFile; productRef = E11363F12B32984100915F38 /* AppFeature */; }; - E1A59CDA2AFFF5D400E08FF8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A59CD92AFFF5D400E08FF8 /* AppDelegate.swift */; }; - E1A59CDC2AFFF5D400E08FF8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A59CDB2AFFF5D400E08FF8 /* SceneDelegate.swift */; }; + E1A4489F2B367B89008C6875 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A4489E2B367B89008C6875 /* main.swift */; }; E1A59CE32AFFF5D600E08FF8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E1A59CE22AFFF5D600E08FF8 /* Assets.xcassets */; }; E1A59CE62AFFF5D600E08FF8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E1A59CE42AFFF5D600E08FF8 /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + E1A4489E2B367B89008C6875 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; E1A59CD62AFFF5D400E08FF8 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; - E1A59CD92AFFF5D400E08FF8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - E1A59CDB2AFFF5D400E08FF8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; E1A59CE22AFFF5D600E08FF8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E1A59CE52AFFF5D600E08FF8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; E1A59CE72AFFF5D600E08FF8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -62,11 +60,10 @@ E1A59CD82AFFF5D400E08FF8 /* Example */ = { isa = PBXGroup; children = ( - E1A59CD92AFFF5D400E08FF8 /* AppDelegate.swift */, - E1A59CDB2AFFF5D400E08FF8 /* SceneDelegate.swift */, E1A59CE22AFFF5D600E08FF8 /* Assets.xcassets */, E1A59CE42AFFF5D600E08FF8 /* LaunchScreen.storyboard */, E1A59CE72AFFF5D600E08FF8 /* Info.plist */, + E1A4489E2B367B89008C6875 /* main.swift */, ); path = Example; sourceTree = ""; @@ -106,6 +103,7 @@ TargetAttributes = { E1A59CD52AFFF5D400E08FF8 = { CreatedOnToolsVersion = 15.0; + LastSwiftMigration = 1500; }; }; }; @@ -146,8 +144,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - E1A59CDA2AFFF5D400E08FF8 /* AppDelegate.swift in Sources */, - E1A59CDC2AFFF5D400E08FF8 /* SceneDelegate.swift in Sources */, + E1A4489F2B367B89008C6875 /* main.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -289,14 +286,15 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Example/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -305,6 +303,9 @@ PRODUCT_BUNDLE_IDENTIFIER = dev.capturecontext.Example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_PRECOMPILE_BRIDGING_HEADER = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -315,14 +316,15 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Example/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -331,6 +333,8 @@ PRODUCT_BUNDLE_IDENTIFIER = dev.capturecontext.Example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ""; + SWIFT_PRECOMPILE_BRIDGING_HEADER = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/Example/Example/Info.plist b/Example/Example/Info.plist index 0eb786d..a0f00f7 100644 --- a/Example/Example/Info.plist +++ b/Example/Example/Info.plist @@ -14,7 +14,7 @@ UISceneConfigurationName Default Configuration UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate + AppFeature.SceneDelegate diff --git a/Example/Example/SceneDelegate.swift b/Example/Example/SceneDelegate.swift deleted file mode 100644 index 9f0b5cc..0000000 --- a/Example/Example/SceneDelegate.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// SceneDelegate.swift -// Example -// -// Created by Maxim Krouk on 11.11.2023. -// - -import _ComposableArchitecture -import UIKit -import CombineNavigation -import AppFeature - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? - let store = Store( - initialState: FeedTabFeature.State( - feed: .init( - list: .init( - tweets: .init( - uncheckedUniqueElements: TweetModel - .mockTweets.filter(\.replyTo.isNil) - .map { .mock(model: $0) } - ) - ) - ) - ), - reducer: { - FeedTabFeature()._printChanges() - } - ) - - func scene( - _ scene: UIScene, - willConnectTo session: UISceneSession, - options connectionOptions: UIScene.ConnectionOptions - ) { - guard let scene = scene as? UIWindowScene else { return } - let controller = FeedTabController() - controller.setStore(store) - - let window = UIWindow(windowScene: scene) - self.window = window - - window.rootViewController = UINavigationController( - rootViewController: controller - ) - - window.makeKeyAndVisible() - } - - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } - - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } -} - diff --git a/Example/Example/main.swift b/Example/Example/main.swift new file mode 100644 index 0000000..13debb2 --- /dev/null +++ b/Example/Example/main.swift @@ -0,0 +1,12 @@ +import AppFeature +import AppUI + +let delegate = AppDelegate() +UIApplication.shared.delegate = delegate + +_ = UIApplicationMain( + CommandLine.argc, + CommandLine.unsafeArgv, + nil, + nil +) diff --git a/Example/Package.swift b/Example/Package.swift index 2d2f9d4..c1495ed 100644 --- a/Example/Package.swift +++ b/Example/Package.swift @@ -16,6 +16,10 @@ let package = Package( url: "https://github.com/capturecontext/swift-foundation-extensions.git", .upToNextMinor(from: "0.4.0") ), + .package( + url: "https://github.com/pointfreeco/swift-identified-collections.git", + .upToNextMajor(from: "1.0.0") + ), ], producibleTargets: [ // MARK: - Utils @@ -32,6 +36,10 @@ let package = Package( .product( name: "FoundationExtensions", package: "swift-foundation-extensions" + ), + .product( + name: "IdentifiedCollections", + package: "swift-identified-collections" ) ], path: ._extensions("LocalExtensions") @@ -66,10 +74,164 @@ let package = Package( name: "AppFeature", product: .library(.static), dependencies: [ + .target("AppUI"), + .target("FeedTabFeature"), + .dependency("_ComposableArchitecture"), .localExtensions, + ] + ), + + .target( + name: "AppModels", + product: .library(.static), + dependencies: [ + .localExtensions + ] + ), + + .target( + name: "AppUI", + product: .library(.static), + dependencies: [ + .localExtensions, + ] + ), + + .target( + name: "CurrentUserProfileFeature", + product: .library(.static), + dependencies: [ + .target("AppModels"), + .target("TweetsListFeature"), + .target("UserSettingsFeature"), .dependency("_ComposableArchitecture"), + .localExtensions, ] - ) + ), + + .target( + name: "FeedAndProfileFeature", + product: .library(.static), + dependencies: [ + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "FeedTabFeature", + product: .library(.static), + dependencies: [ + .target("ProfileFeature"), + .target("TweetsFeedFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "MainFeature", + product: .library(.static), + dependencies: [ + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "ProfileFeature", + product: .library(.static), + dependencies: [ + .target("UserProfileFeature"), + .target("CurrentUserProfileFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "ProfileFeedFeature", + product: .library(.static), + dependencies: [ + .target("TweetFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "ProfileTabFeature", + product: .library(.static), + dependencies: [ + .target("AppModels"), + .target("TweetsFeedFeature"), + .target("UserProfileFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "TweetDetailFeature", + product: .library(.static), + dependencies: [ + .target("TweetFeature"), + .target("TweetsListFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "TweetFeature", + product: .library(.static), + dependencies: [ + .target("AppModels"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "TweetsFeedFeature", + product: .library(.static), + dependencies: [ + .target("TweetsListFeature"), + .target("TweetDetailFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "TweetsListFeature", + product: .library(.static), + dependencies: [ + .target("TweetFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "UserProfileFeature", + product: .library(.static), + dependencies: [ + .target("AppModels"), + .target("TweetsListFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "UserSettingsFeature", + product: .library(.static), + dependencies: [ + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), ] ) diff --git a/Example/Example/AppDelegate.swift b/Example/Sources/AppFeature/Bootstrap/AppDelegate.swift similarity index 61% rename from Example/Example/AppDelegate.swift rename to Example/Sources/AppFeature/Bootstrap/AppDelegate.swift index 83984ac..6ec980f 100644 --- a/Example/Example/AppDelegate.swift +++ b/Example/Sources/AppFeature/Bootstrap/AppDelegate.swift @@ -8,9 +8,8 @@ import UIKit import CombineNavigation -@main -class AppDelegate: UIResponder, UIApplicationDelegate { - func application( +public class AppDelegate: UIResponder, UIApplicationDelegate { + public func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { @@ -20,7 +19,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: UISceneSession Lifecycle - func application( + public func application( _ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions @@ -31,15 +30,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) } - func application( + public func application( _ application: UIApplication, didDiscardSceneSessions sceneSessions: Set - ) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, - // this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, - // as they will not return. - } + ) {} } - diff --git a/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift new file mode 100644 index 0000000..3734b8d --- /dev/null +++ b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift @@ -0,0 +1,57 @@ +// +// SceneDelegate.swift +// Example +// +// Created by Maxim Krouk on 11.11.2023. +// + +import _ComposableArchitecture +import AppUI +import AppModels +import FeedTabFeature + +public class SceneDelegate: UIResponder, UIWindowSceneDelegate { + public var window: UIWindow? + let store = Store( + initialState: FeedTabFeature.State( + feed: .init( + list: .init( + tweets: .init( + uncheckedUniqueElements: TweetModel + .mockTweets.filter(\.replyTo.isNil) + .map { .mock(model: $0) } + ) + ) + ) + ), + reducer: { + FeedTabFeature()._printChanges() + } + ) + + public func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let scene = scene as? UIWindowScene else { return } + let controller = FeedTabController() + controller.setStore(store) + + let window = UIWindow(windowScene: scene) + self.window = window + + window.rootViewController = UINavigationController( + rootViewController: controller + ) + + window.makeKeyAndVisible() + } + + public func sceneDidDisconnect(_ scene: UIScene) {} + public func sceneDidBecomeActive(_ scene: UIScene) {} + public func sceneWillResignActive(_ scene: UIScene) {} + public func sceneWillEnterForeground(_ scene: UIScene) {} + public func sceneDidEnterBackground(_ scene: UIScene) {} +} + diff --git a/Example/Sources/AppFeature/Model/FollowerModel.swift b/Example/Sources/AppModels/FollowerModel.swift similarity index 96% rename from Example/Sources/AppFeature/Model/FollowerModel.swift rename to Example/Sources/AppModels/FollowerModel.swift index 377bbf0..f17d5c5 100644 --- a/Example/Sources/AppFeature/Model/FollowerModel.swift +++ b/Example/Sources/AppModels/FollowerModel.swift @@ -1,4 +1,4 @@ -import Foundation +import LocalExtensions public struct FollowerModel: Equatable, Identifiable { public var id: UUID { user.id } diff --git a/Example/Sources/AppModels/TweetModel.swift b/Example/Sources/AppModels/TweetModel.swift new file mode 100644 index 0000000..78e3c50 --- /dev/null +++ b/Example/Sources/AppModels/TweetModel.swift @@ -0,0 +1,51 @@ +import LocalExtensions + +public struct TweetModel: Equatable, Identifiable, Codable { + public var id: UUID + public var authorID: UUID + public var replyTo: UUID? + public var text: String + + public init( + id: UUID, + authorID: UUID, + replyTo: UUID? = nil, + text: String + ) { + self.id = id + self.authorID = authorID + self.replyTo = replyTo + self.text = text + } +} + +extension TweetModel { + public static func mock( + id: UUID = .init(), + authorID: UUID = UserModel.mock().id, + replyTo: UUID? = nil, + text: String = """ + Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ + Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ + In ad laboris labore irure ea ea officia. + """ + ) -> TweetModel { + .init( + id: id, + authorID: authorID, + replyTo: replyTo, + text: text + ) + } + + public func withReplies( + @TweetModelsBuilder replies: (TweetModel) -> [TweetModel] + ) -> [TweetModel] { + [self] + replies(self) + } +} + +@resultBuilder +public struct TweetModelsBuilder: ArrayBuilderProtocol { + public typealias Element = TweetModel +} diff --git a/Example/Sources/AppFeature/Model/UserModel.swift b/Example/Sources/AppModels/UserModel.swift similarity index 97% rename from Example/Sources/AppFeature/Model/UserModel.swift rename to Example/Sources/AppModels/UserModel.swift index d36bbd6..ccb7315 100644 --- a/Example/Sources/AppFeature/Model/UserModel.swift +++ b/Example/Sources/AppModels/UserModel.swift @@ -1,4 +1,4 @@ -import Foundation +import LocalExtensions public struct UserModel: Equatable, Identifiable { public var id: UUID diff --git a/Example/Sources/AppFeature/Model/_Mock/MockThreads.swift b/Example/Sources/AppModels/_Mock/MockThreads.swift similarity index 99% rename from Example/Sources/AppFeature/Model/_Mock/MockThreads.swift rename to Example/Sources/AppModels/_Mock/MockThreads.swift index 938bff4..1fa8881 100644 --- a/Example/Sources/AppFeature/Model/_Mock/MockThreads.swift +++ b/Example/Sources/AppModels/_Mock/MockThreads.swift @@ -1,5 +1,4 @@ -import _ComposableArchitecture -import FoundationExtensions +import LocalExtensions extension TweetModel { public static func mockReplies( diff --git a/Example/Sources/AppUI/Exports.swift b/Example/Sources/AppUI/Exports.swift new file mode 100644 index 0000000..b406d35 --- /dev/null +++ b/Example/Sources/AppUI/Exports.swift @@ -0,0 +1 @@ +@_exported import SwiftUI diff --git a/Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileFeature.swift b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift similarity index 95% rename from Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileFeature.swift rename to Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift index 4690b9a..c495bfc 100644 --- a/Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileFeature.swift +++ b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift @@ -1,5 +1,8 @@ import _ComposableArchitecture -import FoundationExtensions +import Foundation +import AppModels +import TweetsListFeature +import UserSettingsFeature @Reducer public struct CurrentUserProfileFeature { diff --git a/Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileView.swift b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileView.swift similarity index 95% rename from Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileView.swift rename to Example/Sources/CurrentUserProfileFeature/CurrentUserProfileView.swift index fb2a8bd..7dbc804 100644 --- a/Example/Sources/AppFeature/CurrentUserProfileFeature/CurrentUserProfileView.swift +++ b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileView.swift @@ -1,5 +1,8 @@ import _ComposableArchitecture import SwiftUI +import AppModels +import TweetsListFeature +import UserSettingsFeature public struct CurrentUserProfileView: ComposableView { let store: StoreOf diff --git a/Example/Sources/AppFeature/FeedAndProfileView/FeedAndProfileViewController.swift b/Example/Sources/FeedAndProfileFeature/FeedAndProfileViewController.swift similarity index 100% rename from Example/Sources/AppFeature/FeedAndProfileView/FeedAndProfileViewController.swift rename to Example/Sources/FeedAndProfileFeature/FeedAndProfileViewController.swift diff --git a/Example/Sources/AppFeature/FeedTabFeature/FeedTabController.swift b/Example/Sources/FeedTabFeature/FeedTabController.swift similarity index 95% rename from Example/Sources/AppFeature/FeedTabFeature/FeedTabController.swift rename to Example/Sources/FeedTabFeature/FeedTabController.swift index 9b05c80..66e1d95 100644 --- a/Example/Sources/AppFeature/FeedTabFeature/FeedTabController.swift +++ b/Example/Sources/FeedTabFeature/FeedTabController.swift @@ -1,10 +1,9 @@ import _ComposableArchitecture -import UIKit -import SwiftUI -import Combine +import CocoaExtensions import CombineExtensions -import Capture import CombineNavigation +import ProfileFeature +import TweetsFeedFeature @RoutingController public final class FeedTabController: ComposableViewController< diff --git a/Example/Sources/AppFeature/FeedTabFeature/FeedTabFeature.swift b/Example/Sources/FeedTabFeature/FeedTabFeature.swift similarity index 97% rename from Example/Sources/AppFeature/FeedTabFeature/FeedTabFeature.swift rename to Example/Sources/FeedTabFeature/FeedTabFeature.swift index 86f168f..83a1206 100644 --- a/Example/Sources/AppFeature/FeedTabFeature/FeedTabFeature.swift +++ b/Example/Sources/FeedTabFeature/FeedTabFeature.swift @@ -1,4 +1,6 @@ import _ComposableArchitecture +import ProfileFeature +import TweetsFeedFeature @Reducer public struct FeedTabFeature { diff --git a/Example/Sources/AppFeature/MainFeature/MainFeature.swift b/Example/Sources/MainFeature/MainFeature.swift similarity index 100% rename from Example/Sources/AppFeature/MainFeature/MainFeature.swift rename to Example/Sources/MainFeature/MainFeature.swift diff --git a/Example/Sources/AppFeature/ProfileFeature/ProfileFeature.swift b/Example/Sources/ProfileFeature/ProfileFeature.swift similarity index 91% rename from Example/Sources/AppFeature/ProfileFeature/ProfileFeature.swift rename to Example/Sources/ProfileFeature/ProfileFeature.swift index 81bf714..df39451 100644 --- a/Example/Sources/AppFeature/ProfileFeature/ProfileFeature.swift +++ b/Example/Sources/ProfileFeature/ProfileFeature.swift @@ -1,4 +1,6 @@ import _ComposableArchitecture +import UserProfileFeature +import CurrentUserProfileFeature @Reducer public struct ProfileFeature { diff --git a/Example/Sources/AppFeature/ProfileFeature/ProfileView.swift b/Example/Sources/ProfileFeature/ProfileView.swift similarity index 91% rename from Example/Sources/AppFeature/ProfileFeature/ProfileView.swift rename to Example/Sources/ProfileFeature/ProfileView.swift index a7365d7..0331a77 100644 --- a/Example/Sources/AppFeature/ProfileFeature/ProfileView.swift +++ b/Example/Sources/ProfileFeature/ProfileView.swift @@ -1,5 +1,8 @@ import _ComposableArchitecture import SwiftUI +import AppModels +import UserProfileFeature +import CurrentUserProfileFeature public struct ProfileView: ComposableView { let store: StoreOf diff --git a/Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedFeature.swift b/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift similarity index 92% rename from Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedFeature.swift rename to Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift index 0919655..18405f0 100644 --- a/Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedFeature.swift +++ b/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift @@ -1,5 +1,7 @@ import _ComposableArchitecture -import Foundation +import LocalExtensions +import AppModels +import TweetFeature @Reducer public struct ProfileFeedFeature { diff --git a/Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedView.swift b/Example/Sources/ProfileFeedFeature/ProfileFeedView.swift similarity index 94% rename from Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedView.swift rename to Example/Sources/ProfileFeedFeature/ProfileFeedView.swift index d56a09a..3ca2eb5 100644 --- a/Example/Sources/AppFeature/ProfileFeedFeature/ProfileFeedView.swift +++ b/Example/Sources/ProfileFeedFeature/ProfileFeedView.swift @@ -1,5 +1,7 @@ import _ComposableArchitecture import SwiftUI +import AppModels +import TweetFeature public struct ProfileFeedView: ComposableView { let store: StoreOf diff --git a/Example/Sources/AppFeature/ProfileTabFeature/ProfileTabFeature.swift b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift similarity index 100% rename from Example/Sources/AppFeature/ProfileTabFeature/ProfileTabFeature.swift rename to Example/Sources/ProfileTabFeature/ProfileTabFeature.swift diff --git a/Example/Sources/AppFeature/TweetDetailFeature/TweetDetailController.swift b/Example/Sources/TweetDetailFeature/TweetDetailController.swift similarity index 100% rename from Example/Sources/AppFeature/TweetDetailFeature/TweetDetailController.swift rename to Example/Sources/TweetDetailFeature/TweetDetailController.swift diff --git a/Example/Sources/AppFeature/TweetDetailFeature/TweetDetailFeature.swift b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift similarity index 96% rename from Example/Sources/AppFeature/TweetDetailFeature/TweetDetailFeature.swift rename to Example/Sources/TweetDetailFeature/TweetDetailFeature.swift index 2813947..5eb09a0 100644 --- a/Example/Sources/AppFeature/TweetDetailFeature/TweetDetailFeature.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift @@ -1,5 +1,8 @@ import _ComposableArchitecture import Foundation +import AppModels +import TweetFeature +import TweetsListFeature @Reducer public struct TweetDetailFeature { diff --git a/Example/Sources/AppFeature/TweetDetailFeature/TweetDetailView.swift b/Example/Sources/TweetDetailFeature/TweetDetailView.swift similarity index 93% rename from Example/Sources/AppFeature/TweetDetailFeature/TweetDetailView.swift rename to Example/Sources/TweetDetailFeature/TweetDetailView.swift index bf2ed37..7683017 100644 --- a/Example/Sources/AppFeature/TweetDetailFeature/TweetDetailView.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailView.swift @@ -1,5 +1,8 @@ import _ComposableArchitecture import SwiftUI +import AppModels +import TweetFeature +import TweetsListFeature public struct TweetDetailView: ComposableView { let store: StoreOf diff --git a/Example/Sources/AppFeature/TweetFeature/TweetFeature.swift b/Example/Sources/TweetFeature/TweetFeature.swift similarity index 99% rename from Example/Sources/AppFeature/TweetFeature/TweetFeature.swift rename to Example/Sources/TweetFeature/TweetFeature.swift index 19d27b3..2e410e2 100644 --- a/Example/Sources/AppFeature/TweetFeature/TweetFeature.swift +++ b/Example/Sources/TweetFeature/TweetFeature.swift @@ -1,5 +1,6 @@ import _ComposableArchitecture import Foundation +import AppModels @Reducer public struct TweetFeature { diff --git a/Example/Sources/AppFeature/TweetFeature/TweetView.swift b/Example/Sources/TweetFeature/TweetView.swift similarity index 100% rename from Example/Sources/AppFeature/TweetFeature/TweetView.swift rename to Example/Sources/TweetFeature/TweetView.swift diff --git a/Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedController.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift similarity index 93% rename from Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedController.swift rename to Example/Sources/TweetsFeedFeature/TweetsFeedController.swift index 2fd0d85..5901e7b 100644 --- a/Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedController.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift @@ -1,10 +1,9 @@ import _ComposableArchitecture -import UIKit -import SwiftUI -import Combine +import CocoaExtensions import CombineExtensions -import Capture import CombineNavigation +import TweetsListFeature +import TweetDetailFeature @RoutingController public final class TweetsFeedController: ComposableViewController< diff --git a/Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedFeature.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift similarity index 96% rename from Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedFeature.swift rename to Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift index ce861ca..48c5428 100644 --- a/Example/Sources/AppFeature/TweetsFeedFeature/TweetsFeedFeature.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift @@ -1,5 +1,7 @@ import _ComposableArchitecture import Foundation +import TweetsListFeature +import TweetDetailFeature @Reducer public struct TweetsFeedFeature { diff --git a/Example/Sources/AppFeature/TweetsListFeature/TweetsListFeature.swift b/Example/Sources/TweetsListFeature/TweetsListFeature.swift similarity index 97% rename from Example/Sources/AppFeature/TweetsListFeature/TweetsListFeature.swift rename to Example/Sources/TweetsListFeature/TweetsListFeature.swift index 657f77f..b67cf2e 100644 --- a/Example/Sources/AppFeature/TweetsListFeature/TweetsListFeature.swift +++ b/Example/Sources/TweetsListFeature/TweetsListFeature.swift @@ -1,5 +1,6 @@ import _ComposableArchitecture import Foundation +import TweetFeature @Reducer public struct TweetsListFeature { diff --git a/Example/Sources/AppFeature/TweetsListFeature/TweetsListView.swift b/Example/Sources/TweetsListFeature/TweetsListView.swift similarity index 96% rename from Example/Sources/AppFeature/TweetsListFeature/TweetsListView.swift rename to Example/Sources/TweetsListFeature/TweetsListView.swift index 441b30e..b4faacf 100644 --- a/Example/Sources/AppFeature/TweetsListFeature/TweetsListView.swift +++ b/Example/Sources/TweetsListFeature/TweetsListView.swift @@ -1,5 +1,6 @@ import _ComposableArchitecture import SwiftUI +import TweetFeature public struct TweetsListView: ComposableView { let store: StoreOf diff --git a/Example/Sources/AppFeature/UserProfileFeature/UserProfileFeature.swift b/Example/Sources/UserProfileFeature/UserProfileFeature.swift similarity index 95% rename from Example/Sources/AppFeature/UserProfileFeature/UserProfileFeature.swift rename to Example/Sources/UserProfileFeature/UserProfileFeature.swift index 8687776..20fa009 100644 --- a/Example/Sources/AppFeature/UserProfileFeature/UserProfileFeature.swift +++ b/Example/Sources/UserProfileFeature/UserProfileFeature.swift @@ -1,5 +1,7 @@ import _ComposableArchitecture -import FoundationExtensions +import Foundation +import AppModels +import TweetsListFeature @Reducer public struct UserProfileFeature { diff --git a/Example/Sources/AppFeature/UserProfileFeature/UserProfileView.swift b/Example/Sources/UserProfileFeature/UserProfileView.swift similarity index 96% rename from Example/Sources/AppFeature/UserProfileFeature/UserProfileView.swift rename to Example/Sources/UserProfileFeature/UserProfileView.swift index 6766a58..4ee599b 100644 --- a/Example/Sources/AppFeature/UserProfileFeature/UserProfileView.swift +++ b/Example/Sources/UserProfileFeature/UserProfileView.swift @@ -1,5 +1,7 @@ import _ComposableArchitecture import SwiftUI +import AppModels +import TweetsListFeature public struct UserProfileView: ComposableView { let store: StoreOf diff --git a/Example/Sources/AppFeature/UserSettingsFeature/UserSettingsFeature.swift b/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift similarity index 100% rename from Example/Sources/AppFeature/UserSettingsFeature/UserSettingsFeature.swift rename to Example/Sources/UserSettingsFeature/UserSettingsFeature.swift diff --git a/Example/Sources/AppFeature/UserSettingsFeature/UserSettingsView.swift b/Example/Sources/UserSettingsFeature/UserSettingsView.swift similarity index 100% rename from Example/Sources/AppFeature/UserSettingsFeature/UserSettingsView.swift rename to Example/Sources/UserSettingsFeature/UserSettingsView.swift diff --git a/Example/Sources/AppFeature/Model/TweetModel.swift b/Example/Sources/_Extensions/LocalExtensions/ArrayBuilder.swift similarity index 61% rename from Example/Sources/AppFeature/Model/TweetModel.swift rename to Example/Sources/_Extensions/LocalExtensions/ArrayBuilder.swift index a1fca47..d86a0c9 100644 --- a/Example/Sources/AppFeature/Model/TweetModel.swift +++ b/Example/Sources/_Extensions/LocalExtensions/ArrayBuilder.swift @@ -1,55 +1,3 @@ -import Foundation - -public struct TweetModel: Equatable, Identifiable, Codable { - public var id: UUID - public var authorID: UUID - public var replyTo: UUID? - public var text: String - - public init( - id: UUID, - authorID: UUID, - replyTo: UUID? = nil, - text: String - ) { - self.id = id - self.authorID = authorID - self.replyTo = replyTo - self.text = text - } -} - -extension TweetModel { - public static func mock( - id: UUID = .init(), - authorID: UUID = UserModel.mock().id, - replyTo: UUID? = nil, - text: String = """ - Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ - Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ - In ad laboris labore irure ea ea officia. - """ - ) -> TweetModel { - .init( - id: id, - authorID: authorID, - replyTo: replyTo, - text: text - ) - } - - public func withReplies( - @TweetModelsBuilder replies: (TweetModel) -> [TweetModel] - ) -> [TweetModel] { - [self] + replies(self) - } -} - -@resultBuilder -public struct TweetModelsBuilder: ArrayBuilderProtocol { - public typealias Element = TweetModel -} - public protocol ArrayBuilderProtocol { associatedtype Element diff --git a/Example/Sources/_Extensions/LocalExtensions/Exports.swift b/Example/Sources/_Extensions/LocalExtensions/Exports.swift index adcc18e..2095f85 100644 --- a/Example/Sources/_Extensions/LocalExtensions/Exports.swift +++ b/Example/Sources/_Extensions/LocalExtensions/Exports.swift @@ -1 +1,2 @@ @_exported import FoundationExtensions +@_exported import IdentifiedCollections From 5c01ec3e52c078b7f7de04228206f354eaf548c6 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 23 Dec 2023 05:35:33 +0100 Subject: [PATCH 19/43] fix: App build and launch --- Example/Example/Info.plist | 20 ++++ Example/Example/main.swift | 8 +- Example/Package.swift | 4 +- .../AppFeature/Bootstrap/SceneDelegate.swift | 26 +++-- .../FeedTabFeature/FeedTabController.swift | 5 +- Example/Sources/MainFeature/MainFeature.swift | 59 +++++++++-- .../MainFeature/MainViewController.swift | 99 +++++++++++++++++++ .../ProfileTabFeature/ProfileTabFeature.swift | 2 + .../TweetDetailController.swift | 5 +- .../TweetsFeedController.swift | 5 +- .../_ComposableArchitecture/Exports.swift | 11 --- 11 files changed, 196 insertions(+), 48 deletions(-) create mode 100644 Example/Sources/MainFeature/MainViewController.swift diff --git a/Example/Example/Info.plist b/Example/Example/Info.plist index a0f00f7..5a14045 100644 --- a/Example/Example/Info.plist +++ b/Example/Example/Info.plist @@ -2,6 +2,24 @@ + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -19,5 +37,7 @@ + UILaunchStoryboardName + LaunchScreen diff --git a/Example/Example/main.swift b/Example/Example/main.swift index 13debb2..673d052 100644 --- a/Example/Example/main.swift +++ b/Example/Example/main.swift @@ -1,12 +1,8 @@ -import AppFeature -import AppUI - -let delegate = AppDelegate() -UIApplication.shared.delegate = delegate +import UIKit _ = UIApplicationMain( CommandLine.argc, CommandLine.unsafeArgv, nil, - nil + "AppFeature.AppDelegate" ) diff --git a/Example/Package.swift b/Example/Package.swift index c1495ed..310ebbb 100644 --- a/Example/Package.swift +++ b/Example/Package.swift @@ -75,7 +75,7 @@ let package = Package( product: .library(.static), dependencies: [ .target("AppUI"), - .target("FeedTabFeature"), + .target("MainFeature"), .dependency("_ComposableArchitecture"), .localExtensions, ] @@ -133,6 +133,8 @@ let package = Package( name: "MainFeature", product: .library(.static), dependencies: [ + .target("FeedTabFeature"), + .target("ProfileTabFeature"), .dependency("_ComposableArchitecture"), .localExtensions, ] diff --git a/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift index 3734b8d..90def48 100644 --- a/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift +++ b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift @@ -8,24 +8,30 @@ import _ComposableArchitecture import AppUI import AppModels -import FeedTabFeature +import MainFeature public class SceneDelegate: UIResponder, UIWindowSceneDelegate { public var window: UIWindow? let store = Store( - initialState: FeedTabFeature.State( + initialState: MainFeature.State( feed: .init( - list: .init( - tweets: .init( - uncheckedUniqueElements: TweetModel - .mockTweets.filter(\.replyTo.isNil) - .map { .mock(model: $0) } + feed: .init( + list: .init( + tweets: .init( + uncheckedUniqueElements: TweetModel + .mockTweets.filter(\.replyTo.isNil) + .map { .mock(model: $0) } + ) ) ) - ) + ), + profile: .init( + root: .init(model: .mock()) + ), + selectedTab: .feed ), reducer: { - FeedTabFeature()._printChanges() + MainFeature()._printChanges() } ) @@ -35,7 +41,7 @@ public class SceneDelegate: UIResponder, UIWindowSceneDelegate { options connectionOptions: UIScene.ConnectionOptions ) { guard let scene = scene as? UIWindowScene else { return } - let controller = FeedTabController() + let controller = MainViewController() controller.setStore(store) let window = UIWindow(windowScene: scene) diff --git a/Example/Sources/FeedTabFeature/FeedTabController.swift b/Example/Sources/FeedTabFeature/FeedTabController.swift index 66e1d95..eb5f2f2 100644 --- a/Example/Sources/FeedTabFeature/FeedTabController.swift +++ b/Example/Sources/FeedTabFeature/FeedTabController.swift @@ -6,10 +6,7 @@ import ProfileFeature import TweetsFeedFeature @RoutingController -public final class FeedTabController: ComposableViewController< - FeedTabFeature.State, - FeedTabFeature.Action -> { +public final class FeedTabController: ComposableViewControllerOf { let contentController: TweetsFeedController = .init() @ComposableStackDestination diff --git a/Example/Sources/MainFeature/MainFeature.swift b/Example/Sources/MainFeature/MainFeature.swift index 1ee01ea..9530724 100644 --- a/Example/Sources/MainFeature/MainFeature.swift +++ b/Example/Sources/MainFeature/MainFeature.swift @@ -1,8 +1,51 @@ -//import _ComposableArchitecture -// -//public struct MainFeature: Reducer { -// public struct State: Equatable { -// public var feed: FeedTabFeature.State -// public var profile: ProfileTabFeature.State -// } -//} +import _ComposableArchitecture +import FeedTabFeature +import ProfileTabFeature + +@Reducer +public struct MainFeature { + @ObservableState + public struct State: Equatable { + public init( + feed: FeedTabFeature.State, + profile: ProfileTabFeature.State, + selectedTab: Tab = .feed + ) { + self.feed = feed + self.profile = profile + self.selectedTab = selectedTab + } + + public var feed: FeedTabFeature.State + public var profile: ProfileTabFeature.State + public var selectedTab: Tab + + @CasePathable + public enum Tab: Hashable { + case feed + case profile + } + } + + public enum Action: Equatable, BindableAction { + case feed(FeedTabFeature.Action) + case profile(ProfileTabFeature.Action) + case binding(BindingAction) + } + + public init() {} + + public var body: some ReducerOf { + Scope( + state: \.feed, + action: \.feed, + child: FeedTabFeature.init + ) + Scope( + state: \.profile, + action: \.profile, + child: ProfileTabFeature.init + ) + BindingReducer() + } +} diff --git a/Example/Sources/MainFeature/MainViewController.swift b/Example/Sources/MainFeature/MainViewController.swift new file mode 100644 index 0000000..6c71c03 --- /dev/null +++ b/Example/Sources/MainFeature/MainViewController.swift @@ -0,0 +1,99 @@ +import _ComposableArchitecture +import FoundationExtensions +import CombineNavigation +import AppUI +import CocoaAliases +import FeedTabFeature +import ProfileTabFeature + +public final class MainViewController: ComposableTabBarControllerOf, UITabBarControllerDelegate { + let feedTabController: FeedTabController = .init() + let profileTabController: UIViewController = .init() + + public override func _init() { + super._init() + + setViewControllers( + [ + UINavigationController( + rootViewController: feedTabController.configured { $0 + .set { $0.tabBarItem = .init( + title: "Feed", + image: UIImage(systemName: "house"), + selectedImage: UIImage(systemName: "house.fill") + ) } + } + ), + UINavigationController( + rootViewController: profileTabController.configured { $0 + .set { $0.tabBarItem = .init( + title: "Profile", + image: UIImage(systemName: "person"), + selectedImage: UIImage(systemName: "person.fill") + ) } + } + ) + ], + animated: false + ) + } + + public override func scope(_ store: Store?) { + feedTabController.setStore(store?.scope( + state: \.feed, + action: \.feed + )) + } + + public override func bind( + _ state: StorePublisher, + into cancellables: inout Cancellables + ) { + publisher(for: \.selectedIndex) + .sinkValues(capture { _self, index in + guard + let controller = _self.controller(for: index), + let tab = _self.tab(for: controller) + else { return } + + _self.store?.send(.binding(.set(\.selectedTab, tab))) + }) + .store(in: &cancellables) + + state.selectedTab + .sinkValues(capture { _self, tab in + _self.index(of: _self.controller(for: tab)).map { index in + _self.selectedIndex = index + } + }) + .store(in: &cancellables) + } + + func index(of controller: CocoaViewController) -> Int? { + viewControllers?.firstIndex(of: controller) + } + + func controller(for tab: State.Tab) -> CocoaViewController { + switch tab { + case .feed: + return feedTabController + case .profile: + return profileTabController + } + } + + func controller(for index: Int) -> CocoaViewController? { + return viewControllers?[safe: index] + } + + func tab(for controller: CocoaViewController) -> State.Tab? { + switch controller { + case feedTabController: + return .feed + case profileTabController: + return .profile + default: + return nil + } + } +} diff --git a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift index 19129b0..6abcbd7 100644 --- a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift +++ b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift @@ -1,4 +1,6 @@ import _ComposableArchitecture +import UserProfileFeature +import TweetsFeedFeature @Reducer public struct ProfileTabFeature { diff --git a/Example/Sources/TweetDetailFeature/TweetDetailController.swift b/Example/Sources/TweetDetailFeature/TweetDetailController.swift index 2f77593..0611cc4 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailController.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailController.swift @@ -7,10 +7,7 @@ import Capture import CombineNavigation @RoutingController -public final class TweetDetailController: ComposableViewController< - TweetDetailFeature.State, - TweetDetailFeature.Action -> { +public final class TweetDetailController: ComposableViewControllerOf { let host = ComposableHostingController(rootView: nil) @ComposableTreeDestination diff --git a/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift index 5901e7b..1bf2575 100644 --- a/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift @@ -6,10 +6,7 @@ import TweetsListFeature import TweetDetailFeature @RoutingController -public final class TweetsFeedController: ComposableViewController< - TweetsFeedFeature.State, - TweetsFeedFeature.Action -> { +public final class TweetsFeedController: ComposableViewControllerOf { let host = ComposableHostingController(rootView: nil) @ComposableTreeDestination diff --git a/Example/Sources/_Dependencies/_ComposableArchitecture/Exports.swift b/Example/Sources/_Dependencies/_ComposableArchitecture/Exports.swift index e73d35f..290a4fd 100644 --- a/Example/Sources/_Dependencies/_ComposableArchitecture/Exports.swift +++ b/Example/Sources/_Dependencies/_ComposableArchitecture/Exports.swift @@ -1,12 +1 @@ @_exported import ComposableExtensions - -extension ComposableViewController { - public func sendPop( - _ ids: [StackElementID], - from actionPath: CaseKeyPath> - ) { - ids.first.map { id in - _ = store?.send(actionPath.callAsFunction(.popFrom(id: id))) - } - } -} From eb498dc8b510380543192beb977bafacfcb6c1a9 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 23 Dec 2023 06:23:19 +0100 Subject: [PATCH 20/43] fix: Add missing ObservableState conformance --- Example/Sources/FeedTabFeature/FeedTabFeature.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Example/Sources/FeedTabFeature/FeedTabFeature.swift b/Example/Sources/FeedTabFeature/FeedTabFeature.swift index 83a1206..8c5605d 100644 --- a/Example/Sources/FeedTabFeature/FeedTabFeature.swift +++ b/Example/Sources/FeedTabFeature/FeedTabFeature.swift @@ -32,6 +32,7 @@ public struct FeedTabFeature { } } + @ObservableState public struct State: Equatable { public var feed: TweetsFeedFeature.State public var path: StackState From 674edc1bdb583696cab06c86a1c8bda9b06dada9 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 23 Dec 2023 18:35:56 +0100 Subject: [PATCH 21/43] [wip] feat(docs):Add project structure graph --- Example/Project.fig | Bin 0 -> 51675 bytes Example/project-structure.pdf | Bin 0 -> 1522072 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 Example/Project.fig create mode 100644 Example/project-structure.pdf diff --git a/Example/Project.fig b/Example/Project.fig new file mode 100644 index 0000000000000000000000000000000000000000..c4300b29752a551a146aaa255d722db201c572de GIT binary patch literal 51675 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

L2SU(6L`1=BuG; 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∨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%0A$a~IZX!<1WjVaP>;8(U_Uj5 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 Z}26>2oB9>hK^5ew=Hm1k8 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 z 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 Date: Fri, 29 Dec 2023 00:36:00 +0100 Subject: [PATCH 22/43] [wip] feat: Model improvements --- Example/Package.swift | 78 +++- .../Sources/APIClient/APIClient+Live.swift | 387 ++++++++++++++++++ Example/Sources/APIClient/APIClient.swift | 27 ++ .../APIClient/Auth/APIClient+Auth.swift | 95 +++++ .../APIClient/Feed/APIClient+Feed.swift | 42 ++ .../APIClient/Tweet/APIClient+Tweet.swift | 196 +++++++++ .../APIClient/User/APIClient+User.swift | 119 ++++++ .../AppFeature/Bootstrap/SceneDelegate.swift | 85 +++- Example/Sources/AppModels/FollowerModel.swift | 2 +- Example/Sources/AppModels/TweetModel.swift | 33 +- Example/Sources/AppModels/UserModel.swift | 14 +- .../Sources/AppModels/_Mock/MockThreads.swift | 2 +- Example/Sources/AppUI/TextInput.swift | 31 ++ .../AuthFeature/AuthFeature+SignIn.swift | 74 ++++ .../AuthFeature/AuthFeature+SignUp.swift | 21 + Example/Sources/AuthFeature/AuthFeature.swift | 32 ++ .../DatabaseSchema+MigrationPlan.swift | 13 + .../DatabaseSchema+ModelContext.swift | 29 ++ .../DatabaseSchema/DatabaseSchema.swift | 6 + Example/Sources/DatabaseSchema/Exports.swift | 26 ++ .../Versions/V1/V1+TweetModel.swift | 53 +++ .../Versions/V1/V1+UserModel.swift | 56 +++ .../DatabaseSchema/Versions/V1/V1.swift | 14 + .../ExternalUserProfileFeature.swift | 68 +++ .../ExternalUserProfileView.swift | 61 +++ .../FeedAndProfileViewController.swift | 1 - .../FeedTabFeature/FeedTabController.swift | 4 +- .../FeedTabFeature/FeedTabFeature.swift | 28 +- Example/Sources/OnboardingFeature/File.swift | 8 + .../ProfileAndFeedPivot.swift | 32 ++ .../ProfileFeature/ProfileFeature.swift | 33 -- .../Sources/ProfileFeature/ProfileView.swift | 50 --- .../ProfileFeedFeature.swift | 2 +- .../ProfileTabFeature/ProfileTabFeature.swift | 43 +- .../TweetDetailFeature.swift | 28 +- .../Sources/TweetFeature/TweetFeature.swift | 35 +- .../TweetsFeedFeature/TweetsFeedFeature.swift | 14 +- .../TweetsListFeature/TweetsListFeature.swift | 6 +- .../UserProfileFeature.swift | 71 +--- .../UserProfileFeature/UserProfileView.swift | 47 +-- .../UserSettingsFeature.swift | 6 +- .../UserSettingsView.swift | 2 +- .../_Dependencies/_Dependencies/Exports.swift | 1 + .../LocalExtensions/Binding+Variable.swift | 11 + .../LocalExtensions/Equated.Comparator+.swift | 14 + .../_Extensions/LocalExtensions/sha256.swift | 41 ++ 46 files changed, 1742 insertions(+), 299 deletions(-) create mode 100644 Example/Sources/APIClient/APIClient+Live.swift create mode 100644 Example/Sources/APIClient/APIClient.swift create mode 100644 Example/Sources/APIClient/Auth/APIClient+Auth.swift create mode 100644 Example/Sources/APIClient/Feed/APIClient+Feed.swift create mode 100644 Example/Sources/APIClient/Tweet/APIClient+Tweet.swift create mode 100644 Example/Sources/APIClient/User/APIClient+User.swift create mode 100644 Example/Sources/AppUI/TextInput.swift create mode 100644 Example/Sources/AuthFeature/AuthFeature+SignIn.swift create mode 100644 Example/Sources/AuthFeature/AuthFeature+SignUp.swift create mode 100644 Example/Sources/AuthFeature/AuthFeature.swift create mode 100644 Example/Sources/DatabaseSchema/DatabaseSchema+MigrationPlan.swift create mode 100644 Example/Sources/DatabaseSchema/DatabaseSchema+ModelContext.swift create mode 100644 Example/Sources/DatabaseSchema/DatabaseSchema.swift create mode 100644 Example/Sources/DatabaseSchema/Exports.swift create mode 100644 Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift create mode 100644 Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift create mode 100644 Example/Sources/DatabaseSchema/Versions/V1/V1.swift create mode 100644 Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift create mode 100644 Example/Sources/ExternalUserProfileFeature/ExternalUserProfileView.swift delete mode 100644 Example/Sources/FeedAndProfileFeature/FeedAndProfileViewController.swift create mode 100644 Example/Sources/OnboardingFeature/File.swift create mode 100644 Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift delete mode 100644 Example/Sources/ProfileFeature/ProfileFeature.swift delete mode 100644 Example/Sources/ProfileFeature/ProfileView.swift create mode 100644 Example/Sources/_Dependencies/_Dependencies/Exports.swift create mode 100644 Example/Sources/_Extensions/LocalExtensions/Binding+Variable.swift create mode 100644 Example/Sources/_Extensions/LocalExtensions/Equated.Comparator+.swift create mode 100644 Example/Sources/_Extensions/LocalExtensions/sha256.swift diff --git a/Example/Package.swift b/Example/Package.swift index 310ebbb..9d267dd 100644 --- a/Example/Package.swift +++ b/Example/Package.swift @@ -12,6 +12,10 @@ let package = Package( url: "https://github.com/capturecontext/composable-architecture-extensions.git", branch: "observation-beta" ), + .package( + url: "https://github.com/pointfreeco/swift-dependencies.git", + .upToNextMajor(from: "1.0.0") + ), .package( url: "https://github.com/capturecontext/swift-foundation-extensions.git", .upToNextMinor(from: "0.4.0") @@ -50,8 +54,8 @@ let package = Package( // Ideally should be extracted to a separate `Dependencies` package // See https://github.com/capturecontext/basic-ios-template // - Can import targets from `Utils` section + // - Must not import targets from `Modules` section - // Meant to locally extend ComposableExtensions .target( name: "_ComposableArchitecture", product: .library(.static), @@ -65,17 +69,46 @@ let package = Package( path: ._dependencies("_ComposableArchitecture") ), + .target( + name: "_Dependencies", + product: .library(.static), + dependencies: [ + .localExtensions, + .product( + name: "Dependencies", + package: "swift-dependencies" + ) + ], + path: ._dependencies("_Dependencies") + ), + // MARK: - Modules // Application modules + // - Can import any targets from sections above + // - Should not import external dependencies directly // - Feature modules have suffix `Feature` - // - Service and Model modules have no suffix + // - Service and Model modules have no specific suffix + + .target( + name: "APIClient", + product: .library(.static), + dependencies: [ + .target("AppModels"), + .target("DatabaseSchema"), + .dependency("_Dependencies"), + .localExtensions + ] + ), .target( name: "AppFeature", product: .library(.static), dependencies: [ + .target("APIClient"), .target("AppUI"), + .target("AuthFeature"), .target("MainFeature"), + .target("OnboardingFeature"), .dependency("_ComposableArchitecture"), .localExtensions, ] @@ -97,6 +130,15 @@ let package = Package( ] ), + .target( + name: "AuthFeature", + product: .library(.static), + dependencies: [ + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + .target( name: "CurrentUserProfileFeature", product: .library(.static), @@ -110,9 +152,19 @@ let package = Package( ), .target( - name: "FeedAndProfileFeature", + name: "DatabaseSchema", product: .library(.static), dependencies: [ + .localExtensions + ] + ), + + .target( + name: "ExternalUserProfileFeature", + product: .library(.static), + dependencies: [ + .target("AppModels"), + .target("TweetsListFeature"), .dependency("_ComposableArchitecture"), .localExtensions, ] @@ -122,7 +174,7 @@ let package = Package( name: "FeedTabFeature", product: .library(.static), dependencies: [ - .target("ProfileFeature"), + .target("UserProfileFeature"), .target("TweetsFeedFeature"), .dependency("_ComposableArchitecture"), .localExtensions, @@ -141,11 +193,20 @@ let package = Package( ), .target( - name: "ProfileFeature", + name: "OnboardingFeature", product: .library(.static), dependencies: [ + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "ProfileAndFeedPivot", + product: .library(.static), + dependencies: [ + .target("TweetsFeedFeature"), .target("UserProfileFeature"), - .target("CurrentUserProfileFeature"), .dependency("_ComposableArchitecture"), .localExtensions, ] @@ -168,6 +229,7 @@ let package = Package( .target("AppModels"), .target("TweetsFeedFeature"), .target("UserProfileFeature"), + .target("ProfileAndFeedPivot"), .dependency("_ComposableArchitecture"), .localExtensions, ] @@ -219,8 +281,8 @@ let package = Package( name: "UserProfileFeature", product: .library(.static), dependencies: [ - .target("AppModels"), - .target("TweetsListFeature"), + .target("CurrentUserProfileFeature"), + .target("ExternalUserProfileFeature"), .dependency("_ComposableArchitecture"), .localExtensions, ] diff --git a/Example/Sources/APIClient/APIClient+Live.swift b/Example/Sources/APIClient/APIClient+Live.swift new file mode 100644 index 0000000..2d02f0a --- /dev/null +++ b/Example/Sources/APIClient/APIClient+Live.swift @@ -0,0 +1,387 @@ +import _Dependencies +import SwiftData +import LocalExtensions +import DatabaseSchema +import AppModels + +// Not sure if it's okay, just wanted to silence warnings +// this file is just mock implementation that uses local database +// for backend work simulation 😁 +extension ModelContext: @unchecked Sendable {} + +private let database = try! DatabaseSchema.createModelContext(.inMemory) + +extension APIClient: DependencyKey { + public func _accessDatabase(_ operation: (ModelContext) -> Void) { + operation(database) + } + + public static var liveValue: APIClient { + @Box + var currentUser: DatabaseSchema.UserModel? + + return .init( + auth: .backendLike( + database: database, + currentUser: _currentUser + ), + feed: .backendLike( + database: database, + currentUser: _currentUser + ), + tweet: .backendLike( + database: database, + currentUser: _currentUser + ), + user: .backendLike( + database: database, + currentUser: _currentUser + ) + ) + } +} + +private enum Errors { + struct UserExists: Swift.Error { + var localizedDesctiption: String { "User exists" } + } + + struct UserDoesNotExist: Swift.Error { + var localizedDesctiption: String { "User doesn't exist" } + } + + struct UnauthenticatedRequest: Swift.Error { + var localizedDesctiption: String { "Request requires authentication" } + } + + struct TweetDoesNotExist: Swift.Error { + var localizedDesctiption: String { "Tweet doesn't exist" } + } + + struct UnauthorizedRequest: Swift.Error { + var localizedDesctiption: String { "Unauthirized access" } + } + + struct AuthenticationFailed: Swift.Error { + var localizedDesctiption: String { "Username or password is incorrect" } + } +} + +extension APIClient.Auth { + static func backendLike( + database: ModelContext, + currentUser: Box + ) -> Self { + .init( + signIn: .init { input in + return Result { + let pwHash = try Data.sha256(input.password).unwrap().get() + + let username = input.username + guard let user = try database.fetch( + DatabaseSchema.UserModel.self, + #Predicate { model in + model.username == username + && model.password == pwHash + } + ).first + else { throw Errors.AuthenticationFailed() } + + currentUser.content = user + } + }, + signUp: .init { input in + return Result { + let username = input.username + let userExists = try database.fetch( + DatabaseSchema.UserModel.self, + #Predicate { $0.username == username } + ).isNotEmpty + + guard !userExists + else { throw Errors.UserExists() } + + let pwHash = try Data.sha256(input.password).unwrap().get() + + let user = DatabaseSchema.UserModel( + id: USID(), + username: input.username, + password: pwHash + ) + + database.insert(user) + try database.save() + currentUser.content = user + } + }, + logout: .init { _ in + currentUser.content = nil + } + ) + } +} + +extension APIClient.Feed { + static func backendLike( + database: ModelContext, + currentUser: Box + ) -> Self { + .init( + fetchTweets: .init { input in + return Result { + try database.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.replySource == nil } + ) + .dropFirst(input.page * input.limit) + .prefix(input.limit) + .map { tweet in + return TweetModel( + id: tweet.id.usid(), + authorID: tweet.author.id.usid(), + replyTo: tweet.replySource?.id.usid(), + repliesCount: tweet.replies.count, + isLiked: currentUser.content.map { user in + tweet.replies.contains { $0 === user } + }.or(false), + likesCount: tweet.likes.count, + isReposted: currentUser.content.map(tweet.reposts.map(\.author).contains).or(false), + repostsCount: tweet.reposts.count, + text: tweet.content + ) + } + } + } + ) + } +} + +extension APIClient.Tweet { + static func backendLike( + database: ModelContext, + currentUser: Box + ) -> Self { + .init( + like: .init { input in + return Result { + guard let user = currentUser.content + else { throw Errors.UnauthenticatedRequest() } + + let shouldLike = input.value + let isLiked = user.likedTweets.contains(where: { $0.id == input.id.rawValue }) + + guard shouldLike != isLiked else { return } + + if shouldLike { + let tweetID = input.id.rawValue + guard let tweet = try database.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw Errors.TweetDoesNotExist() } + + user.likedTweets.append(tweet) + } else { + user.likedTweets.removeAll { $0.id == input.id.rawValue } + } + + try database.save() + } + }, + post: .init { input in + return Result { + guard let user = currentUser.content + else { throw Errors.UnauthenticatedRequest() } + + database.insert(DatabaseSchema.TweetModel( + id: USID(), + createdAt: .now, + author: user, + content: input + )) + + try database.save() + } + }, + repost: .init { input in + return Result { + guard let user = currentUser.content + else { throw Errors.UnauthenticatedRequest() } + + let tweetID = input.id.rawValue + guard let originalTweet = try database.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw Errors.TweetDoesNotExist() } + + originalTweet.reposts.append(DatabaseSchema.TweetModel( + id: USID(), + createdAt: .now, + author: user, + content: input.content + )) + + try database.save() + } + }, + reply: .init { input in + return Result { + guard let user = currentUser.content + else { throw Errors.UnauthenticatedRequest() } + + let tweetID = input.id.rawValue + guard let originalTweet = try database.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw Errors.TweetDoesNotExist() } + + originalTweet.replies.append(DatabaseSchema.TweetModel( + id: USID(), + createdAt: .now, + author: user, + content: input.content + )) + + try database.save() + } + }, + delete: .init { input in + return Result { + guard let user = currentUser.content + else { throw Errors.UnauthenticatedRequest() } + + guard let tweetToDelete = try database.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == input.rawValue } + ).first + else { throw Errors.TweetDoesNotExist() } + + user.tweets.removeAll { $0 === tweetToDelete } + + try database.save() + } + }, + report: .init { input in + // Pretend we did collect the report + return .success(()) + }, + fetchReplies: .init { input in + return Result { + let tweetID = input.id.rawValue + guard let tweet = try database.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw Errors.TweetDoesNotExist() } + + return tweet.replies + .dropFirst(input.page * input.limit) + .prefix(input.limit) + .map { tweet in + return TweetModel( + id: tweet.id.usid(), + authorID: tweet.author.id.usid(), + replyTo: tweet.replySource?.id.usid(), + repliesCount: tweet.replies.count, + isLiked: currentUser.content.map { user in + tweet.replies.contains { $0 === user } + }.or(false), + likesCount: tweet.likes.count, + isReposted: currentUser.content.map(tweet.reposts.map(\.author).contains).or(false), + repostsCount: tweet.reposts.count, + text: tweet.content + ) + } + } + } + ) + } +} + +extension APIClient.User { + static func backendLike( + database: ModelContext, + currentUser: Box + ) -> Self { + .init( + fetch: .init { id in + return Result { + guard let user = try database.fetch( + DatabaseSchema.UserModel.self, + #Predicate { $0.id == id.rawValue } + ).first + else { throw Errors.UserDoesNotExist()} + + return UserModel( + id: user.id.usid(), + username: user.username, + displayName: user.displayName, + bio: user.bio, + avatarURL: user.avatarURL + ) + } + }, + follow: .init { input in + return Result { + guard let user = currentUser.content + else { throw Errors.UnauthenticatedRequest() } + + let shouldFollow = input.value + let isFollowing = user.follows.contains(where: { $0.id == input.id.rawValue }) + + guard shouldFollow != isFollowing else { return } + + if shouldFollow { + let userID = input.id.rawValue + guard let userToFollow = try database.fetch( + DatabaseSchema.UserModel.self, + #Predicate { $0.id == userID } + ).first + else { throw Errors.UserDoesNotExist() } + + user.follows.append(userToFollow) + } else { + user.follows.removeAll { $0.id == input.id.rawValue } + } + + try database.save() + } + }, + report: .init { input in + // Pretend we did collect the report + return .success(()) + }, + fetchTweets: .init { input in + return Result { + let userID = input.id.rawValue + guard let user = try database.fetch( + DatabaseSchema.UserModel.self, + #Predicate { $0.id == userID } + ).first + else { throw Errors.UserDoesNotExist() } + + return user.tweets + .dropFirst(input.page * input.limit) + .prefix(input.limit) + .map { tweet in + return TweetModel( + id: tweet.id.usid(), + authorID: tweet.author.id.usid(), + replyTo: tweet.replySource?.id.usid(), + repliesCount: tweet.replies.count, + isLiked: currentUser.content.map { user in + tweet.replies.contains { $0 === user } + }.or(false), + likesCount: tweet.likes.count, + isReposted: currentUser.content.map(tweet.reposts.map(\.author).contains).or(false), + repostsCount: tweet.reposts.count, + text: tweet.content + ) + } + } + } + ) + } +} diff --git a/Example/Sources/APIClient/APIClient.swift b/Example/Sources/APIClient/APIClient.swift new file mode 100644 index 0000000..1907b3d --- /dev/null +++ b/Example/Sources/APIClient/APIClient.swift @@ -0,0 +1,27 @@ +import _Dependencies + +public struct APIClient { + public init( + auth: Auth, + feed: Feed, + tweet: Tweet, + user: User + ) { + self.auth = auth + self.feed = feed + self.tweet = tweet + self.user = user + } + + public var auth: Auth + public var feed: Feed + public var tweet: Tweet + public var user: User +} + +extension DependencyValues { + public var apiClient: APIClient { + get { self[APIClient.self] } + set { self[APIClient.self] = newValue } + } +} diff --git a/Example/Sources/APIClient/Auth/APIClient+Auth.swift b/Example/Sources/APIClient/Auth/APIClient+Auth.swift new file mode 100644 index 0000000..4b6f5d9 --- /dev/null +++ b/Example/Sources/APIClient/Auth/APIClient+Auth.swift @@ -0,0 +1,95 @@ +import LocalExtensions +import AppModels + +extension APIClient { + public struct Auth { + public init( + signIn: Operations.SignIn, + signUp: Operations.SignUp, + logout: Operations.Logout + ) { + self.signIn = signIn + self.signUp = signUp + self.logout = logout + } + + public var signIn: Operations.SignIn + public var signUp: Operations.SignUp + public var logout: Operations.Logout + } +} + +extension APIClient.Auth { + public enum Operations {} +} + +extension APIClient.Auth.Operations { + public struct SignIn { + public typealias Input = ( + username: String, + password: String + ) + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + username: String, + password: String + ) async -> Output { + await asyncCall((username, password)) + } + } + + public struct SignUp { + public typealias Input = ( + username: String, + password: String + ) + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + username: String, + password: String + ) async -> Output { + await asyncCall((username, password)) + } + } + + public struct Logout { + public typealias Input = Void + + public typealias Output = Void + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + username: String, + password: String + ) async -> Output { + await asyncCall(()) + } + } +} diff --git a/Example/Sources/APIClient/Feed/APIClient+Feed.swift b/Example/Sources/APIClient/Feed/APIClient+Feed.swift new file mode 100644 index 0000000..f4b5e41 --- /dev/null +++ b/Example/Sources/APIClient/Feed/APIClient+Feed.swift @@ -0,0 +1,42 @@ +import LocalExtensions +import AppModels + +extension APIClient { + public struct Feed { + public init(fetchTweets: Operations.FetchTweets) { + self.fetchTweets = fetchTweets + } + + public var fetchTweets: Operations.FetchTweets + } +} + +extension APIClient.Feed { + public enum Operations {} +} + +extension APIClient.Feed.Operations { + public struct FetchTweets { + public typealias Input = ( + page: Int, + limit: Int + ) + + public typealias Output = Result<[TweetModel], Error> + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + page: Int = 0, + limit: Int = 15 + ) async -> Output { + await asyncCall((page, limit)) + } + } +} diff --git a/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift b/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift new file mode 100644 index 0000000..1c578ea --- /dev/null +++ b/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift @@ -0,0 +1,196 @@ +import LocalExtensions +import AppModels + +extension APIClient { + public struct Tweet { + public init( + like: Operations.Like, + post: Operations.Post, + repost: Operations.Repost, + reply: Operations.Reply, + delete: Operations.Delete, + report: Operations.Report, + fetchReplies: Operations.FetchReplies + ) { + self.like = like + self.post = post + self.repost = repost + self.reply = reply + self.delete = delete + self.report = report + self.fetchReplies = fetchReplies + } + + public var like: Operations.Like + public var post: Operations.Post + public var repost: Operations.Repost + public var reply: Operations.Reply + public var delete: Operations.Delete + public var report: Operations.Report + public var fetchReplies: Operations.FetchReplies + } +} + +extension APIClient.Tweet { + public enum Operations {} +} + +extension APIClient.Tweet.Operations { + public struct Like { + public typealias Input = ( + id: USID, + value: Bool + ) + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + id: USID, + value: Bool + ) async -> Output { + await asyncCall((id, value)) + } + } + + public struct Repost { + public typealias Input = ( + id: USID, + content: String + ) + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + id: USID, + with content: String + ) async -> Output { + await asyncCall((id, content)) + } + } + + public struct Delete { + public typealias Input = USID + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + id: USID + ) async -> Output { + await asyncCall(id) + } + } + + public struct Report { + public typealias Input = USID + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + id: USID + ) async -> Output { + await asyncCall(id) + } + } + + public struct Reply { + public typealias Input = ( + id: USID, + content: String + ) + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + to id: USID, + with content: String + ) async -> Output { + await asyncCall((id, content)) + } + } + + public struct Post { + public typealias Input = String + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + _ text: String + ) async -> Output { + await asyncCall(text) + } + } + + public struct FetchReplies { + public typealias Input = ( + id: USID, + page: Int, + limit: Int + ) + + public typealias Output = Result<[TweetModel], Error> + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + for id: USID, + page: Int = 0, + limit: Int = 15 + ) async -> Output { + await asyncCall((id, page, limit)) + } + } +} diff --git a/Example/Sources/APIClient/User/APIClient+User.swift b/Example/Sources/APIClient/User/APIClient+User.swift new file mode 100644 index 0000000..26ba4db --- /dev/null +++ b/Example/Sources/APIClient/User/APIClient+User.swift @@ -0,0 +1,119 @@ +import LocalExtensions +import AppModels + +extension APIClient { + public struct User { + public init( + fetch: Operations.Fetch, + follow: Operations.Follow, + report: Operations.Report, + fetchTweets: Operations.FetchTweets + ) { + self.fetch = fetch + self.follow = follow + self.report = report + self.fetchTweets = fetchTweets + } + + public var fetch: Operations.Fetch + public var follow: Operations.Follow + public var report: Operations.Report + public var fetchTweets: Operations.FetchTweets + } +} + +extension APIClient.User { + public enum Operations {} +} + +extension APIClient.User.Operations { + public struct Fetch { + public typealias Input = USID + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + id: USID + ) async -> Output { + await asyncCall(id) + } + } + + public struct Follow { + public typealias Input = ( + id: USID, + value: Bool + ) + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + id: USID, + value: Bool + ) async -> Output { + await asyncCall((id, value)) + } + } + + public struct Report { + public typealias Input = USID + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + id: USID + ) async -> Output { + await asyncCall(id) + } + } + + public struct FetchTweets { + public typealias Input = ( + id: USID, + page: Int, + limit: Int + ) + + public typealias Output = Result<[TweetModel], Error> + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + for id: USID, + page: Int = 0, + limit: Int = 15 + ) async -> Output { + await asyncCall((id, page, limit)) + } + } +} diff --git a/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift index 90def48..a24a75c 100644 --- a/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift +++ b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift @@ -9,31 +9,68 @@ import _ComposableArchitecture import AppUI import AppModels import MainFeature +import DatabaseSchema +import LocalExtensions public class SceneDelegate: UIResponder, UIWindowSceneDelegate { public var window: UIWindow? - let store = Store( - initialState: MainFeature.State( - feed: .init( - feed: .init( - list: .init( - tweets: .init( - uncheckedUniqueElements: TweetModel - .mockTweets.filter(\.replyTo.isNil) - .map { .mock(model: $0) } - ) - ) + + @Dependency(\.apiClient) + var apiClient + + func prefillDatabase() { + apiClient._accessDatabase { database in + let currentUser = DatabaseSchema.UserModel( + id: USID(), + username: "capturecontext", + password: .sha256("psswrd")! + ) + + let tweet = DatabaseSchema.TweetModel( + id: USID(), + author: currentUser, + content: "Hello, World!" ) - ), - profile: .init( - root: .init(model: .mock()) - ), - selectedTab: .feed - ), - reducer: { - MainFeature()._printChanges() + + database.insert(tweet) + + try! database.save() + } + } + + func setupStore(_ callback: @escaping (StoreOf) -> Void) { + Task { @MainActor in + do { + _ = try await self.apiClient.auth.signIn(username: "capturecontext", password: "psswrd").get() + let tweets = try await self.apiClient.feed.fetchTweets(page: 0, limit: 3).get() + + callback(Store( + initialState: MainFeature.State( + feed: .init( + feed: .init( + list: .init( + tweets: .init( + uncheckedUniqueElements: + // TweetModel.mockTweets.filter(\.replyTo.isNil).map { .mock(model: $0) } + tweets.map { .mock(model: $0) } + ) + ) + ) + ), + profile: .init(), + selectedTab: .feed + ), + reducer: { + MainFeature()._printChanges() + } + )) + } catch { + print(error) + } } - ) + } + + var store: StoreOf? public func scene( _ scene: UIScene, @@ -42,7 +79,13 @@ public class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) { guard let scene = scene as? UIWindowScene else { return } let controller = MainViewController() - controller.setStore(store) + + prefillDatabase() + + setupStore { store in + self.store = store + controller.setStore(store) + } let window = UIWindow(windowScene: scene) self.window = window diff --git a/Example/Sources/AppModels/FollowerModel.swift b/Example/Sources/AppModels/FollowerModel.swift index f17d5c5..9f8c786 100644 --- a/Example/Sources/AppModels/FollowerModel.swift +++ b/Example/Sources/AppModels/FollowerModel.swift @@ -1,7 +1,7 @@ import LocalExtensions public struct FollowerModel: Equatable, Identifiable { - public var id: UUID { user.id } + public var id: USID { user.id } public var user: UserModel public var isFollowingYou: Bool public var isFollowedByYou: Bool diff --git a/Example/Sources/AppModels/TweetModel.swift b/Example/Sources/AppModels/TweetModel.swift index 78e3c50..6c85012 100644 --- a/Example/Sources/AppModels/TweetModel.swift +++ b/Example/Sources/AppModels/TweetModel.swift @@ -1,29 +1,44 @@ import LocalExtensions public struct TweetModel: Equatable, Identifiable, Codable { - public var id: UUID - public var authorID: UUID - public var replyTo: UUID? + public var id: USID + public var authorID: USID + public var replyTo: USID? + public var repliesCount: Int + public var isLiked: Bool + public var likesCount: Int + public var isReposted: Bool + public var repostsCount: Int public var text: String public init( - id: UUID, - authorID: UUID, - replyTo: UUID? = nil, + id: USID, + authorID: USID, + replyTo: USID? = nil, + repliesCount: Int = 0, + isLiked: Bool = false, + likesCount: Int = 0, + isReposted: Bool = false, + repostsCount: Int = 0, text: String ) { self.id = id self.authorID = authorID self.replyTo = replyTo + self.repliesCount = repliesCount + self.isLiked = isLiked + self.likesCount = likesCount + self.isReposted = isReposted + self.repostsCount = repostsCount self.text = text } } extension TweetModel { public static func mock( - id: UUID = .init(), - authorID: UUID = UserModel.mock().id, - replyTo: UUID? = nil, + id: USID = .init(), + authorID: USID = UserModel.mock().id, + replyTo: USID? = nil, text: String = """ Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ diff --git a/Example/Sources/AppModels/UserModel.swift b/Example/Sources/AppModels/UserModel.swift index ccb7315..55dbf3c 100644 --- a/Example/Sources/AppModels/UserModel.swift +++ b/Example/Sources/AppModels/UserModel.swift @@ -1,27 +1,33 @@ import LocalExtensions public struct UserModel: Equatable, Identifiable { - public var id: UUID + public var id: USID public var username: String + public var displayName: String + public var bio: String public var avatarURL: URL? public init( - id: UUID, + id: USID, username: String, + displayName: String = "", + bio: String = "", avatarURL: URL? = nil ) { self.id = id self.username = username + self.displayName = displayName + self.bio = bio self.avatarURL = avatarURL } } extension UserModel { - static var mockCacheByID: [UUID: UserModel] = [:] + static var mockCacheByID: [USID: UserModel] = [:] static var mockCacheByUsername: [String: UserModel] = [:] public static func mock( - id: UUID = .init(), + id: USID = .init(), username: String = "username", avatarURL: URL? = nil ) -> Self { diff --git a/Example/Sources/AppModels/_Mock/MockThreads.swift b/Example/Sources/AppModels/_Mock/MockThreads.swift index 1fa8881..23c29d1 100644 --- a/Example/Sources/AppModels/_Mock/MockThreads.swift +++ b/Example/Sources/AppModels/_Mock/MockThreads.swift @@ -2,7 +2,7 @@ import LocalExtensions extension TweetModel { public static func mockReplies( - for id: UUID + for id: USID ) -> IdentifiedArrayOf { mockTweets[id: id].map { source in mockTweets.filter { $0.replyTo == source.id } diff --git a/Example/Sources/AppUI/TextInput.swift b/Example/Sources/AppUI/TextInput.swift new file mode 100644 index 0000000..a3e25ac --- /dev/null +++ b/Example/Sources/AppUI/TextInput.swift @@ -0,0 +1,31 @@ +import SwiftUI +import LocalExtensions + +public enum TextInput { + public struct State: Equatable { + public var title: LocalizedStringKey + public var text: String + public var prompt: String? + } + + public struct View: SwiftUI.View { + @Binding + private var state: State + + public init(_ state: Binding) { + self._state = state + } + + public var body: some SwiftUI.View { + TextField( + state.title, + text: $state.text, + prompt: state.prompt.map { Text($0) } + ) + } + } +} + +#Preview { + TextInput.View(.variable(.init(title: "title", text: "text", prompt: "prompt"))) +} diff --git a/Example/Sources/AuthFeature/AuthFeature+SignIn.swift b/Example/Sources/AuthFeature/AuthFeature+SignIn.swift new file mode 100644 index 0000000..168e966 --- /dev/null +++ b/Example/Sources/AuthFeature/AuthFeature+SignIn.swift @@ -0,0 +1,74 @@ +import _ComposableArchitecture +import LocalExtensions +import APIClient + +extension AuthFeature { + @Reducer + public struct SignIn { + @ObservableState + public struct State: Equatable { + public init( + username: String = "", + password: String = "" + ) { + self.username = username + self.password = password + } + + public var username: String + public var password: String + + @Presents + public var alert: AlertState? + } + + public enum Action: Equatable, BindableAction { + case signInButtonTapped + case binding(BindingAction) + case event(Event) + + @CasePathable + public enum Event: Equatable { + case result(Result, Equated>) + } + } + + public init() {} + + @Dependency(\.apiClient) + var apiClient + + public var body: some ReducerOf { + CombineReducers { + Pullback(\.signInButtonTapped) { state in + let state = state + return .run { send in + switch await apiClient.auth.signIn( + username: state.username, + password: state.password + ) { + case .success: + await send(.event(.result(.success(.void)))) + case let .failure(error): + await send(.event(.result(.failure(.init(error))))) + } + } + } + Pullback(\.event.result.failure) { state, error in + return .send(.binding(.set(\.alert, AlertState( + title: { TextState("Error") }, + actions: { + ButtonState( + role: .cancel, + action: .binding(.set(\.alert, nil)), + label: { TextState("OK") } + ) + }, + message: { TextState(error.localizedDescription) } + )))) + } + BindingReducer() + } + } + } +} diff --git a/Example/Sources/AuthFeature/AuthFeature+SignUp.swift b/Example/Sources/AuthFeature/AuthFeature+SignUp.swift new file mode 100644 index 0000000..7066cd8 --- /dev/null +++ b/Example/Sources/AuthFeature/AuthFeature+SignUp.swift @@ -0,0 +1,21 @@ +import _ComposableArchitecture + +extension AuthFeature { + @Reducer + public struct SignUp { + @ObservableState + public struct State: Equatable { + public init() {} + } + + public enum Action: Equatable { + + } + + public init() {} + + public var body: some ReducerOf { + EmptyReducer() + } + } +} diff --git a/Example/Sources/AuthFeature/AuthFeature.swift b/Example/Sources/AuthFeature/AuthFeature.swift new file mode 100644 index 0000000..5fceea1 --- /dev/null +++ b/Example/Sources/AuthFeature/AuthFeature.swift @@ -0,0 +1,32 @@ +import _ComposableArchitecture + +@Reducer +public struct AuthFeature { + @ObservableState + public enum State: Equatable { + case signIn(SignIn.State = .init()) + case signUp(SignUp.State = .init()) + } + + public enum Action: Equatable { + case signIn(SignIn.Action) + case signUp(SignUp.Action) + } + + public init() {} + + public var body: some ReducerOf { + CombineReducers { + Scope( + state: \.signIn, + action: \.signIn, + child: SignIn.init + ) + Scope( + state: \.signUp, + action: \.signUp, + child: SignUp.init + ) + } + } +} diff --git a/Example/Sources/DatabaseSchema/DatabaseSchema+MigrationPlan.swift b/Example/Sources/DatabaseSchema/DatabaseSchema+MigrationPlan.swift new file mode 100644 index 0000000..2afa45d --- /dev/null +++ b/Example/Sources/DatabaseSchema/DatabaseSchema+MigrationPlan.swift @@ -0,0 +1,13 @@ +import SwiftData + +extension DatabaseSchema { + public enum MigrationPlan: SchemaMigrationPlan { + public static var stages: [MigrationStage] { + [] + } + + public static var schemas: [any VersionedSchema.Type] { + [V1.self] + } + } +} diff --git a/Example/Sources/DatabaseSchema/DatabaseSchema+ModelContext.swift b/Example/Sources/DatabaseSchema/DatabaseSchema+ModelContext.swift new file mode 100644 index 0000000..9e362dd --- /dev/null +++ b/Example/Sources/DatabaseSchema/DatabaseSchema+ModelContext.swift @@ -0,0 +1,29 @@ +import SwiftData +import LocalExtensions + +extension DatabaseSchema { + public enum ModelPersistance { + case inMemory + case file(URL = .applicationSupportDirectory.appending(path: "db.store")) + } + + public static func createModelContext( + _ persistance: ModelPersistance + ) throws -> ModelContext { + + let config = switch persistance { + case .inMemory: + ModelConfiguration(isStoredInMemoryOnly: true) + case let .file(url): + ModelConfiguration(url: url) + } + + let container = try ModelContainer( + for: TweetModel.self, UserModel.self, + migrationPlan: DatabaseSchema.MigrationPlan.self, + configurations: config + ) + + return ModelContext(container) + } +} diff --git a/Example/Sources/DatabaseSchema/DatabaseSchema.swift b/Example/Sources/DatabaseSchema/DatabaseSchema.swift new file mode 100644 index 0000000..6143bc5 --- /dev/null +++ b/Example/Sources/DatabaseSchema/DatabaseSchema.swift @@ -0,0 +1,6 @@ +public enum DatabaseSchema { + public typealias Current = V1 +} + +import SwiftData +import LocalExtensions diff --git a/Example/Sources/DatabaseSchema/Exports.swift b/Example/Sources/DatabaseSchema/Exports.swift new file mode 100644 index 0000000..b20524f --- /dev/null +++ b/Example/Sources/DatabaseSchema/Exports.swift @@ -0,0 +1,26 @@ +@_exported import SwiftData +import Foundation + +extension DatabaseSchema { + public typealias TweetModel = Current.TweetModel + public typealias UserModel = Current.UserModel +} + +extension PersistentModel { + public typealias Fetch = FetchDescriptor + public typealias Sort = SortDescriptor + public typealias Predicate = Foundation.Predicate +} + +extension ModelContext { + public func fetch( + _ model: Model.Type = Model.self, + _ predicate: Model.Predicate, + sortBy sortDescriptors: [Model.Sort] = [] + ) throws -> [Model] { + return try fetch(Model.Fetch( + predicate: predicate, + sortBy: sortDescriptors + )) + } +} diff --git a/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift b/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift new file mode 100644 index 0000000..025d693 --- /dev/null +++ b/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift @@ -0,0 +1,53 @@ +import SwiftData +import LocalExtensions + +extension DatabaseSchema.V1 { + @Model + public final class TweetModel: Equatable, Identifiable, Sendable { + @Attribute(.unique) + public var id: String + public var createdAt: Date + + @Relationship(inverse: \UserModel.tweets) + public var author: UserModel + + @Relationship + public var repostSource: TweetModel? + + @Relationship + public var replySource: TweetModel? + + @Relationship(inverse: \TweetModel.replySource) + public var replies: [TweetModel] + + @Relationship(inverse: \TweetModel.repostSource) + public var reposts: [TweetModel] + + @Relationship(inverse: \UserModel.likedTweets) + public var likes: [UserModel] + + public var content: String + + public init( + id: USID, + createdAt: Date = .now, + author: UserModel, + repostSource: TweetModel? = nil, + replySource: TweetModel? = nil, + replies: [TweetModel] = [], + reposts: [TweetModel] = [], + likes: [UserModel] = [], + content: String + ) { + self.id = id.rawValue + self.createdAt = createdAt + self.author = author + self.repostSource = repostSource + self.replySource = replySource + self.replies = replies + self.reposts = reposts + self.likes = likes + self.content = content + } + } +} diff --git a/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift b/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift new file mode 100644 index 0000000..75f461a --- /dev/null +++ b/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift @@ -0,0 +1,56 @@ +import SwiftData +import LocalExtensions + +extension DatabaseSchema.V1 { + @Model + public final class UserModel: Equatable, Identifiable, Sendable { + @Attribute(.unique) + public var id: String + + @Attribute(.unique) + public var username: String + + public var password: Data + public var displayName: String + public var bio: String + public var avatarURL: URL? + + @Relationship(deleteRule: .cascade) + public var tweets: [TweetModel] + + @Relationship + public var likedTweets: [TweetModel] + + @Relationship(inverse: \UserModel.followers) + public var follows: [UserModel] + + @Relationship + public var followers: [UserModel] + + public init( + id: USID, + username: String, + password: Data, + displayName: String = "", + bio: String = "", + avatarURL: URL? = nil, + tweets: [TweetModel] = [], + likedTweets: [TweetModel] = [], + follows: [UserModel] = [], + followers: [UserModel] = [] + ) { + self.id = id.rawValue + self.username = username + self.password = password + self.displayName = displayName + self.bio = bio + self.avatarURL = avatarURL + self.tweets = tweets + self.likedTweets = likedTweets + self.follows = follows + self.followers = followers + } + } +} + + diff --git a/Example/Sources/DatabaseSchema/Versions/V1/V1.swift b/Example/Sources/DatabaseSchema/Versions/V1/V1.swift new file mode 100644 index 0000000..b785870 --- /dev/null +++ b/Example/Sources/DatabaseSchema/Versions/V1/V1.swift @@ -0,0 +1,14 @@ +import SwiftData +import LocalExtensions + +extension DatabaseSchema { + public enum V1: VersionedSchema { + public static let versionIdentifier: Schema.Version = .init(1, 0, 0) + public static var models: [any PersistentModel.Type] { + [ + TweetModel.self, + UserModel.self + ] + } + } +} diff --git a/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift new file mode 100644 index 0000000..9e32a95 --- /dev/null +++ b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift @@ -0,0 +1,68 @@ +import _ComposableArchitecture +import LocalExtensions +import AppModels +import TweetsListFeature + +@Reducer +public struct ExternalUserProfileFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var model: FollowerModel + public var tweetsList: TweetsListFeature.State + + @Presents + public var avatarPreview: URL? + + public init( + model: FollowerModel, + tweetsList: TweetsListFeature.State = .init() + ) { + self.model = model + self.tweetsList = tweetsList + } + } + + public enum Action: Equatable { + case avatarPreview(PresentationAction) + case tweetsList(TweetsListFeature.Action) + case openDetail(for: USID) + case openProfile(USID) + case tapOnAvatar + case tapFollow + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case .tapOnAvatar: + state.avatarPreview = state.model.user.avatarURL + return .none + + case .tapFollow: + state.model.isFollowedByYou.toggle() + return .none + + case let .tweetsList(.tweets(.element(_, .openDetail(id)))): + return .send(.openDetail(for: id)) + + case let .tweetsList(.tweets(.element(_, .openProfile(id)))): + return .send(.openProfile(id)) + + default: + return .none + } + } + .ifLet( + \State.$avatarPreview, + action: \.avatarPreview, + destination: {} + ) + Scope( + state: \.tweetsList, + action: \.tweetsList, + child: TweetsListFeature.init + ) + } +} diff --git a/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileView.swift b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileView.swift new file mode 100644 index 0000000..3bd8cdb --- /dev/null +++ b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileView.swift @@ -0,0 +1,61 @@ +import _ComposableArchitecture +import SwiftUI +import AppModels +import TweetsListFeature + +public struct ExternalUserProfileView: ComposableView { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView(.vertical) { + headerView + .padding(.vertical, 32) + Divider() + .padding(.bottom, 32) + TweetsListView(store.scope( + state: \.tweetsList, + action: \.tweetsList + )) + } + } + + @ViewBuilder + var headerView: some View { + VStack(spacing: 24) { + Circle() + .fill(Color(.label).opacity(0.3)) + .frame(width: 86, height: 86) + .onTapGesture { + store.send(.tapOnAvatar) + } + Text("@" + store.model.user.username.lowercased()) + .monospaced() + .bold() + Button(action: { store.send(.tapFollow) }) { + Text(store.model.isFollowedByYou ? "Unfollow" : "Follow") + } + } + } +} + +#Preview { + NavigationStack { + ExternalUserProfileView(Store( + initialState: .init( + model: .mock(), + tweetsList: .init(tweets: [ + .mock(), + .mock(), + .mock(), + .mock(), + .mock() + ]) + ), + reducer: ExternalUserProfileFeature.init + )) + } +} diff --git a/Example/Sources/FeedAndProfileFeature/FeedAndProfileViewController.swift b/Example/Sources/FeedAndProfileFeature/FeedAndProfileViewController.swift deleted file mode 100644 index 8b13789..0000000 --- a/Example/Sources/FeedAndProfileFeature/FeedAndProfileViewController.swift +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Example/Sources/FeedTabFeature/FeedTabController.swift b/Example/Sources/FeedTabFeature/FeedTabController.swift index eb5f2f2..d3c290d 100644 --- a/Example/Sources/FeedTabFeature/FeedTabController.swift +++ b/Example/Sources/FeedTabFeature/FeedTabController.swift @@ -2,7 +2,7 @@ import _ComposableArchitecture import CocoaExtensions import CombineExtensions import CombineNavigation -import ProfileFeature +import UserProfileFeature import TweetsFeedFeature @RoutingController @@ -13,7 +13,7 @@ public final class FeedTabController: ComposableViewControllerOf var feedControllers: [StackElementID: TweetsFeedController] @ComposableStackDestination({ _ in .init(rootView: nil) }) - var profileControllers: [StackElementID: ComposableHostingController] + var profileControllers: [StackElementID: ComposableHostingController] public override func viewDidLoad() { super.viewDidLoad() diff --git a/Example/Sources/FeedTabFeature/FeedTabFeature.swift b/Example/Sources/FeedTabFeature/FeedTabFeature.swift index 8c5605d..11dae24 100644 --- a/Example/Sources/FeedTabFeature/FeedTabFeature.swift +++ b/Example/Sources/FeedTabFeature/FeedTabFeature.swift @@ -1,5 +1,5 @@ import _ComposableArchitecture -import ProfileFeature +import UserProfileFeature import TweetsFeedFeature @Reducer @@ -10,12 +10,12 @@ public struct FeedTabFeature { public struct Path { public enum State: Equatable { case feed(TweetsFeedFeature.State) - case profile(ProfileFeature.State) + case profile(UserProfileFeature.State) } public enum Action: Equatable { case feed(TweetsFeedFeature.Action) - case profile(ProfileFeature.Action) + case profile(UserProfileFeature.Action) } public var body: some ReducerOf { @@ -27,7 +27,7 @@ public struct FeedTabFeature { Scope( state: /State.profile, action: /Action.profile, - child: ProfileFeature.init + child: UserProfileFeature.init ) } } @@ -63,27 +63,9 @@ public struct FeedTabFeature { case let .feed(.openProfile(id)), let .path(.element(_, action: .feed(.openProfile(id)))): - state.path.append(.profile(.user(.init(model: .mock(user: .mock(id: id)))))) + state.path.append(.profile(.external(.init(model: .mock(user: .mock(id: id)))))) return .none - case let .path(.element(stackID, action: .profile(profile))): - switch profile { - case .user(.tweetsList(.tweets(.element(_, .tap)))): - guard case let .profile(.user(profile)) = state.path[id: stackID] - else { return .none } - state.path.append(.feed(.init(list: profile.tweetsList))) - return .none - - case .currentUser(.tweetsList(.tweets(.element(_, .tap)))): - guard case let .profile(.currentUser(profile)) = state.path[id: stackID] - else { return .none } - state.path.append(.feed(.init(list: profile.tweetsList))) - return .none - - default: - return .none - } - default: return .none } diff --git a/Example/Sources/OnboardingFeature/File.swift b/Example/Sources/OnboardingFeature/File.swift new file mode 100644 index 0000000..9bc01ec --- /dev/null +++ b/Example/Sources/OnboardingFeature/File.swift @@ -0,0 +1,8 @@ +// +// File.swift +// +// +// Created by Maxim Krouk on 24.12.2023. +// + +import Foundation diff --git a/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift b/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift new file mode 100644 index 0000000..7cca49b --- /dev/null +++ b/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift @@ -0,0 +1,32 @@ +import _ComposableArchitecture +import TweetsFeedFeature +import UserProfileFeature + +@Reducer +public struct ProfileAndFeedPivot { + @ObservableState + public enum State: Equatable { + case feed(TweetsFeedFeature.State = .init()) + case profile(UserProfileFeature.State) + } + + public enum Action: Equatable { + case feed(TweetsFeedFeature.Action) + case profile(UserProfileFeature.Action) + } + + public init() {} + + public var body: some ReducerOf { + Scope( + state: /State.feed, + action: /Action.feed, + child: TweetsFeedFeature.init + ) + Scope( + state: /State.profile, + action: /Action.profile, + child: UserProfileFeature.init + ) + } +} diff --git a/Example/Sources/ProfileFeature/ProfileFeature.swift b/Example/Sources/ProfileFeature/ProfileFeature.swift deleted file mode 100644 index df39451..0000000 --- a/Example/Sources/ProfileFeature/ProfileFeature.swift +++ /dev/null @@ -1,33 +0,0 @@ -import _ComposableArchitecture -import UserProfileFeature -import CurrentUserProfileFeature - -@Reducer -public struct ProfileFeature { - public init() {} - - @ObservableState - public enum State: Equatable { - case user(UserProfileFeature.State) - case currentUser(CurrentUserProfileFeature.State) - } - - public enum Action: Equatable { - case user(UserProfileFeature.Action) - case currentUser(CurrentUserProfileFeature.Action) - } - - public var body: some ReducerOf { - EmptyReducer() - .ifCaseLet( - \.user, - action: \.user, - then: UserProfileFeature.init - ) - .ifCaseLet( - \.currentUser, - action: \.currentUser, - then: CurrentUserProfileFeature.init - ) - } -} diff --git a/Example/Sources/ProfileFeature/ProfileView.swift b/Example/Sources/ProfileFeature/ProfileView.swift deleted file mode 100644 index 0331a77..0000000 --- a/Example/Sources/ProfileFeature/ProfileView.swift +++ /dev/null @@ -1,50 +0,0 @@ -import _ComposableArchitecture -import SwiftUI -import AppModels -import UserProfileFeature -import CurrentUserProfileFeature - -public struct ProfileView: ComposableView { - let store: StoreOf - - public init(_ store: StoreOf) { - self.store = store - } - - public var body: some View { - SwitchStore(store) { state in - switch state { - case .user: - CaseLet( - /ProfileFeature.State.user, - action: ProfileFeature.Action.user, - then: UserProfileView.init - ) - case .currentUser: - CaseLet( - /ProfileFeature.State.currentUser, - action: ProfileFeature.Action.currentUser, - then: CurrentUserProfileView.init - ) - } - } - } -} - -#Preview { - NavigationStack { - ProfileView(Store( - initialState: .user(.init( - model: .mock(), - tweetsList: .init(tweets: [ - .mock(), - .mock(), - .mock(), - .mock(), - .mock() - ]) - )), - reducer: ProfileFeature.init - )) - } -} diff --git a/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift b/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift index 18405f0..9b97613 100644 --- a/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift +++ b/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift @@ -20,7 +20,7 @@ public struct ProfileFeedFeature { public enum Action: Equatable { case tweets(IdentifiedActionOf) - case openProfile(UUID) + case openProfile(USID) } public var body: some ReducerOf { diff --git a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift index 6abcbd7..4898ce4 100644 --- a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift +++ b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift @@ -1,47 +1,54 @@ import _ComposableArchitecture +import AuthFeature +import ProfileAndFeedPivot import UserProfileFeature import TweetsFeedFeature +import CurrentUserProfileFeature @Reducer public struct ProfileTabFeature { public init() {} + public typealias Path = ProfileAndFeedPivot + @Reducer - public struct Path: Reducer { + public struct Root { @ObservableState public enum State: Equatable { - case feed(TweetsFeedFeature.State) - case profile(UserProfileFeature.State) + case auth(AuthFeature.State = .signIn()) + case profile(CurrentUserProfileFeature.State) } public enum Action: Equatable { - case feed(TweetsFeedFeature.Action) - case profile(UserProfileFeature.Action) + case auth(AuthFeature.Action) + case profile(CurrentUserProfileFeature.Action) } public var body: some ReducerOf { Scope( - state: /State.feed, - action: /Action.feed, - child: TweetsFeedFeature.init + state: /State.auth, + action: /Action.auth, + child: AuthFeature.init ) Scope( state: /State.profile, action: /Action.profile, - child: UserProfileFeature.init + child: CurrentUserProfileFeature.init ) } } @ObservableState public struct State: Equatable { + public var root: Root.State public var path: StackState public init( - root: UserProfileFeature.State, + root: Root.State = .auth(), path: StackState = .init() ) { - self.path = [.profile(root)] + path + self.root = root + self.path = path } } @@ -54,15 +61,15 @@ public struct ProfileTabFeature { Reduce { state, action in switch action { case let .path(.element(_, action: .feed(.openProfile(id)))): - state.path.append(.profile(.init(model: .mock(user: .mock(id: id))))) + state.path.append(.profile(.external(.init(model: .mock(user: .mock(id: id)))))) return .none - case let .path(.element(stackID, .profile(.tweetsList(.tweets(.element(_, .tap)))))): - guard case let .profile(profile) = state.path[id: stackID] - else { return .none } - - state.path.append(.feed(.init(list: profile.tweetsList))) - return .none +// case let .path(.element(stackID, .profile(.user(.tweetsList(.tweets(.element(_, .tap))))))): +// guard case let .profile(.external(profile)) = state.path[id: stackID] +// else { return .none } +// +// state.path.append(.feed(.init(list: profile.tweetsList))) +// return .none default: return .none diff --git a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift index 5eb09a0..e267c88 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift @@ -1,8 +1,9 @@ import _ComposableArchitecture -import Foundation +import LocalExtensions import AppModels import TweetFeature import TweetsListFeature +import APIClient @Reducer public struct TweetDetailFeature { @@ -25,32 +26,18 @@ public struct TweetDetailFeature { self.replies = replies self.detail = detail } - - public static func collectMock( - for id: UUID - ) -> Self? { - TweetModel.mockTweets[id: id].map { source in - return .init( - source: .mock(model: source), - replies: .init( - tweets: IdentifiedArray( - uncheckedUniqueElements: TweetModel - .mockReplies(for: source.id) - .map { .mock(model: $0) } - ) - ) - ) - } - } } public enum Action: Equatable { case source(TweetFeature.Action) case replies(TweetsListFeature.Action) case detail(PresentationAction) - case openProfile(UUID) + case openProfile(USID) } + @Dependency(\.apiClient) + var apiClient + public var body: some ReducerOf { CombineReducers { Reduce { state, action in @@ -62,8 +49,7 @@ public struct TweetDetailFeature { return .send(.openProfile(id)) case let .replies(.openDetail(id)): - state.detail = .collectMock(for: id) - + #warning("Not handled") return .none default: diff --git a/Example/Sources/TweetFeature/TweetFeature.swift b/Example/Sources/TweetFeature/TweetFeature.swift index 2e410e2..bf37c06 100644 --- a/Example/Sources/TweetFeature/TweetFeature.swift +++ b/Example/Sources/TweetFeature/TweetFeature.swift @@ -1,5 +1,5 @@ import _ComposableArchitecture -import Foundation +import LocalExtensions import AppModels @Reducer @@ -8,19 +8,34 @@ public struct TweetFeature { @ObservableState public struct State: Equatable, Identifiable { - public var id: UUID - public var replyTo: UUID? + public var id: USID + public var replyTo: USID? + public var repliesCount: Int + public var isLiked: Bool + public var likesCount: Int + public var isReposted: Bool + public var repostsCount: Int public var author: UserModel public var text: String public init( - id: UUID, - replyTo: UUID? = nil, + id: USID, + replyTo: USID? = nil, + repliesCount: Int = 0, + isLiked: Bool = false, + likesCount: Int = 0, + isReposted: Bool = false, + repostsCount: Int = 0, author: UserModel, text: String ) { self.id = id self.replyTo = replyTo + self.repliesCount = repliesCount + self.isLiked = isLiked + self.likesCount = likesCount + self.isReposted = isReposted + self.repostsCount = repostsCount self.author = author self.text = text } @@ -37,8 +52,8 @@ public struct TweetFeature { } public static func mock( - id: UUID = .init(), - replyTo: UUID? = nil, + id: USID = .init(), + replyTo: USID? = nil, author: UserModel = .mock(), text: String = """ Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ @@ -55,7 +70,7 @@ public struct TweetFeature { } public func mockReply( - id: UUID = .init(), + id: USID = .init(), author: UserModel = .mock(), text: String = """ Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ @@ -75,8 +90,8 @@ public struct TweetFeature { public enum Action: Equatable { case tap case tapOnAuthor - case openDetail(for: UUID) - case openProfile(UUID) + case openDetail(for: USID) + case openProfile(USID) } public func reduce( diff --git a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift index 48c5428..5810d6f 100644 --- a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift @@ -1,5 +1,6 @@ import _ComposableArchitecture -import Foundation +import LocalExtensions +import APIClient import TweetsListFeature import TweetDetailFeature @@ -26,7 +27,7 @@ public struct TweetsFeedFeature { public enum Action: Equatable { case list(TweetsListFeature.Action) case detail(PresentationAction) - case openProfile(UUID) + case openProfile(USID) } public var body: some ReducerOf { @@ -36,11 +37,12 @@ public struct TweetsFeedFeature { let .list(.tweets(.element(_, .openProfile(id)))), let .detail(.presented(.openProfile(id))): return .send(.openProfile(id)) - + case let .list(.tweets(.element(itemID, .openDetail))): - state.detail = state.list.tweets[id: itemID].flatMap { tweet in - .collectMock(for: tweet.id) - } +// state.detail = state.list.tweets[id: itemID].flatMap { tweet in +// .collectMock(for: tweet.id) +// } + #warning("Not handled") return .none default: diff --git a/Example/Sources/TweetsListFeature/TweetsListFeature.swift b/Example/Sources/TweetsListFeature/TweetsListFeature.swift index b67cf2e..d35a7b4 100644 --- a/Example/Sources/TweetsListFeature/TweetsListFeature.swift +++ b/Example/Sources/TweetsListFeature/TweetsListFeature.swift @@ -1,5 +1,5 @@ import _ComposableArchitecture -import Foundation +import LocalExtensions import TweetFeature @Reducer @@ -17,8 +17,8 @@ public struct TweetsListFeature { public enum Action: Equatable { case tweets(IdentifiedActionOf) - case openDetail(for: UUID) - case openProfile(UUID) + case openDetail(for: USID) + case openProfile(USID) } public var body: some ReducerOf { diff --git a/Example/Sources/UserProfileFeature/UserProfileFeature.swift b/Example/Sources/UserProfileFeature/UserProfileFeature.swift index 20fa009..3aeb3a8 100644 --- a/Example/Sources/UserProfileFeature/UserProfileFeature.swift +++ b/Example/Sources/UserProfileFeature/UserProfileFeature.swift @@ -1,68 +1,33 @@ import _ComposableArchitecture -import Foundation -import AppModels -import TweetsListFeature +import ExternalUserProfileFeature +import CurrentUserProfileFeature @Reducer public struct UserProfileFeature { public init() {} @ObservableState - public struct State: Equatable { - public var model: FollowerModel - public var tweetsList: TweetsListFeature.State - - @Presents - public var avatarPreview: URL? - - public init( - model: FollowerModel, - tweetsList: TweetsListFeature.State = .init() - ) { - self.model = model - self.tweetsList = tweetsList - } + public enum State: Equatable { + case external(ExternalUserProfileFeature.State) + case current(CurrentUserProfileFeature.State) } public enum Action: Equatable { - case avatarPreview(PresentationAction) - case tweetsList(TweetsListFeature.Action) - case openDetail(for: UUID) - case openProfile(UUID) - case tapOnAvatar - case tapFollow + case external(ExternalUserProfileFeature.Action) + case current(CurrentUserProfileFeature.Action) } public var body: some ReducerOf { - Reduce { state, action in - switch action { - case .tapOnAvatar: - state.avatarPreview = state.model.user.avatarURL - return .none - - case .tapFollow: - state.model.isFollowedByYou.toggle() - return .none - - case let .tweetsList(.tweets(.element(_, .openDetail(id)))): - return .send(.openDetail(for: id)) - - case let .tweetsList(.tweets(.element(_, .openProfile(id)))): - return .send(.openProfile(id)) - - default: - return .none - } - } - .ifLet( - \State.$avatarPreview, - action: \.avatarPreview, - destination: {} - ) - Scope( - state: \.tweetsList, - action: \.tweetsList, - child: TweetsListFeature.init - ) + EmptyReducer() + .ifCaseLet( + \.external, + action: \.external, + then: ExternalUserProfileFeature.init + ) + .ifCaseLet( + \.current, + action: \.current, + then: CurrentUserProfileFeature.init + ) } } diff --git a/Example/Sources/UserProfileFeature/UserProfileView.swift b/Example/Sources/UserProfileFeature/UserProfileView.swift index 4ee599b..3ff6691 100644 --- a/Example/Sources/UserProfileFeature/UserProfileView.swift +++ b/Example/Sources/UserProfileFeature/UserProfileView.swift @@ -1,7 +1,8 @@ import _ComposableArchitecture import SwiftUI import AppModels -import TweetsListFeature +import ExternalUserProfileFeature +import CurrentUserProfileFeature public struct UserProfileView: ComposableView { let store: StoreOf @@ -11,33 +12,19 @@ public struct UserProfileView: ComposableView { } public var body: some View { - ScrollView(.vertical) { - headerView - .padding(.vertical, 32) - Divider() - .padding(.bottom, 32) - TweetsListView(store.scope( - state: \.tweetsList, - action: \.tweetsList - )) - } - } - - @ViewBuilder - var headerView: some View { - VStack(spacing: 24) { - Circle() - .fill(Color(.label).opacity(0.3)) - .frame(width: 86, height: 86) - .onTapGesture { - store.send(.tapOnAvatar) - } - Text("@" + store.model.user.username.lowercased()) - .monospaced() - .bold() - Button(action: { store.send(.tapFollow) }) { - Text(store.model.isFollowedByYou ? "Unfollow" : "Follow") - } + switch store.state { + case .external: + store.scope( + state: \.external, + action: \.external + ) + .map(ExternalUserProfileView.init) + case .current: + store.scope( + state: \.current, + action: \.current + ) + .map(CurrentUserProfileView.init) } } } @@ -45,7 +32,7 @@ public struct UserProfileView: ComposableView { #Preview { NavigationStack { UserProfileView(Store( - initialState: .init( + initialState: .external(.init( model: .mock(), tweetsList: .init(tweets: [ .mock(), @@ -54,7 +41,7 @@ public struct UserProfileView: ComposableView { .mock(), .mock() ]) - ), + )), reducer: UserProfileFeature.init )) } diff --git a/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift b/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift index 513c322..3a63ed9 100644 --- a/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift +++ b/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift @@ -1,5 +1,5 @@ import _ComposableArchitecture -import Foundation +import LocalExtensions @Reducer public struct UserSettingsFeature { @@ -7,10 +7,10 @@ public struct UserSettingsFeature { @ObservableState public struct State: Equatable, Identifiable { - public var id: UUID + public var id: USID public init( - id: UUID + id: USID ) { self.id = id } diff --git a/Example/Sources/UserSettingsFeature/UserSettingsView.swift b/Example/Sources/UserSettingsFeature/UserSettingsView.swift index 2c68f3e..94b69fc 100644 --- a/Example/Sources/UserSettingsFeature/UserSettingsView.swift +++ b/Example/Sources/UserSettingsFeature/UserSettingsView.swift @@ -9,7 +9,7 @@ public struct UserSettingsView: ComposableView { } public var body: some View { - Text(store.id.uuidString) + Text(store.id.usidString) } } diff --git a/Example/Sources/_Dependencies/_Dependencies/Exports.swift b/Example/Sources/_Dependencies/_Dependencies/Exports.swift new file mode 100644 index 0000000..08f44ac --- /dev/null +++ b/Example/Sources/_Dependencies/_Dependencies/Exports.swift @@ -0,0 +1 @@ +@_exported import Dependencies diff --git a/Example/Sources/_Extensions/LocalExtensions/Binding+Variable.swift b/Example/Sources/_Extensions/LocalExtensions/Binding+Variable.swift new file mode 100644 index 0000000..6e1c45b --- /dev/null +++ b/Example/Sources/_Extensions/LocalExtensions/Binding+Variable.swift @@ -0,0 +1,11 @@ +import SwiftUI + +extension Binding { + public static func variable(_ initialValue: Value) -> Binding { + var value = initialValue + return Binding( + get: { value }, + set: { value = $0 } + ) + } +} diff --git a/Example/Sources/_Extensions/LocalExtensions/Equated.Comparator+.swift b/Example/Sources/_Extensions/LocalExtensions/Equated.Comparator+.swift new file mode 100644 index 0000000..cabee36 --- /dev/null +++ b/Example/Sources/_Extensions/LocalExtensions/Equated.Comparator+.swift @@ -0,0 +1,14 @@ +import FoundationExtensions + +// TODO: Move to FoundationExtensions +extension Equated.Comparator { + public static func const(_ result: Bool) -> Self { + return .custom { _, _ in result } + } +} + +extension Equated where Value == Void { + public static var void: Self { + return .init((), by: .const(true)) + } +} diff --git a/Example/Sources/_Extensions/LocalExtensions/sha256.swift b/Example/Sources/_Extensions/LocalExtensions/sha256.swift new file mode 100644 index 0000000..5dc13ea --- /dev/null +++ b/Example/Sources/_Extensions/LocalExtensions/sha256.swift @@ -0,0 +1,41 @@ +import Foundation +import CommonCrypto + +extension Data { + public static func sha256( + _ string: String, + encoding: String.Encoding = .utf8 + ) -> Data? { + return string.data(using: encoding).flatMap(Self.sha256) + } + + public static func sha256(_ data: Data) -> Data { + let digestLength = Int(CC_SHA256_DIGEST_LENGTH) + var hash = [UInt8](repeating: 0, count: digestLength) + _ = data.withUnsafeBytes { (pointer: UnsafeRawBufferPointer) in + CC_SHA256(pointer.baseAddress, UInt32(data.count), &hash) + } + return Data(bytes: hash, count: digestLength) + } +} + +extension String { + public static func sha256( + _ string: String, + encoding: String.Encoding = .utf8 + ) -> String? { + return Data.sha256(string, encoding: encoding).flatMap(Self.hexString) + } + + public static func hexString(from data: Data) -> String { + var bytes = [UInt8](repeating: 0, count: data.count) + data.copyBytes(to: &bytes, count: data.count) + + var hexString = "" + for byte in bytes { + hexString += String(format:"%02x", UInt8(byte)) + } + + return hexString + } +} From c2e65ad213b6b8e5b3e1b265cb4e07c6baf5a912 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Fri, 29 Dec 2023 15:50:25 +0100 Subject: [PATCH 23/43] [wip] feat: Model improvements --- Example/Example.xcodeproj/project.pbxproj | 2 + .../xcshareddata/xcschemes/Example.xcscheme | 84 ++++ Example/Package.swift | 2 + .../Sources/APIClient/APIClient+Live.swift | 229 ++++++---- .../APIClient/Tweet/APIClient+Tweet.swift | 23 + .../APIClient/User/APIClient+User.swift | 2 +- .../AppFeature/Bootstrap/SceneDelegate.swift | 43 +- .../Convertions/ConvertibleModel.swift | 19 + Example/Sources/AppModels/Exports.swift | 23 + Example/Sources/AppModels/FollowerModel.swift | 32 -- Example/Sources/AppModels/TweetModel.swift | 55 +-- Example/Sources/AppModels/UserInfoModel.swift | 38 ++ Example/Sources/AppModels/UserModel.swift | 43 -- .../Sources/AppModels/_Mock/MockThreads.swift | 400 +++++++++--------- .../CurrentUserProfileFeature.swift | 4 +- .../CurrentUserProfileView.swift | 33 +- Example/Sources/DatabaseSchema/Database.swift | 31 ++ .../DatabaseSchema/DatabaseSchema.swift | 5 +- Example/Sources/DatabaseSchema/Exports.swift | 1 + .../Versions/V1/V1+TweetModel.swift | 2 +- .../Versions/V1/V1+UserModel.swift | 4 +- .../ExternalUserProfileFeature.swift | 6 +- .../ExternalUserProfileView.swift | 35 +- .../FeedTabFeature/FeedTabFeature.swift | 5 +- .../ProfileTabFeature/ProfileTabFeature.swift | 5 +- .../TweetDetailFeature.swift | 19 +- .../TweetDetailFeature/TweetDetailView.swift | 48 ++- .../Sources/TweetFeature/TweetFeature.swift | 93 ++-- Example/Sources/TweetFeature/TweetView.swift | 16 +- .../TweetsFeedFeature/TweetsFeedFeature.swift | 29 +- .../TweetsListFeature/TweetsListView.swift | 32 +- .../UserProfileFeature/UserProfileView.swift | 33 +- 32 files changed, 897 insertions(+), 499 deletions(-) create mode 100644 Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme create mode 100644 Example/Sources/AppModels/Convertions/ConvertibleModel.swift create mode 100644 Example/Sources/AppModels/Exports.swift delete mode 100644 Example/Sources/AppModels/FollowerModel.swift create mode 100644 Example/Sources/AppModels/UserInfoModel.swift delete mode 100644 Example/Sources/AppModels/UserModel.swift create mode 100644 Example/Sources/DatabaseSchema/Database.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index f22c563..edda605 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -289,6 +289,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Example/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -319,6 +320,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Example/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme b/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme new file mode 100644 index 0000000..f1e614e --- /dev/null +++ b/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Package.swift b/Example/Package.swift index 9d267dd..07faf8f 100644 --- a/Example/Package.swift +++ b/Example/Package.swift @@ -118,6 +118,7 @@ let package = Package( name: "AppModels", product: .library(.static), dependencies: [ + .dependency("_Dependencies"), .localExtensions ] ), @@ -239,6 +240,7 @@ let package = Package( name: "TweetDetailFeature", product: .library(.static), dependencies: [ + .target("APIClient"), .target("TweetFeature"), .target("TweetsListFeature"), .dependency("_ComposableArchitecture"), diff --git a/Example/Sources/APIClient/APIClient+Live.swift b/Example/Sources/APIClient/APIClient+Live.swift index 2d02f0a..afce926 100644 --- a/Example/Sources/APIClient/APIClient+Live.swift +++ b/Example/Sources/APIClient/APIClient+Live.swift @@ -4,38 +4,30 @@ import LocalExtensions import DatabaseSchema import AppModels -// Not sure if it's okay, just wanted to silence warnings -// this file is just mock implementation that uses local database -// for backend work simulation 😁 -extension ModelContext: @unchecked Sendable {} - -private let database = try! DatabaseSchema.createModelContext(.inMemory) - extension APIClient: DependencyKey { - public func _accessDatabase(_ operation: (ModelContext) -> Void) { - operation(database) - } - public static var liveValue: APIClient { - @Box + @Reference var currentUser: DatabaseSchema.UserModel? + @Dependency(\.currentUser) + var userIDContainer + + let trackedCurrentUser = _currentUser.onSet { user in + userIDContainer.id = user?.id.usid() + } + return .init( auth: .backendLike( - database: database, - currentUser: _currentUser + currentUser: trackedCurrentUser ), feed: .backendLike( - database: database, - currentUser: _currentUser + currentUser: trackedCurrentUser ), tweet: .backendLike( - database: database, - currentUser: _currentUser + currentUser: trackedCurrentUser ), user: .backendLike( - database: database, - currentUser: _currentUser + currentUser: trackedCurrentUser ) ) } @@ -59,7 +51,7 @@ private enum Errors { } struct UnauthorizedRequest: Swift.Error { - var localizedDesctiption: String { "Unauthirized access" } + var localizedDesctiption: String { "Unauthorized access" } } struct AuthenticationFailed: Swift.Error { @@ -69,16 +61,18 @@ private enum Errors { extension APIClient.Auth { static func backendLike( - database: ModelContext, - currentUser: Box + currentUser: Reference ) -> Self { .init( signIn: .init { input in - return Result { + @Dependency(\.database) + var database + + return await Result { let pwHash = try Data.sha256(input.password).unwrap().get() let username = input.username - guard let user = try database.fetch( + guard let user = try await database.context.fetch( DatabaseSchema.UserModel.self, #Predicate { model in model.username == username @@ -87,13 +81,16 @@ extension APIClient.Auth { ).first else { throw Errors.AuthenticationFailed() } - currentUser.content = user + currentUser.wrappedValue = user } }, signUp: .init { input in - return Result { + @Dependency(\.database) + var database + + return await Result { let username = input.username - let userExists = try database.fetch( + let userExists = try await database.context.fetch( DatabaseSchema.UserModel.self, #Predicate { $0.username == username } ).isNotEmpty @@ -109,13 +106,13 @@ extension APIClient.Auth { password: pwHash ) - database.insert(user) - try database.save() - currentUser.content = user + await database.context.insert(user) + try await database.context.save() + currentUser.wrappedValue = user } }, logout: .init { _ in - currentUser.content = nil + currentUser.wrappedValue = nil } ) } @@ -123,13 +120,15 @@ extension APIClient.Auth { extension APIClient.Feed { static func backendLike( - database: ModelContext, - currentUser: Box + currentUser: Reference ) -> Self { .init( fetchTweets: .init { input in - return Result { - try database.fetch( + @Dependency(\.database) + var database + + return await Result { + return try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.replySource == nil } ) @@ -138,14 +137,17 @@ extension APIClient.Feed { .map { tweet in return TweetModel( id: tweet.id.usid(), - authorID: tweet.author.id.usid(), + author: .init( + id: tweet.author.id.usid(), + username: tweet.author.username + ), replyTo: tweet.replySource?.id.usid(), repliesCount: tweet.replies.count, - isLiked: currentUser.content.map { user in + isLiked: currentUser.wrappedValue.map { user in tweet.replies.contains { $0 === user } }.or(false), likesCount: tweet.likes.count, - isReposted: currentUser.content.map(tweet.reposts.map(\.author).contains).or(false), + isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), repostsCount: tweet.reposts.count, text: tweet.content ) @@ -158,13 +160,45 @@ extension APIClient.Feed { extension APIClient.Tweet { static func backendLike( - database: ModelContext, - currentUser: Box + currentUser: Reference ) -> Self { .init( + fetch: .init { input in + @Dependency(\.database) + var database + + return await Result { + let tweetID = input.rawValue + guard let tweet = try await database.context.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw Errors.TweetDoesNotExist() } + + return TweetModel( + id: tweet.id.usid(), + author: .init( + id: tweet.author.id.usid(), + username: tweet.author.username + ), + replyTo: tweet.replySource?.id.usid(), + repliesCount: tweet.replies.count, + isLiked: currentUser.wrappedValue.map { user in + tweet.replies.contains { $0 === user } + }.or(false), + likesCount: tweet.likes.count, + isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), + repostsCount: tweet.reposts.count, + text: tweet.content + ) + } + }, like: .init { input in - return Result { - guard let user = currentUser.content + @Dependency(\.database) + var database + + return await Result { + guard let user = currentUser.wrappedValue else { throw Errors.UnauthenticatedRequest() } let shouldLike = input.value @@ -174,7 +208,7 @@ extension APIClient.Tweet { if shouldLike { let tweetID = input.id.rawValue - guard let tweet = try database.fetch( + guard let tweet = try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.id == tweetID } ).first @@ -185,31 +219,37 @@ extension APIClient.Tweet { user.likedTweets.removeAll { $0.id == input.id.rawValue } } - try database.save() + try await database.context.save() } }, post: .init { input in - return Result { - guard let user = currentUser.content + @Dependency(\.database) + var database + + return await Result { + guard let user = currentUser.wrappedValue else { throw Errors.UnauthenticatedRequest() } - database.insert(DatabaseSchema.TweetModel( + await database.context.insert(DatabaseSchema.TweetModel( id: USID(), createdAt: .now, author: user, content: input )) - try database.save() + try await database.context.save() } }, repost: .init { input in - return Result { - guard let user = currentUser.content + @Dependency(\.database) + var database + + return await Result { + guard let user = currentUser.wrappedValue else { throw Errors.UnauthenticatedRequest() } let tweetID = input.id.rawValue - guard let originalTweet = try database.fetch( + guard let originalTweet = try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.id == tweetID } ).first @@ -222,16 +262,19 @@ extension APIClient.Tweet { content: input.content )) - try database.save() + try await database.context.save() } }, reply: .init { input in - return Result { - guard let user = currentUser.content + @Dependency(\.database) + var database + + return await Result { + guard let user = currentUser.wrappedValue else { throw Errors.UnauthenticatedRequest() } let tweetID = input.id.rawValue - guard let originalTweet = try database.fetch( + guard let originalTweet = try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.id == tweetID } ).first @@ -244,15 +287,18 @@ extension APIClient.Tweet { content: input.content )) - try database.save() + try await database.context.save() } }, delete: .init { input in - return Result { - guard let user = currentUser.content + @Dependency(\.database) + var database + + return await Result { + guard let user = currentUser.wrappedValue else { throw Errors.UnauthenticatedRequest() } - guard let tweetToDelete = try database.fetch( + guard let tweetToDelete = try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.id == input.rawValue } ).first @@ -260,7 +306,7 @@ extension APIClient.Tweet { user.tweets.removeAll { $0 === tweetToDelete } - try database.save() + try await database.context.save() } }, report: .init { input in @@ -268,9 +314,12 @@ extension APIClient.Tweet { return .success(()) }, fetchReplies: .init { input in - return Result { + @Dependency(\.database) + var database + + return await Result { let tweetID = input.id.rawValue - guard let tweet = try database.fetch( + guard let tweet = try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.id == tweetID } ).first @@ -282,14 +331,17 @@ extension APIClient.Tweet { .map { tweet in return TweetModel( id: tweet.id.usid(), - authorID: tweet.author.id.usid(), + author: .init( + id: tweet.author.id.usid(), + username: tweet.author.username + ), replyTo: tweet.replySource?.id.usid(), repliesCount: tweet.replies.count, - isLiked: currentUser.content.map { user in + isLiked: currentUser.wrappedValue.map { user in tweet.replies.contains { $0 === user } }.or(false), likesCount: tweet.likes.count, - isReposted: currentUser.content.map(tweet.reposts.map(\.author).contains).or(false), + isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), repostsCount: tweet.reposts.count, text: tweet.content ) @@ -302,30 +354,39 @@ extension APIClient.Tweet { extension APIClient.User { static func backendLike( - database: ModelContext, - currentUser: Box + currentUser: Reference ) -> Self { .init( fetch: .init { id in - return Result { - guard let user = try database.fetch( + @Dependency(\.database) + var database + + return await Result { + guard let user = try await database.context.fetch( DatabaseSchema.UserModel.self, #Predicate { $0.id == id.rawValue } ).first else { throw Errors.UserDoesNotExist()} - return UserModel( + return UserInfoModel( id: user.id.usid(), username: user.username, displayName: user.displayName, bio: user.bio, - avatarURL: user.avatarURL + avatarURL: user.avatarURL, + isFollowingYou: currentUser.wrappedValue?.followers.contains { $0 === user } ?? false, + isFollowedByYou: user.followers.contains { $0 === currentUser.wrappedValue }, + followsCount: user.follows.count, + followersCount: user.followers.count ) } }, follow: .init { input in - return Result { - guard let user = currentUser.content + @Dependency(\.database) + var database + + return await Result { + guard let user = currentUser.wrappedValue else { throw Errors.UnauthenticatedRequest() } let shouldFollow = input.value @@ -335,7 +396,7 @@ extension APIClient.User { if shouldFollow { let userID = input.id.rawValue - guard let userToFollow = try database.fetch( + guard let userToFollow = try await database.context.fetch( DatabaseSchema.UserModel.self, #Predicate { $0.id == userID } ).first @@ -346,7 +407,7 @@ extension APIClient.User { user.follows.removeAll { $0.id == input.id.rawValue } } - try database.save() + try await database.context.save() } }, report: .init { input in @@ -354,9 +415,12 @@ extension APIClient.User { return .success(()) }, fetchTweets: .init { input in - return Result { + @Dependency(\.database) + var database + + return await Result { let userID = input.id.rawValue - guard let user = try database.fetch( + guard let user = try await database.context.fetch( DatabaseSchema.UserModel.self, #Predicate { $0.id == userID } ).first @@ -368,14 +432,17 @@ extension APIClient.User { .map { tweet in return TweetModel( id: tweet.id.usid(), - authorID: tweet.author.id.usid(), + author: .init( + id: tweet.author.id.usid(), + username: tweet.author.username + ), replyTo: tweet.replySource?.id.usid(), repliesCount: tweet.replies.count, - isLiked: currentUser.content.map { user in + isLiked: currentUser.wrappedValue.map { user in tweet.replies.contains { $0 === user } }.or(false), likesCount: tweet.likes.count, - isReposted: currentUser.content.map(tweet.reposts.map(\.author).contains).or(false), + isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), repostsCount: tweet.reposts.count, text: tweet.content ) diff --git a/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift b/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift index 1c578ea..b2b299d 100644 --- a/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift +++ b/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift @@ -4,6 +4,7 @@ import AppModels extension APIClient { public struct Tweet { public init( + fetch: Operations.Fetch, like: Operations.Like, post: Operations.Post, repost: Operations.Repost, @@ -12,6 +13,7 @@ extension APIClient { report: Operations.Report, fetchReplies: Operations.FetchReplies ) { + self.fetch = fetch self.like = like self.post = post self.repost = repost @@ -21,6 +23,7 @@ extension APIClient { self.fetchReplies = fetchReplies } + public var fetch: Operations.Fetch public var like: Operations.Like public var post: Operations.Post public var repost: Operations.Repost @@ -36,6 +39,26 @@ extension APIClient.Tweet { } extension APIClient.Tweet.Operations { + public struct Fetch { + public typealias Input = USID + + public typealias Output = Result + + public typealias AsyncSignature = @Sendable (Input) async -> Output + + public var asyncCall: AsyncSignature + + public init(_ asyncCall: @escaping AsyncSignature) { + self.asyncCall = asyncCall + } + + public func callAsFunction( + id: USID + ) async -> Output { + await asyncCall(id) + } + } + public struct Like { public typealias Input = ( id: USID, diff --git a/Example/Sources/APIClient/User/APIClient+User.swift b/Example/Sources/APIClient/User/APIClient+User.swift index 26ba4db..e7a17f6 100644 --- a/Example/Sources/APIClient/User/APIClient+User.swift +++ b/Example/Sources/APIClient/User/APIClient+User.swift @@ -30,7 +30,7 @@ extension APIClient.User.Operations { public struct Fetch { public typealias Input = USID - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output diff --git a/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift index a24a75c..63efde0 100644 --- a/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift +++ b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift @@ -15,11 +15,17 @@ import LocalExtensions public class SceneDelegate: UIResponder, UIWindowSceneDelegate { public var window: UIWindow? + @Dependency(\.database) + var database + @Dependency(\.apiClient) var apiClient - func prefillDatabase() { - apiClient._accessDatabase { database in + func setupStore(_ callback: @escaping (StoreOf) -> Void) { + Task { @MainActor in + do { + let modelContext = await database.context + let currentUser = DatabaseSchema.UserModel( id: USID(), username: "capturecontext", @@ -29,18 +35,27 @@ public class SceneDelegate: UIResponder, UIWindowSceneDelegate { let tweet = DatabaseSchema.TweetModel( id: USID(), author: currentUser, - content: "Hello, World!" + content: "Hello, First World!" ) - database.insert(tweet) + let reply1 = DatabaseSchema.TweetModel( + id: USID(), + author: currentUser, + replySource: tweet, + content: "Hello, Second World!" + ) - try! database.save() - } - } + let reply2 = DatabaseSchema.TweetModel( + id: USID(), + author: currentUser, + replySource: reply1, + content: "Hello, Third World!" + ) + + modelContext.insert(reply2) + + try! modelContext.save() - func setupStore(_ callback: @escaping (StoreOf) -> Void) { - Task { @MainActor in - do { _ = try await self.apiClient.auth.signIn(username: "capturecontext", password: "psswrd").get() let tweets = try await self.apiClient.feed.fetchTweets(page: 0, limit: 3).get() @@ -51,8 +66,7 @@ public class SceneDelegate: UIResponder, UIWindowSceneDelegate { list: .init( tweets: .init( uncheckedUniqueElements: - // TweetModel.mockTweets.filter(\.replyTo.isNil).map { .mock(model: $0) } - tweets.map { .mock(model: $0) } + tweets.map { $0.convert(to: .tweetFeature) } ) ) ) @@ -61,7 +75,8 @@ public class SceneDelegate: UIResponder, UIWindowSceneDelegate { selectedTab: .feed ), reducer: { - MainFeature()._printChanges() + MainFeature() + ._printChanges() } )) } catch { @@ -80,8 +95,6 @@ public class SceneDelegate: UIResponder, UIWindowSceneDelegate { guard let scene = scene as? UIWindowScene else { return } let controller = MainViewController() - prefillDatabase() - setupStore { store in self.store = store controller.setStore(store) diff --git a/Example/Sources/AppModels/Convertions/ConvertibleModel.swift b/Example/Sources/AppModels/Convertions/ConvertibleModel.swift new file mode 100644 index 0000000..aeb1f61 --- /dev/null +++ b/Example/Sources/AppModels/Convertions/ConvertibleModel.swift @@ -0,0 +1,19 @@ +public protocol ConvertibleModel {} + +extension ConvertibleModel { + public func convert(to convertion: Convertion) -> Value { + return convertion.convert(self) + } +} + +public struct Convertion { + private let _convert: (From) -> To + + public init(_ convert: @escaping (From) -> To) { + self._convert = convert + } + + func convert(_ value: From) -> To { + return _convert(value) + } +} diff --git a/Example/Sources/AppModels/Exports.swift b/Example/Sources/AppModels/Exports.swift new file mode 100644 index 0000000..606d2b6 --- /dev/null +++ b/Example/Sources/AppModels/Exports.swift @@ -0,0 +1,23 @@ +import _Dependencies +import LocalExtensions + +extension DependencyValues { + public var currentUser: CurrentUserIDContainer { + get { self[CurrentUserIDContainer.self] } + set { self[CurrentUserIDContainer.self] = newValue } + } +} + +public struct CurrentUserIDContainer { + @Reference + public var id: USID? + + public init(id: USID? = nil) { + self.id = id + } +} + +extension CurrentUserIDContainer: DependencyKey { + public static var liveValue: CurrentUserIDContainer { .init() } + public static var previewValue: CurrentUserIDContainer { .init() } +} diff --git a/Example/Sources/AppModels/FollowerModel.swift b/Example/Sources/AppModels/FollowerModel.swift deleted file mode 100644 index 9f8c786..0000000 --- a/Example/Sources/AppModels/FollowerModel.swift +++ /dev/null @@ -1,32 +0,0 @@ -import LocalExtensions - -public struct FollowerModel: Equatable, Identifiable { - public var id: USID { user.id } - public var user: UserModel - public var isFollowingYou: Bool - public var isFollowedByYou: Bool - - public init( - user: UserModel, - isFollowingYou: Bool = false, - isFollowedByYou: Bool = false - ) { - self.user = user - self.isFollowingYou = isFollowingYou - self.isFollowedByYou = isFollowedByYou - } -} - -extension FollowerModel { - public static func mock( - user: UserModel = .mock(), - isFollowingYou: Bool = false, - isFollowedByYou: Bool = false - ) -> Self { - .init( - user: user, - isFollowingYou: isFollowingYou, - isFollowedByYou: isFollowedByYou - ) - } -} diff --git a/Example/Sources/AppModels/TweetModel.swift b/Example/Sources/AppModels/TweetModel.swift index 6c85012..36462a5 100644 --- a/Example/Sources/AppModels/TweetModel.swift +++ b/Example/Sources/AppModels/TweetModel.swift @@ -1,8 +1,24 @@ import LocalExtensions -public struct TweetModel: Equatable, Identifiable, Codable { +public struct TweetModel: Equatable, Identifiable, Codable, ConvertibleModel { + public struct AuthorModel: Equatable, Identifiable, Codable, ConvertibleModel { + public var id: USID + public var avatarURL: URL? + public var username: String + + public init( + id: USID, + avatarURL: URL? = nil, + username: String + ) { + self.id = id + self.avatarURL = avatarURL + self.username = username + } + } + public var id: USID - public var authorID: USID + public var author: AuthorModel public var replyTo: USID? public var repliesCount: Int public var isLiked: Bool @@ -13,7 +29,7 @@ public struct TweetModel: Equatable, Identifiable, Codable { public init( id: USID, - authorID: USID, + author: AuthorModel, replyTo: USID? = nil, repliesCount: Int = 0, isLiked: Bool = false, @@ -23,7 +39,7 @@ public struct TweetModel: Equatable, Identifiable, Codable { text: String ) { self.id = id - self.authorID = authorID + self.author = author self.replyTo = replyTo self.repliesCount = repliesCount self.isLiked = isLiked @@ -33,34 +49,3 @@ public struct TweetModel: Equatable, Identifiable, Codable { self.text = text } } - -extension TweetModel { - public static func mock( - id: USID = .init(), - authorID: USID = UserModel.mock().id, - replyTo: USID? = nil, - text: String = """ - Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ - Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ - In ad laboris labore irure ea ea officia. - """ - ) -> TweetModel { - .init( - id: id, - authorID: authorID, - replyTo: replyTo, - text: text - ) - } - - public func withReplies( - @TweetModelsBuilder replies: (TweetModel) -> [TweetModel] - ) -> [TweetModel] { - [self] + replies(self) - } -} - -@resultBuilder -public struct TweetModelsBuilder: ArrayBuilderProtocol { - public typealias Element = TweetModel -} diff --git a/Example/Sources/AppModels/UserInfoModel.swift b/Example/Sources/AppModels/UserInfoModel.swift new file mode 100644 index 0000000..9e2246c --- /dev/null +++ b/Example/Sources/AppModels/UserInfoModel.swift @@ -0,0 +1,38 @@ +import LocalExtensions + +public struct UserInfoModel: Equatable, Identifiable, ConvertibleModel { + public var id: USID + public var username: String + public var displayName: String + public var bio: String + public var avatarURL: URL? + public var isFollowingYou: Bool + public var isFollowedByYou: Bool + public var followsCount: Int + public var followersCount: Int + public var tweetsCount: Int + + public init( + id: USID, + username: String, + displayName: String = "", + bio: String = "", + avatarURL: URL? = nil, + isFollowingYou: Bool = false, + isFollowedByYou: Bool = false, + followsCount: Int = 0, + followersCount: Int = 0, + tweetsCount: Int = 0 + ) { + self.id = id + self.username = username + self.displayName = displayName + self.bio = bio + self.avatarURL = avatarURL + self.isFollowingYou = isFollowingYou + self.isFollowedByYou = isFollowedByYou + self.followsCount = followsCount + self.followersCount = followersCount + self.tweetsCount = tweetsCount + } +} diff --git a/Example/Sources/AppModels/UserModel.swift b/Example/Sources/AppModels/UserModel.swift deleted file mode 100644 index 55dbf3c..0000000 --- a/Example/Sources/AppModels/UserModel.swift +++ /dev/null @@ -1,43 +0,0 @@ -import LocalExtensions - -public struct UserModel: Equatable, Identifiable { - public var id: USID - public var username: String - public var displayName: String - public var bio: String - public var avatarURL: URL? - - public init( - id: USID, - username: String, - displayName: String = "", - bio: String = "", - avatarURL: URL? = nil - ) { - self.id = id - self.username = username - self.displayName = displayName - self.bio = bio - self.avatarURL = avatarURL - } -} - -extension UserModel { - static var mockCacheByID: [USID: UserModel] = [:] - static var mockCacheByUsername: [String: UserModel] = [:] - - public static func mock( - id: USID = .init(), - username: String = "username", - avatarURL: URL? = nil - ) -> Self { - let user = mockCacheByID[id] ?? mockCacheByUsername[username] ?? UserModel( - id: id, - username: username, - avatarURL: avatarURL - ) - mockCacheByID[user.id] = user - mockCacheByUsername[user.username] = user - return user - } -} diff --git a/Example/Sources/AppModels/_Mock/MockThreads.swift b/Example/Sources/AppModels/_Mock/MockThreads.swift index 23c29d1..53fc49d 100644 --- a/Example/Sources/AppModels/_Mock/MockThreads.swift +++ b/Example/Sources/AppModels/_Mock/MockThreads.swift @@ -1,200 +1,200 @@ -import LocalExtensions - -extension TweetModel { - public static func mockReplies( - for id: USID - ) -> IdentifiedArrayOf { - mockTweets[id: id].map { source in - mockTweets.filter { $0.replyTo == source.id } - }.or([]) - } - - public static let mockTweets: IdentifiedArrayOf = .init(uniqueElements: [ - TweetModel.mock( - authorID: UserModel.mock(username: "JohnDoe").id, - text: "Hello, world!" - ).withReplies { model in - TweetModel.mock( - authorID: UserModel.mock(username: "JaneDoe").id, - replyTo: model.id, - text: "Hello, John!" - ) - TweetModel.mock( - authorID: UserModel.mock(username: "Alice").id, - replyTo: model.id, - text: "Nice weather today." - ) - TweetModel.mock( - authorID: UserModel.mock(username: "Bob").id, - replyTo: model.id, - text: "Agree with you, Alice." - ) - TweetModel.mock( - authorID: UserModel.mock(username: "Charlie").id, - replyTo: model.id, - text: "Looking forward to the weekend." - ).withReplies { model in - TweetModel.mock( - authorID: UserModel.mock(username: "Emma").id, - replyTo: model.id, - text: "Me too, Charlie!" - ) - TweetModel.mock( - authorID: UserModel.mock(username: "Oliver").id, - replyTo: model.id, - text: "Same here." - ) - } - TweetModel.mock( - authorID: UserModel.mock(username: "Sophia").id, - replyTo: model.id, - text: "Have a nice day, everyone!" - ) - }, - TweetModel.mock( - authorID: UserModel.mock(username: "Mike").id, - text: "Let's discuss our favorite movies!" - ).withReplies { model in - TweetModel.mock( - authorID: UserModel.mock(username: "Lucy").id, - replyTo: model.id, - text: "I love Titanic." - ) - TweetModel.mock( - authorID: UserModel.mock(username: "Sam").id, - replyTo: model.id, - text: "The Shawshank Redemption is the best!" - ).withReplies { innerModel in - TweetModel.mock( - authorID: UserModel.mock(username: "Tom").id, - replyTo: innerModel.id, - text: "Indeed, it's a touching story." - ) - TweetModel.mock( - authorID: UserModel.mock(username: "EmmaJ").id, - replyTo: innerModel.id, - text: "I was moved to tears by that movie." - ) - } - }, - TweetModel.mock( - authorID: UserModel.mock(username: "Olivia").id, - text: "Crowd-sourcing the best books!" - ).withReplies { model in - for i in 1...10 { - TweetModel.mock( - authorID: UserModel.mock(username: "User\(i)").id, - replyTo: model.id, - text: "Book suggestion #\(i)." - ) - } - }, - TweetModel.mock( - authorID: UserModel.mock(username: "Harry").id, - text: "Who's following the basketball championship?" - ).withReplies { model in - TweetModel.mock( - authorID: UserModel.mock(username: "Nina").id, - replyTo: model.id, - text: "Wouldn't miss it for the world!" - ).withReplies { innerModel in - TweetModel.mock( - authorID: UserModel.mock(username: "Rihanna").id, - replyTo: innerModel.id, - text: "Same here!" - ).withReplies { innerMostModel in - TweetModel.mock( - authorID: UserModel.mock(username: "George").id, - replyTo: innerMostModel.id, - text: "Go Lakers!" - ) - } - } - TweetModel.mock( - authorID: UserModel.mock(username: "Drake").id, - replyTo: model.id, - text: "I'll be at the final game!" - ) - }, - TweetModel.mock( - authorID: UserModel.mock(username: "ElonMusk").id, - text: "Exploring Mars: What are the most significant challenges we're looking to overcome?" - ).withReplies { model in - TweetModel.mock( - authorID: UserModel.mock(username: "AstroJane").id, - replyTo: model.id, - text: "I believe overcoming the harsh weather conditions is a major challenge." - ).withReplies { innerModel in - TweetModel.mock( - authorID: UserModel.mock(username: "ScienceMike").id, - replyTo: innerModel.id, - text: "Absolutely, the extreme cold and dust storms are definitely obstacles." - ) - } - }, - TweetModel.mock( - authorID: UserModel.mock(username: "BillGates").id, - text: "How can technology further help in improving education globally?" - ).withReplies { model in - for i in 1...5 { - TweetModel.mock( - authorID: UserModel.mock(username: "EdTechExpert\(i)").id, - replyTo: model.id, - text: "I think technology #\(i) would greatly improve global education." - ) - } - }, - TweetModel.mock( - authorID: UserModel.mock(username: "TaylorSwift").id, - text: "New album release next month! What themes do you guys hope to hear?" - ).withReplies { model in - TweetModel.mock( - authorID: UserModel.mock(username: "Fan1").id, - replyTo: model.id, - text: "I hope to hear some songs about moving on and finding oneself." - ) - TweetModel.mock( - authorID: UserModel.mock(username: "Fan2").id, - replyTo: model.id, - text: "Can't wait for love songs!" - ).withReplies { innerModel in - TweetModel.mock( - authorID: UserModel.mock(username: "Fan3").id, - replyTo: innerModel.id, - text: "Yes, her love songs always hit differently." - ) - } - }, - TweetModel.mock( - authorID: UserModel.mock(username: "ChefGordon").id, - text: "What's your all-time favorite recipe?" - ).withReplies { model in - TweetModel.mock( - authorID: UserModel.mock(username: "FoodieSam").id, - replyTo: model.id, - text: "I love a classic spaghetti carbonara. Simple, yet so delicious." - ) - TweetModel.mock( - authorID: UserModel.mock(username: "CulinaryMaster").id, - replyTo: model.id, - text: "Can't go wrong with a perfectly cooked steak." - ) - }, - TweetModel.mock( - authorID: UserModel.mock(username: "CryptoExpert").id, - text: "What's everyone's prediction for Bitcoin for the next year?" - ).withReplies { model in - TweetModel.mock( - authorID: UserModel.mock(username: "BitcoinBull").id, - replyTo: model.id, - text: "I foresee a great year ahead for Bitcoin. Hold on to what you've got!" - ).withReplies { innerModel in - TweetModel.mock( - authorID: UserModel.mock(username: "CryptoSkeptic").id, - replyTo: innerModel.id, - text: "I'm not so certain. It's wise to diversify and not put all your eggs in one basket." - ) - } - } - ].flatMap { $0 }) -} +//import LocalExtensions +// +//extension TweetModel { +// public static func mockReplies( +// for id: USID +// ) -> IdentifiedArrayOf { +// mockTweets[id: id].map { source in +// mockTweets.filter { $0.replyTo == source.id } +// }.or([]) +// } +// +// public static let mockTweets: IdentifiedArrayOf = .init(uniqueElements: [ +// TweetModel.mock( +// author: UserModel.mock(username: "JohnDoe"), +// text: "Hello, world!" +// ).withReplies { model in +// TweetModel.mock( +// author: UserModel.mock(username: "JaneDoe"), +// replyTo: model.id, +// text: "Hello, John!" +// ) +// TweetModel.mock( +// author: UserModel.mock(username: "Alice"), +// replyTo: model.id, +// text: "Nice weather today." +// ) +// TweetModel.mock( +// author: UserModel.mock(username: "Bob"), +// replyTo: model.id, +// text: "Agree with you, Alice." +// ) +// TweetModel.mock( +// author: UserModel.mock(username: "Charlie"), +// replyTo: model.id, +// text: "Looking forward to the weekend." +// ).withReplies { model in +// TweetModel.mock( +// author: UserModel.mock(username: "Emma"), +// replyTo: model.id, +// text: "Me too, Charlie!" +// ) +// TweetModel.mock( +// author: UserModel.mock(username: "Oliver"), +// replyTo: model.id, +// text: "Same here." +// ) +// } +// TweetModel.mock( +// author: UserModel.mock(username: "Sophia"), +// replyTo: model.id, +// text: "Have a nice day, everyone!" +// ) +// }, +// TweetModel.mock( +// author: UserModel.mock(username: "Mike"), +// text: "Let's discuss our favorite movies!" +// ).withReplies { model in +// TweetModel.mock( +// author: UserModel.mock(username: "Lucy"), +// replyTo: model.id, +// text: "I love Titanic." +// ) +// TweetModel.mock( +// author: UserModel.mock(username: "Sam"), +// replyTo: model.id, +// text: "The Shawshank Redemption is the best!" +// ).withReplies { innerModel in +// TweetModel.mock( +// author: UserModel.mock(username: "Tom"), +// replyTo: innerModel.id, +// text: "Indeed, it's a touching story." +// ) +// TweetModel.mock( +// author: UserModel.mock(username: "EmmaJ"), +// replyTo: innerModel.id, +// text: "I was moved to tears by that movie." +// ) +// } +// }, +// TweetModel.mock( +// author: UserModel.mock(username: "Olivia"), +// text: "Crowd-sourcing the best books!" +// ).withReplies { model in +// for i in 1...10 { +// TweetModel.mock( +// author: UserModel.mock(username: "User\(i)"), +// replyTo: model.id, +// text: "Book suggestion #\(i)." +// ) +// } +// }, +// TweetModel.mock( +// author: UserModel.mock(username: "Harry"), +// text: "Who's following the basketball championship?" +// ).withReplies { model in +// TweetModel.mock( +// author: UserModel.mock(username: "Nina"), +// replyTo: model.id, +// text: "Wouldn't miss it for the world!" +// ).withReplies { innerModel in +// TweetModel.mock( +// author: UserModel.mock(username: "Rihanna"), +// replyTo: innerModel.id, +// text: "Same here!" +// ).withReplies { innerMostModel in +// TweetModel.mock( +// author: UserModel.mock(username: "George"), +// replyTo: innerMostModel.id, +// text: "Go Lakers!" +// ) +// } +// } +// TweetModel.mock( +// author: UserModel.mock(username: "Drake"), +// replyTo: model.id, +// text: "I'll be at the final game!" +// ) +// }, +// TweetModel.mock( +// author: UserModel.mock(username: "ElonMusk"), +// text: "Exploring Mars: What are the most significant challenges we're looking to overcome?" +// ).withReplies { model in +// TweetModel.mock( +// author: UserModel.mock(username: "AstroJane"), +// replyTo: model.id, +// text: "I believe overcoming the harsh weather conditions is a major challenge." +// ).withReplies { innerModel in +// TweetModel.mock( +// author: UserModel.mock(username: "ScienceMike"), +// replyTo: innerModel.id, +// text: "Absolutely, the extreme cold and dust storms are definitely obstacles." +// ) +// } +// }, +// TweetModel.mock( +// author: UserModel.mock(username: "BillGates"), +// text: "How can technology further help in improving education globally?" +// ).withReplies { model in +// for i in 1...5 { +// TweetModel.mock( +// author: UserModel.mock(username: "EdTechExpert\(i)"), +// replyTo: model.id, +// text: "I think technology #\(i) would greatly improve global education." +// ) +// } +// }, +// TweetModel.mock( +// author: UserModel.mock(username: "TaylorSwift"), +// text: "New album release next month! What themes do you guys hope to hear?" +// ).withReplies { model in +// TweetModel.mock( +// author: UserModel.mock(username: "Fan1"), +// replyTo: model.id, +// text: "I hope to hear some songs about moving on and finding oneself." +// ) +// TweetModel.mock( +// author: UserModel.mock(username: "Fan2"), +// replyTo: model.id, +// text: "Can't wait for love songs!" +// ).withReplies { innerModel in +// TweetModel.mock( +// author: UserModel.mock(username: "Fan3"), +// replyTo: innerModel.id, +// text: "Yes, her love songs always hit differently." +// ) +// } +// }, +// TweetModel.mock( +// author: UserModel.mock(username: "ChefGordon"), +// text: "What's your all-time favorite recipe?" +// ).withReplies { model in +// TweetModel.mock( +// author: UserModel.mock(username: "FoodieSam"), +// replyTo: model.id, +// text: "I love a classic spaghetti carbonara. Simple, yet so delicious." +// ) +// TweetModel.mock( +// author: UserModel.mock(username: "CulinaryMaster"), +// replyTo: model.id, +// text: "Can't go wrong with a perfectly cooked steak." +// ) +// }, +// TweetModel.mock( +// author: UserModel.mock(username: "CryptoExpert"), +// text: "What's everyone's prediction for Bitcoin for the next year?" +// ).withReplies { model in +// TweetModel.mock( +// author: UserModel.mock(username: "BitcoinBull"), +// replyTo: model.id, +// text: "I foresee a great year ahead for Bitcoin. Hold on to what you've got!" +// ).withReplies { innerModel in +// TweetModel.mock( +// author: UserModel.mock(username: "CryptoSkeptic"), +// replyTo: innerModel.id, +// text: "I'm not so certain. It's wise to diversify and not put all your eggs in one basket." +// ) +// } +// } +// ].flatMap { $0 }) +//} diff --git a/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift index c495bfc..aa13e3b 100644 --- a/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift +++ b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift @@ -37,14 +37,14 @@ public struct CurrentUserProfileFeature { @ObservableState public struct State: Equatable { - public var model: UserModel + public var model: UserInfoModel public var tweetsList: TweetsListFeature.State @Presents public var destination: Destination.State? public init( - model: UserModel, + model: UserInfoModel, tweetsList: TweetsListFeature.State = .init(), destination: Destination.State? = nil ) { diff --git a/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileView.swift b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileView.swift index 7dbc804..49cb75c 100644 --- a/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileView.swift +++ b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileView.swift @@ -61,13 +61,34 @@ public struct CurrentUserProfileView: ComposableView { NavigationStack { CurrentUserProfileView(Store( initialState: .init( - model: .mock(), + model: .init( + id: .init(), + username: "capturecontext", + displayName: "CaptureContext", + bio: "SwiftData kinda sucks", + avatarURL: nil, + isFollowingYou: false, + isFollowedByYou: false, + followsCount: 69, + followersCount: 1123927, + tweetsCount: 1 + ), tweetsList: .init(tweets: [ - .mock(), - .mock(), - .mock(), - .mock(), - .mock() + .init( + id: .init(), + replyTo: nil, + repliesCount: 3, + isLiked: true, + likesCount: 999, + isReposted: false, + repostsCount: 0, + author: .init( + id: .init(), + avatarURL: nil, + username: "capturecontext" + ), + text: "Hello, World!" + ) ]) ), reducer: CurrentUserProfileFeature.init diff --git a/Example/Sources/DatabaseSchema/Database.swift b/Example/Sources/DatabaseSchema/Database.swift new file mode 100644 index 0000000..1ab5f0a --- /dev/null +++ b/Example/Sources/DatabaseSchema/Database.swift @@ -0,0 +1,31 @@ +import _Dependencies + +public actor Database: Sendable { + public let context: ModelContext + + public init(context: ModelContext) { + self.context = context + } +} + +// Not sure if it's okay, just wanted to silence warnings +// this file is just mock implementation that uses local database +// for backend work simulation 😁 +extension ModelContext: @unchecked Sendable {} + +extension Database: DependencyKey { + public static var liveValue: Database { + try! .init(context: DatabaseSchema.createModelContext(.file())) + } + + public static var previewValue: Database { + try! .init(context: DatabaseSchema.createModelContext(.inMemory)) + } +} + +extension DependencyValues { + public var database: Database { + get { self[Database.self] } + set { self[Database.self] = newValue } + } +} diff --git a/Example/Sources/DatabaseSchema/DatabaseSchema.swift b/Example/Sources/DatabaseSchema/DatabaseSchema.swift index 6143bc5..402afff 100644 --- a/Example/Sources/DatabaseSchema/DatabaseSchema.swift +++ b/Example/Sources/DatabaseSchema/DatabaseSchema.swift @@ -1,6 +1,5 @@ +import _Dependencies + public enum DatabaseSchema { public typealias Current = V1 } - -import SwiftData -import LocalExtensions diff --git a/Example/Sources/DatabaseSchema/Exports.swift b/Example/Sources/DatabaseSchema/Exports.swift index b20524f..94fd61c 100644 --- a/Example/Sources/DatabaseSchema/Exports.swift +++ b/Example/Sources/DatabaseSchema/Exports.swift @@ -1,5 +1,6 @@ @_exported import SwiftData import Foundation +import _Dependencies extension DatabaseSchema { public typealias TweetModel = Current.TweetModel diff --git a/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift b/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift index 025d693..a6a3608 100644 --- a/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift +++ b/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift @@ -5,7 +5,7 @@ extension DatabaseSchema.V1 { @Model public final class TweetModel: Equatable, Identifiable, Sendable { @Attribute(.unique) - public var id: String + public let id: String public var createdAt: Date @Relationship(inverse: \UserModel.tweets) diff --git a/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift b/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift index 75f461a..197d271 100644 --- a/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift +++ b/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift @@ -3,9 +3,9 @@ import LocalExtensions extension DatabaseSchema.V1 { @Model - public final class UserModel: Equatable, Identifiable, Sendable { + public final class UserModel: Equatable, Identifiable, @unchecked Sendable { @Attribute(.unique) - public var id: String + public let id: String @Attribute(.unique) public var username: String diff --git a/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift index 9e32a95..abd40a4 100644 --- a/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift +++ b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift @@ -9,14 +9,14 @@ public struct ExternalUserProfileFeature { @ObservableState public struct State: Equatable { - public var model: FollowerModel + public var model: UserInfoModel public var tweetsList: TweetsListFeature.State @Presents public var avatarPreview: URL? public init( - model: FollowerModel, + model: UserInfoModel, tweetsList: TweetsListFeature.State = .init() ) { self.model = model @@ -37,7 +37,7 @@ public struct ExternalUserProfileFeature { Reduce { state, action in switch action { case .tapOnAvatar: - state.avatarPreview = state.model.user.avatarURL + state.avatarPreview = state.model.avatarURL return .none case .tapFollow: diff --git a/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileView.swift b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileView.swift index 3bd8cdb..cd426a9 100644 --- a/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileView.swift +++ b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileView.swift @@ -32,7 +32,7 @@ public struct ExternalUserProfileView: ComposableView { .onTapGesture { store.send(.tapOnAvatar) } - Text("@" + store.model.user.username.lowercased()) + Text("@" + store.model.username.lowercased()) .monospaced() .bold() Button(action: { store.send(.tapFollow) }) { @@ -46,13 +46,34 @@ public struct ExternalUserProfileView: ComposableView { NavigationStack { ExternalUserProfileView(Store( initialState: .init( - model: .mock(), + model: .init( + id: .init(), + username: "capturecontext", + displayName: "CaptureContext", + bio: "SwiftData kinda sucks", + avatarURL: nil, + isFollowingYou: false, + isFollowedByYou: false, + followsCount: 69, + followersCount: 1123927, + tweetsCount: 1 + ), tweetsList: .init(tweets: [ - .mock(), - .mock(), - .mock(), - .mock(), - .mock() + .init( + id: .init(), + replyTo: nil, + repliesCount: 3, + isLiked: true, + likesCount: 999, + isReposted: false, + repostsCount: 0, + author: .init( + id: .init(), + avatarURL: nil, + username: "capturecontext" + ), + text: "Hello, World!" + ) ]) ), reducer: ExternalUserProfileFeature.init diff --git a/Example/Sources/FeedTabFeature/FeedTabFeature.swift b/Example/Sources/FeedTabFeature/FeedTabFeature.swift index 11dae24..4c70f35 100644 --- a/Example/Sources/FeedTabFeature/FeedTabFeature.swift +++ b/Example/Sources/FeedTabFeature/FeedTabFeature.swift @@ -63,7 +63,10 @@ public struct FeedTabFeature { case let .feed(.openProfile(id)), let .path(.element(_, action: .feed(.openProfile(id)))): - state.path.append(.profile(.external(.init(model: .mock(user: .mock(id: id)))))) + state.path.append(.profile(.external(.init(model: .init( + id: id, + username: "\(id)" + ))))) return .none default: diff --git a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift index 4898ce4..cea0711 100644 --- a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift +++ b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift @@ -61,7 +61,10 @@ public struct ProfileTabFeature { Reduce { state, action in switch action { case let .path(.element(_, action: .feed(.openProfile(id)))): - state.path.append(.profile(.external(.init(model: .mock(user: .mock(id: id)))))) + state.path.append(.profile(.external(.init(model: .init( + id: id, + username: "\(id)" + ))))) return .none // case let .path(.element(stackID, .profile(.user(.tweetsList(.tweets(.element(_, .tap))))))): diff --git a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift index e267c88..73f50d2 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift @@ -33,6 +33,7 @@ public struct TweetDetailFeature { case replies(TweetsListFeature.Action) case detail(PresentationAction) case openProfile(USID) + case openDetail(TweetDetailFeature.State) } @Dependency(\.apiClient) @@ -49,7 +50,23 @@ public struct TweetDetailFeature { return .send(.openProfile(id)) case let .replies(.openDetail(id)): - #warning("Not handled") + guard let selectedTweet = state.replies.tweets[id: id] + else { return .none } + + return .run { send in + do { + let replies = try await apiClient.tweet.fetchReplies(for: id).get() + await send(.openDetail(.init(source: selectedTweet, replies: .init( + tweets: .init(uniqueElements: replies.map { $0.convert(to: .tweetFeature) })) + ))) + } catch { + #warning("Error is not handled") + fatalError(error.localizedDescription) + } + } + + case let .openDetail(detail): + state.detail = detail return .none default: diff --git a/Example/Sources/TweetDetailFeature/TweetDetailView.swift b/Example/Sources/TweetDetailFeature/TweetDetailView.swift index 7683017..226c16a 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailView.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailView.swift @@ -39,10 +39,52 @@ public struct TweetDetailView: ComposableView { #Preview { TweetDetailView(Store( initialState: .init( - source: .mock(), + source: TweetFeature.State( + id: .init(), + replyTo: nil, + repliesCount: 3, + isLiked: true, + likesCount: 999, + isReposted: false, + repostsCount: 0, + author: .init( + id: .init(), + avatarURL: nil, + username: "capturecontext" + ), + text: "Hello, World!" + ), replies: .init(tweets: [ - .mock(), - .mock() + TweetFeature.State( + id: .init(), + replyTo: nil, + repliesCount: 12, + isLiked: true, + likesCount: 69, + isReposted: false, + repostsCount: 0, + author: .init( + id: .init(), + avatarURL: nil, + username: "capturecontext" + ), + text: "Hello, First World!" + ), + TweetFeature.State( + id: .init(), + replyTo: nil, + repliesCount: 0, + isLiked: true, + likesCount: 420, + isReposted: false, + repostsCount: 1, + author: .init( + id: .init(), + avatarURL: nil, + username: "capturecontext" + ), + text: "Hello, Second World!" + ) ]) ), reducer: TweetDetailFeature.init diff --git a/Example/Sources/TweetFeature/TweetFeature.swift b/Example/Sources/TweetFeature/TweetFeature.swift index bf37c06..49ad243 100644 --- a/Example/Sources/TweetFeature/TweetFeature.swift +++ b/Example/Sources/TweetFeature/TweetFeature.swift @@ -8,6 +8,23 @@ public struct TweetFeature { @ObservableState public struct State: Equatable, Identifiable { + @ObservableState + public struct AuthorState: Equatable { + public var id: USID + public var avatarURL: URL? + public var username: String + + public init( + id: USID, + avatarURL: URL? = nil, + username: String + ) { + self.id = id + self.avatarURL = avatarURL + self.username = username + } + } + public var id: USID public var replyTo: USID? public var repliesCount: Int @@ -15,7 +32,7 @@ public struct TweetFeature { public var likesCount: Int public var isReposted: Bool public var repostsCount: Int - public var author: UserModel + public var author: AuthorState public var text: String public init( @@ -26,7 +43,7 @@ public struct TweetFeature { likesCount: Int = 0, isReposted: Bool = false, repostsCount: Int = 0, - author: UserModel, + author: AuthorState, text: String ) { self.id = id @@ -39,52 +56,6 @@ public struct TweetFeature { self.author = author self.text = text } - - public static func mock( - model: TweetModel - ) -> Self { - .mock( - id: model.id, - replyTo: model.replyTo, - author: .mock(id: model.authorID), - text: model.text - ) - } - - public static func mock( - id: USID = .init(), - replyTo: USID? = nil, - author: UserModel = .mock(), - text: String = """ - Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ - Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ - In ad laboris labore irure ea ea officia. - """ - ) -> Self { - .init( - id: id, - replyTo: replyTo, - author: author, - text: text - ) - } - - public func mockReply( - id: USID = .init(), - author: UserModel = .mock(), - text: String = """ - Nisi commodo non ea consequat qui ad pariatur dolore elit ipsum laboris ipsum. \ - Culpa anim incididunt sunt minim ut eiusmod nulla mollit minim qui. \ - In ad laboris labore irure ea ea officia. - """ - ) -> Self { - .init( - id: id, - replyTo: self.id, - author: author, - text: text - ) - } } public enum Action: Equatable { @@ -110,3 +81,29 @@ public struct TweetFeature { } } } + +extension Convertion where From == TweetModel, To == TweetFeature.State { + public static var tweetFeature: Convertion { + return .init { .init( + id: $0.id, + replyTo: $0.replyTo, + repliesCount: $0.repliesCount, + isLiked: $0.isLiked, + likesCount: $0.likesCount, + isReposted: $0.isReposted, + repostsCount: $0.repostsCount, + author: $0.author.convert(to: .tweetFeature), + text: $0.text + )} + } +} + +extension Convertion where From == TweetModel.AuthorModel, To == TweetFeature.State.AuthorState { + public static var tweetFeature: Convertion { + return .init { .init( + id: $0.id, + avatarURL: $0.avatarURL, + username: $0.username + )} + } +} diff --git a/Example/Sources/TweetFeature/TweetView.swift b/Example/Sources/TweetFeature/TweetView.swift index 22a2dd2..1ed7a34 100644 --- a/Example/Sources/TweetFeature/TweetView.swift +++ b/Example/Sources/TweetFeature/TweetView.swift @@ -38,7 +38,21 @@ public struct TweetView: ComposableView { #Preview { TweetView(Store( - initialState: .mock(), + initialState: TweetFeature.State( + id: .init(), + replyTo: nil, + repliesCount: 3, + isLiked: true, + likesCount: 999, + isReposted: false, + repostsCount: 0, + author: .init( + id: .init(), + avatarURL: nil, + username: "capturecontext" + ), + text: "Hello, World!" + ), reducer: TweetFeature.init )) } diff --git a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift index 5810d6f..2c05fcd 100644 --- a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift @@ -28,8 +28,12 @@ public struct TweetsFeedFeature { case list(TweetsListFeature.Action) case detail(PresentationAction) case openProfile(USID) + case openDetail(TweetDetailFeature.State) } + @Dependency(\.apiClient) + var apiClient + public var body: some ReducerOf { Reduce { state, action in switch action { @@ -39,12 +43,27 @@ public struct TweetsFeedFeature { return .send(.openProfile(id)) case let .list(.tweets(.element(itemID, .openDetail))): -// state.detail = state.list.tweets[id: itemID].flatMap { tweet in -// .collectMock(for: tweet.id) -// } - #warning("Not handled") + return .run { send in + do { + let tweet = try await apiClient.tweet.fetch(id: itemID).get() + let replies = try await apiClient.tweet.fetchReplies(for: itemID).get() + + await send(.openDetail(.init( + source: tweet.convert(to: .tweetFeature), + replies: .init(tweets: .init( + uniqueElements: replies.map { $0.convert(to: .tweetFeature) } + )) + ))) + } catch { + #warning("Error is not handled") + fatalError("ErrorIsNotHandled: \(error.localizedDescription)") + } + } + + case let .openDetail(detail): + state.detail = detail return .none - + default: return .none } diff --git a/Example/Sources/TweetsListFeature/TweetsListView.swift b/Example/Sources/TweetsListFeature/TweetsListView.swift index b4faacf..9d6c77c 100644 --- a/Example/Sources/TweetsListFeature/TweetsListView.swift +++ b/Example/Sources/TweetsListFeature/TweetsListView.swift @@ -28,8 +28,36 @@ public struct TweetsListView: ComposableView { NavigationStack { TweetsListView(Store( initialState: .init(tweets: [ - .mock(), - .mock() + TweetFeature.State( + id: .init(), + replyTo: nil, + repliesCount: 12, + isLiked: true, + likesCount: 69, + isReposted: false, + repostsCount: 0, + author: .init( + id: .init(), + avatarURL: nil, + username: "capturecontext" + ), + text: "Hello, First World!" + ), + TweetFeature.State( + id: .init(), + replyTo: nil, + repliesCount: 0, + isLiked: true, + likesCount: 420, + isReposted: false, + repostsCount: 1, + author: .init( + id: .init(), + avatarURL: nil, + username: "capturecontext" + ), + text: "Hello, Second World!" + ) ]), reducer: TweetsListFeature.init )) diff --git a/Example/Sources/UserProfileFeature/UserProfileView.swift b/Example/Sources/UserProfileFeature/UserProfileView.swift index 3ff6691..244e0ce 100644 --- a/Example/Sources/UserProfileFeature/UserProfileView.swift +++ b/Example/Sources/UserProfileFeature/UserProfileView.swift @@ -33,13 +33,34 @@ public struct UserProfileView: ComposableView { NavigationStack { UserProfileView(Store( initialState: .external(.init( - model: .mock(), + model: .init( + id: .init(), + username: "capturecontext", + displayName: "CaptureContext", + bio: "SwiftData kinda sucks", + avatarURL: nil, + isFollowingYou: false, + isFollowedByYou: false, + followsCount: 69, + followersCount: 1123927, + tweetsCount: 1 + ), tweetsList: .init(tweets: [ - .mock(), - .mock(), - .mock(), - .mock(), - .mock() + .init( + id: .init(), + replyTo: nil, + repliesCount: 3, + isLiked: true, + likesCount: 999, + isReposted: false, + repostsCount: 0, + author: .init( + id: .init(), + avatarURL: nil, + username: "capturecontext" + ), + text: "Hello, World!" + ) ]) )), reducer: UserProfileFeature.init From 01824e8b9bb22623b952085019561f831ffe9191 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 30 Dec 2023 13:16:30 +0100 Subject: [PATCH 24/43] feat: Delegate actions and API integration --- .../xcschemes/FeedTabFeature.xcscheme | 66 ++++++ .../xcschemes/TweetFeature.xcscheme | 66 ++++++ .../xcschemes/TweetsListFeature.xcscheme | 66 ++++++ .../xcschemes/UserProfileFeature.xcscheme | 66 ++++++ Example/Package.swift | 1 + .../Sources/APIClient/APIClient+Error.swift | 136 +++++++++++ .../Sources/APIClient/APIClient+Live.swift | 221 ++++++++---------- .../APIClient/Auth/APIClient+Auth.swift | 4 +- .../APIClient/Feed/APIClient+Feed.swift | 2 +- .../Sources/APIClient/Helpers/Result.swift | 9 + .../APIClient/Tweet/APIClient+Tweet.swift | 16 +- .../APIClient/User/APIClient+User.swift | 8 +- .../Bootstrap/PrefillDatabase.swift | 55 +++++ .../AppFeature/Bootstrap/SceneDelegate.swift | 89 +------ Example/Sources/AppModels/TweetModel.swift | 6 + Example/Sources/AppUI/Colors/ColorTheme.swift | 142 +++++++++++ .../AppUI/ViewModifiers/ScaledFont.swift | 43 ++++ .../AuthFeature/AuthFeature+SignUp.swift | 1 + Example/Sources/AuthFeature/AuthFeature.swift | 1 + .../CurrentUserProfileFeature.swift | 14 +- .../ExternalUserProfileFeature.swift | 47 ++-- .../FeedTabFeature/FeedTabFeature.swift | 34 ++- Example/Sources/MainFeature/MainFeature.swift | 4 +- .../ProfileAndFeedPivot.swift | 1 + .../ProfileFeedFeature.swift | 1 + .../ProfileTabFeature/ProfileTabFeature.swift | 3 +- .../TweetDetailController.swift | 5 + .../TweetDetailFeature.swift | 126 ++++++++-- .../Sources/TweetFeature/TweetFeature.swift | 59 +++-- Example/Sources/TweetFeature/TweetView.swift | 146 ++++++++++-- .../TweetsFeedController.swift | 5 + .../TweetsFeedFeature/TweetsFeedFeature.swift | 131 ++++++++--- .../TweetsListFeature/TweetsListFeature.swift | 18 +- .../UserProfileFeature.swift | 42 +++- .../UserSettingsFeature.swift | 1 + 35 files changed, 1296 insertions(+), 339 deletions(-) create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/FeedTabFeature.xcscheme create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetFeature.xcscheme create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetsListFeature.xcscheme create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/UserProfileFeature.xcscheme create mode 100644 Example/Sources/APIClient/APIClient+Error.swift create mode 100644 Example/Sources/APIClient/Helpers/Result.swift create mode 100644 Example/Sources/AppFeature/Bootstrap/PrefillDatabase.swift create mode 100644 Example/Sources/AppUI/Colors/ColorTheme.swift create mode 100644 Example/Sources/AppUI/ViewModifiers/ScaledFont.swift diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/FeedTabFeature.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/FeedTabFeature.xcscheme new file mode 100644 index 0000000..61db32c --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/FeedTabFeature.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetFeature.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetFeature.xcscheme new file mode 100644 index 0000000..2f5d0c3 --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetFeature.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetsListFeature.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetsListFeature.xcscheme new file mode 100644 index 0000000..5103ba0 --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetsListFeature.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/UserProfileFeature.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/UserProfileFeature.xcscheme new file mode 100644 index 0000000..5223707 --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/UserProfileFeature.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Package.swift b/Example/Package.swift index 07faf8f..7640bf4 100644 --- a/Example/Package.swift +++ b/Example/Package.swift @@ -252,6 +252,7 @@ let package = Package( name: "TweetFeature", product: .library(.static), dependencies: [ + .target("AppUI"), .target("AppModels"), .dependency("_ComposableArchitecture"), .localExtensions, diff --git a/Example/Sources/APIClient/APIClient+Error.swift b/Example/Sources/APIClient/APIClient+Error.swift new file mode 100644 index 0000000..2401100 --- /dev/null +++ b/Example/Sources/APIClient/APIClient+Error.swift @@ -0,0 +1,136 @@ +import Foundation + +extension APIClient { + public struct Error: Swift.Error, Equatable { + public let code: Code? + public let message: String + public let localizedDescription: String + public let recoveryOptions: [RecoveryOption] + + internal init( + code: Code?, + message: String, + localizedDescription: String? = nil, + recoveryOptions: [RecoveryOption] = [] + ) { + self.code = code + self.message = message + self.localizedDescription = localizedDescription.or(message) + self.recoveryOptions = recoveryOptions + } + + public struct RecoveryOption: Equatable { + var title: String + var deeplink: String + } + } +} + +extension APIClient.Error { + public enum Code: Int, Equatable { + case unauthenticated = 401 + case unauthorized = 403 + case notFound = 404 + case conflict = 409 + } +} + +extension APIClient.Error { + public init(_ error: Swift.Error) { + if let _self = error as? Self { + self = _self + } else { + self = .init( + code: nil, + message: "Something went wrong", + localizedDescription: error.localizedDescription, + recoveryOptions: [] + ) + } + } +} + +extension Swift.Error where Self == APIClient.Error { + static var userAlreadyExists: APIClient.Error { + .init( + code: .conflict, + message: """ + User already exists, \ + try recover your account or \ + use different credentials. + """ + ) + } + + static var usernameNotFound: APIClient.Error { + .init( + code: .notFound, + message: """ + There is no such username in our \ + database, you probably forgot to sign up or \ + made some typo in your username. + """ + ) + } + + static var userNotFound: APIClient.Error { + .init( + code: .notFound, + message: """ + The profile you are trying to view \ + probably was deleted 😢 + """ + ) + } + + static var wrongPassword: APIClient.Error { + .init( + code: .unauthenticated, + message: """ + The password was incorrect, try again \ + or create a new one using recovery options. + """, + recoveryOptions: [ + .init( + title: "Reset password", + deeplink: "/recovery/password-reset" + ) + ] + ) + } + + static func unauthenticatedRequest(_ actionDescription: String) -> APIClient.Error { + .init( + code: .unauthenticated, + message: """ + You need to be authenticated to \ + \(actionDescription). + """, + recoveryOptions: [ + .init( + title: "Authenticate", + deeplink: "/auth" + ) + ] + ) + } + + static var tweetNotFound: APIClient.Error { + .init( + code: .notFound, + message: """ + The tweet you are trying to view \ + probably was deleted 😢 + """ + ) + } + + static var unauthorizedRequest: APIClient.Error { + .init( + code: .unauthorized, + message: """ + You have no permission to perform this action 😢 + """ + ) + } +} diff --git a/Example/Sources/APIClient/APIClient+Live.swift b/Example/Sources/APIClient/APIClient+Live.swift index afce926..8438dc4 100644 --- a/Example/Sources/APIClient/APIClient+Live.swift +++ b/Example/Sources/APIClient/APIClient+Live.swift @@ -33,70 +33,45 @@ extension APIClient: DependencyKey { } } -private enum Errors { - struct UserExists: Swift.Error { - var localizedDesctiption: String { "User exists" } - } - - struct UserDoesNotExist: Swift.Error { - var localizedDesctiption: String { "User doesn't exist" } - } - - struct UnauthenticatedRequest: Swift.Error { - var localizedDesctiption: String { "Request requires authentication" } - } - - struct TweetDoesNotExist: Swift.Error { - var localizedDesctiption: String { "Tweet doesn't exist" } - } - - struct UnauthorizedRequest: Swift.Error { - var localizedDesctiption: String { "Unauthorized access" } - } - - struct AuthenticationFailed: Swift.Error { - var localizedDesctiption: String { "Username or password is incorrect" } - } -} - extension APIClient.Auth { static func backendLike( currentUser: Reference ) -> Self { .init( signIn: .init { input in - @Dependency(\.database) - var database + return await Result { + @Dependency(\.database) + var database - return await Result { let pwHash = try Data.sha256(input.password).unwrap().get() let username = input.username guard let user = try await database.context.fetch( DatabaseSchema.UserModel.self, - #Predicate { model in - model.username == username - && model.password == pwHash - } - ).first - else { throw Errors.AuthenticationFailed() } + #Predicate { $0.username == username } + ).first + else { throw .usernameNotFound } + + guard user.password == pwHash else { + throw .wrongPassword + } currentUser.wrappedValue = user - } + }.mapError(APIClient.Error.init) }, signUp: .init { input in - @Dependency(\.database) - var database + return await Result { + @Dependency(\.database) + var database - return await Result { let username = input.username let userExists = try await database.context.fetch( DatabaseSchema.UserModel.self, #Predicate { $0.username == username } ).isNotEmpty - guard !userExists - else { throw Errors.UserExists() } + guard !userExists + else { throw .userAlreadyExists } let pwHash = try Data.sha256(input.password).unwrap().get() @@ -109,7 +84,7 @@ extension APIClient.Auth { await database.context.insert(user) try await database.context.save() currentUser.wrappedValue = user - } + }.mapError(APIClient.Error.init) }, logout: .init { _ in currentUser.wrappedValue = nil @@ -124,10 +99,10 @@ extension APIClient.Feed { ) -> Self { .init( fetchTweets: .init { input in - @Dependency(\.database) - var database + return await Result { + @Dependency(\.database) + var database - return await Result { return try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.replySource == nil } @@ -139,12 +114,15 @@ extension APIClient.Feed { id: tweet.id.usid(), author: .init( id: tweet.author.id.usid(), + avatarURL: tweet.author.avatarURL, + displayName: tweet.author.displayName, username: tweet.author.username ), + createdAt: tweet.createdAt, replyTo: tweet.replySource?.id.usid(), repliesCount: tweet.replies.count, isLiked: currentUser.wrappedValue.map { user in - tweet.replies.contains { $0 === user } + tweet.likes.contains { $0.id == user.id } }.or(false), likesCount: tweet.likes.count, isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), @@ -152,7 +130,7 @@ extension APIClient.Feed { text: tweet.content ) } - } + }.mapError(APIClient.Error.init) } ) } @@ -164,42 +142,44 @@ extension APIClient.Tweet { ) -> Self { .init( fetch: .init { input in - @Dependency(\.database) - var database - - return await Result { + return await Result { + @Dependency(\.database) + var database let tweetID = input.rawValue guard let tweet = try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.id == tweetID } ).first - else { throw Errors.TweetDoesNotExist() } + else { throw .tweetNotFound } return TweetModel( id: tweet.id.usid(), author: .init( id: tweet.author.id.usid(), + avatarURL: tweet.author.avatarURL, + displayName: tweet.author.displayName, username: tweet.author.username ), + createdAt: tweet.createdAt, replyTo: tweet.replySource?.id.usid(), repliesCount: tweet.replies.count, isLiked: currentUser.wrappedValue.map { user in - tweet.replies.contains { $0 === user } + tweet.likes.contains { $0 === user } }.or(false), likesCount: tweet.likes.count, isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), repostsCount: tweet.reposts.count, text: tweet.content ) - } + }.mapError(APIClient.Error.init) }, like: .init { input in - @Dependency(\.database) - var database + return await Result { + @Dependency(\.database) + var database - return await Result { guard let user = currentUser.wrappedValue - else { throw Errors.UnauthenticatedRequest() } + else { throw .unauthenticatedRequest("like a tweet") } let shouldLike = input.value let isLiked = user.likedTweets.contains(where: { $0.id == input.id.rawValue }) @@ -212,7 +192,7 @@ extension APIClient.Tweet { DatabaseSchema.TweetModel.self, #Predicate { $0.id == tweetID } ).first - else { throw Errors.TweetDoesNotExist() } + else { throw .tweetNotFound } user.likedTweets.append(tweet) } else { @@ -220,40 +200,40 @@ extension APIClient.Tweet { } try await database.context.save() - } + }.mapError(APIClient.Error.init) }, post: .init { input in - @Dependency(\.database) - var database + return await Result { + @Dependency(\.database) + var database - return await Result { guard let user = currentUser.wrappedValue - else { throw Errors.UnauthenticatedRequest() } + else { throw .unauthenticatedRequest("post a tweet") } await database.context.insert(DatabaseSchema.TweetModel( - id: USID(), + id: USID(), createdAt: .now, author: user, content: input )) try await database.context.save() - } + }.mapError(APIClient.Error.init) }, repost: .init { input in - @Dependency(\.database) - var database + return await Result { + @Dependency(\.database) + var database - return await Result { guard let user = currentUser.wrappedValue - else { throw Errors.UnauthenticatedRequest() } + else { throw .unauthenticatedRequest("repost a tweet") } let tweetID = input.id.rawValue guard let originalTweet = try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.id == tweetID } ).first - else { throw Errors.TweetDoesNotExist() } + else { throw .tweetNotFound } originalTweet.reposts.append(DatabaseSchema.TweetModel( id: USID(), @@ -263,22 +243,22 @@ extension APIClient.Tweet { )) try await database.context.save() - } + }.mapError(APIClient.Error.init) }, reply: .init { input in - @Dependency(\.database) - var database + return await Result { + @Dependency(\.database) + var database - return await Result { guard let user = currentUser.wrappedValue - else { throw Errors.UnauthenticatedRequest() } + else { throw .unauthenticatedRequest("reply to a tweet") } let tweetID = input.id.rawValue guard let originalTweet = try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.id == tweetID } ).first - else { throw Errors.TweetDoesNotExist() } + else { throw .tweetNotFound } originalTweet.replies.append(DatabaseSchema.TweetModel( id: USID(), @@ -288,42 +268,42 @@ extension APIClient.Tweet { )) try await database.context.save() - } + }.mapError(APIClient.Error.init) }, delete: .init { input in - @Dependency(\.database) - var database + return await Result { + @Dependency(\.database) + var database - return await Result { guard let user = currentUser.wrappedValue - else { throw Errors.UnauthenticatedRequest() } + else { throw .unauthenticatedRequest("delete tweets") } guard let tweetToDelete = try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.id == input.rawValue } ).first - else { throw Errors.TweetDoesNotExist() } + else { throw .tweetNotFound } user.tweets.removeAll { $0 === tweetToDelete } try await database.context.save() - } + }.mapError(APIClient.Error.init) }, report: .init { input in // Pretend we did collect the report return .success(()) }, fetchReplies: .init { input in - @Dependency(\.database) - var database + return await Result { + @Dependency(\.database) + var database - return await Result { let tweetID = input.id.rawValue guard let tweet = try await database.context.fetch( DatabaseSchema.TweetModel.self, #Predicate { $0.id == tweetID } ).first - else { throw Errors.TweetDoesNotExist() } + else { throw .tweetNotFound } return tweet.replies .dropFirst(input.page * input.limit) @@ -332,13 +312,16 @@ extension APIClient.Tweet { return TweetModel( id: tweet.id.usid(), author: .init( - id: tweet.author.id.usid(), - username: tweet.author.username - ), + id: tweet.author.id.usid(), + avatarURL: tweet.author.avatarURL, + displayName: tweet.author.displayName, + username: tweet.author.username + ), + createdAt: tweet.createdAt, replyTo: tweet.replySource?.id.usid(), repliesCount: tweet.replies.count, isLiked: currentUser.wrappedValue.map { user in - tweet.replies.contains { $0 === user } + tweet.likes.contains { $0.id == user.id } }.or(false), likesCount: tweet.likes.count, isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), @@ -346,7 +329,7 @@ extension APIClient.Tweet { text: tweet.content ) } - } + }.mapError(APIClient.Error.init) } ) } @@ -358,15 +341,15 @@ extension APIClient.User { ) -> Self { .init( fetch: .init { id in - @Dependency(\.database) - var database + return await Result { + @Dependency(\.database) + var database - return await Result { guard let user = try await database.context.fetch( DatabaseSchema.UserModel.self, #Predicate { $0.id == id.rawValue } ).first - else { throw Errors.UserDoesNotExist()} + else { throw .userNotFound } return UserInfoModel( id: user.id.usid(), @@ -379,15 +362,15 @@ extension APIClient.User { followsCount: user.follows.count, followersCount: user.followers.count ) - } + }.mapError(APIClient.Error.init) }, follow: .init { input in - @Dependency(\.database) - var database + return await Result { + @Dependency(\.database) + var database - return await Result { guard let user = currentUser.wrappedValue - else { throw Errors.UnauthenticatedRequest() } + else { throw .unauthenticatedRequest("follow or unfollow profiles") } let shouldFollow = input.value let isFollowing = user.follows.contains(where: { $0.id == input.id.rawValue }) @@ -397,10 +380,10 @@ extension APIClient.User { if shouldFollow { let userID = input.id.rawValue guard let userToFollow = try await database.context.fetch( - DatabaseSchema.UserModel.self, - #Predicate { $0.id == userID } - ).first - else { throw Errors.UserDoesNotExist() } + DatabaseSchema.UserModel.self, + #Predicate { $0.id == userID } + ).first + else { throw .userNotFound } user.follows.append(userToFollow) } else { @@ -408,23 +391,22 @@ extension APIClient.User { } try await database.context.save() - } + }.mapError(APIClient.Error.init) }, report: .init { input in // Pretend we did collect the report return .success(()) }, fetchTweets: .init { input in - @Dependency(\.database) - var database - - return await Result { + return await Result { + @Dependency(\.database) + var database let userID = input.id.rawValue guard let user = try await database.context.fetch( DatabaseSchema.UserModel.self, #Predicate { $0.id == userID } ).first - else { throw Errors.UserDoesNotExist() } + else { throw .userNotFound } return user.tweets .dropFirst(input.page * input.limit) @@ -432,14 +414,17 @@ extension APIClient.User { .map { tweet in return TweetModel( id: tweet.id.usid(), - author: .init( - id: tweet.author.id.usid(), - username: tweet.author.username - ), + author:.init( + id: tweet.author.id.usid(), + avatarURL: tweet.author.avatarURL, + displayName: tweet.author.displayName, + username: tweet.author.username + ), + createdAt: tweet.createdAt, replyTo: tweet.replySource?.id.usid(), repliesCount: tweet.replies.count, isLiked: currentUser.wrappedValue.map { user in - tweet.replies.contains { $0 === user } + tweet.likes.contains { $0 === user } }.or(false), likesCount: tweet.likes.count, isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), @@ -447,7 +432,7 @@ extension APIClient.User { text: tweet.content ) } - } + }.mapError(APIClient.Error.init) } ) } diff --git a/Example/Sources/APIClient/Auth/APIClient+Auth.swift b/Example/Sources/APIClient/Auth/APIClient+Auth.swift index 4b6f5d9..8c5d4d1 100644 --- a/Example/Sources/APIClient/Auth/APIClient+Auth.swift +++ b/Example/Sources/APIClient/Auth/APIClient+Auth.swift @@ -30,7 +30,7 @@ extension APIClient.Auth.Operations { password: String ) - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output @@ -54,7 +54,7 @@ extension APIClient.Auth.Operations { password: String ) - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output diff --git a/Example/Sources/APIClient/Feed/APIClient+Feed.swift b/Example/Sources/APIClient/Feed/APIClient+Feed.swift index f4b5e41..948f7e3 100644 --- a/Example/Sources/APIClient/Feed/APIClient+Feed.swift +++ b/Example/Sources/APIClient/Feed/APIClient+Feed.swift @@ -22,7 +22,7 @@ extension APIClient.Feed.Operations { limit: Int ) - public typealias Output = Result<[TweetModel], Error> + public typealias Output = Result<[TweetModel], APIClient.Error> public typealias AsyncSignature = @Sendable (Input) async -> Output diff --git a/Example/Sources/APIClient/Helpers/Result.swift b/Example/Sources/APIClient/Helpers/Result.swift new file mode 100644 index 0000000..686b636 --- /dev/null +++ b/Example/Sources/APIClient/Helpers/Result.swift @@ -0,0 +1,9 @@ +extension Result { + static func unsafe(_ closure: () throws -> Success) -> Self { + do { + return .success(try closure()) + } catch { + return .failure(error as! Failure) + } + } +} diff --git a/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift b/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift index b2b299d..5fb2229 100644 --- a/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift +++ b/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift @@ -42,7 +42,7 @@ extension APIClient.Tweet.Operations { public struct Fetch { public typealias Input = USID - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output @@ -65,7 +65,7 @@ extension APIClient.Tweet.Operations { value: Bool ) - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output @@ -89,7 +89,7 @@ extension APIClient.Tweet.Operations { content: String ) - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output @@ -110,7 +110,7 @@ extension APIClient.Tweet.Operations { public struct Delete { public typealias Input = USID - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output @@ -130,7 +130,7 @@ extension APIClient.Tweet.Operations { public struct Report { public typealias Input = USID - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output @@ -153,7 +153,7 @@ extension APIClient.Tweet.Operations { content: String ) - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output @@ -174,7 +174,7 @@ extension APIClient.Tweet.Operations { public struct Post { public typealias Input = String - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output @@ -198,7 +198,7 @@ extension APIClient.Tweet.Operations { limit: Int ) - public typealias Output = Result<[TweetModel], Error> + public typealias Output = Result<[TweetModel], APIClient.Error> public typealias AsyncSignature = @Sendable (Input) async -> Output diff --git a/Example/Sources/APIClient/User/APIClient+User.swift b/Example/Sources/APIClient/User/APIClient+User.swift index e7a17f6..dbdaca8 100644 --- a/Example/Sources/APIClient/User/APIClient+User.swift +++ b/Example/Sources/APIClient/User/APIClient+User.swift @@ -30,7 +30,7 @@ extension APIClient.User.Operations { public struct Fetch { public typealias Input = USID - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output @@ -53,7 +53,7 @@ extension APIClient.User.Operations { value: Bool ) - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output @@ -74,7 +74,7 @@ extension APIClient.User.Operations { public struct Report { public typealias Input = USID - public typealias Output = Result + public typealias Output = Result public typealias AsyncSignature = @Sendable (Input) async -> Output @@ -98,7 +98,7 @@ extension APIClient.User.Operations { limit: Int ) - public typealias Output = Result<[TweetModel], Error> + public typealias Output = Result<[TweetModel], APIClient.Error> public typealias AsyncSignature = @Sendable (Input) async -> Output diff --git a/Example/Sources/AppFeature/Bootstrap/PrefillDatabase.swift b/Example/Sources/AppFeature/Bootstrap/PrefillDatabase.swift new file mode 100644 index 0000000..1a0fb4c --- /dev/null +++ b/Example/Sources/AppFeature/Bootstrap/PrefillDatabase.swift @@ -0,0 +1,55 @@ +import _Dependencies +import DatabaseSchema +import LocalExtensions + +let defaultUsername = "capturecontext" +let defaultPassword = "psswrd" + +func prefillDatabaseIfNeeded(autoSignIn: Bool) async throws { + @Dependency(\.database) + var database + + let modelContext = await database.context + + let currentUser = DatabaseSchema.UserModel( + id: USID(), + username: defaultUsername, + password: .sha256(defaultPassword)!, + displayName: "Capture Context" + ) + + let tweet = DatabaseSchema.TweetModel( + id: USID(), + author: currentUser, + likes: [currentUser], + content: "Hello, First World!" + ) + + let reply1 = DatabaseSchema.TweetModel( + id: USID(), + author: currentUser, + replySource: tweet, + content: "Hello, Second World!" + ) + + let reply2 = DatabaseSchema.TweetModel( + id: USID(), + author: currentUser, + replySource: reply1, + content: "Hello, Third World!" + ) + + modelContext.insert(reply2) + + try modelContext.save() + + guard autoSignIn else { return } + + @Dependency(\.apiClient) + var apiClient + + try await apiClient.auth.signIn( + username: defaultUsername, + password: defaultPassword + ).get() +} diff --git a/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift index 63efde0..811f970 100644 --- a/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift +++ b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift @@ -15,90 +15,14 @@ import LocalExtensions public class SceneDelegate: UIResponder, UIWindowSceneDelegate { public var window: UIWindow? - @Dependency(\.database) - var database - - @Dependency(\.apiClient) - var apiClient - - func setupStore(_ callback: @escaping (StoreOf) -> Void) { - Task { @MainActor in - do { - let modelContext = await database.context - - let currentUser = DatabaseSchema.UserModel( - id: USID(), - username: "capturecontext", - password: .sha256("psswrd")! - ) - - let tweet = DatabaseSchema.TweetModel( - id: USID(), - author: currentUser, - content: "Hello, First World!" - ) - - let reply1 = DatabaseSchema.TweetModel( - id: USID(), - author: currentUser, - replySource: tweet, - content: "Hello, Second World!" - ) - - let reply2 = DatabaseSchema.TweetModel( - id: USID(), - author: currentUser, - replySource: reply1, - content: "Hello, Third World!" - ) - - modelContext.insert(reply2) - - try! modelContext.save() - - _ = try await self.apiClient.auth.signIn(username: "capturecontext", password: "psswrd").get() - let tweets = try await self.apiClient.feed.fetchTweets(page: 0, limit: 3).get() - - callback(Store( - initialState: MainFeature.State( - feed: .init( - feed: .init( - list: .init( - tweets: .init( - uncheckedUniqueElements: - tweets.map { $0.convert(to: .tweetFeature) } - ) - ) - ) - ), - profile: .init(), - selectedTab: .feed - ), - reducer: { - MainFeature() - ._printChanges() - } - )) - } catch { - print(error) - } - } - } - - var store: StoreOf? - public func scene( _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { guard let scene = scene as? UIWindowScene else { return } - let controller = MainViewController() - setupStore { store in - self.store = store - controller.setStore(store) - } + let controller = MainViewController() let window = UIWindow(windowScene: scene) self.window = window @@ -108,6 +32,17 @@ public class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) window.makeKeyAndVisible() + + Task { @MainActor in + try await prefillDatabaseIfNeeded(autoSignIn: true) + + controller.setStore(Store( + initialState: .init(), + reducer: { + MainFeature()._printChanges() + } + )) + } } public func sceneDidDisconnect(_ scene: UIScene) {} diff --git a/Example/Sources/AppModels/TweetModel.swift b/Example/Sources/AppModels/TweetModel.swift index 36462a5..bd37255 100644 --- a/Example/Sources/AppModels/TweetModel.swift +++ b/Example/Sources/AppModels/TweetModel.swift @@ -4,21 +4,25 @@ public struct TweetModel: Equatable, Identifiable, Codable, ConvertibleModel { public struct AuthorModel: Equatable, Identifiable, Codable, ConvertibleModel { public var id: USID public var avatarURL: URL? + public var displayName: String public var username: String public init( id: USID, avatarURL: URL? = nil, + displayName: String, username: String ) { self.id = id self.avatarURL = avatarURL + self.displayName = displayName self.username = username } } public var id: USID public var author: AuthorModel + public var createdAt: Date public var replyTo: USID? public var repliesCount: Int public var isLiked: Bool @@ -30,6 +34,7 @@ public struct TweetModel: Equatable, Identifiable, Codable, ConvertibleModel { public init( id: USID, author: AuthorModel, + createdAt: Date = .now, replyTo: USID? = nil, repliesCount: Int = 0, isLiked: Bool = false, @@ -40,6 +45,7 @@ public struct TweetModel: Equatable, Identifiable, Codable, ConvertibleModel { ) { self.id = id self.author = author + self.createdAt = createdAt self.replyTo = replyTo self.repliesCount = repliesCount self.isLiked = isLiked diff --git a/Example/Sources/AppUI/Colors/ColorTheme.swift b/Example/Sources/AppUI/Colors/ColorTheme.swift new file mode 100644 index 0000000..9822e7f --- /dev/null +++ b/Example/Sources/AppUI/Colors/ColorTheme.swift @@ -0,0 +1,142 @@ +import UIKit +import SwiftUI + +public struct ColorTheme { + public struct ColorSet3 { + public let primary: UIColor + public let secondary: UIColor + public let tertiary: UIColor + + public init( + primary: UIColor, + secondary: UIColor, + tertiary: UIColor + ) { + self.primary = primary + self.secondary = secondary + self.tertiary = tertiary + } + } + + public struct ColorSet4 { + public let primary: UIColor + public let secondary: UIColor + public let tertiary: UIColor + public let quaternary: UIColor + + public init( + primary: UIColor, + secondary: UIColor, + tertiary: UIColor, + quaternary: UIColor + ) { + self.primary = primary + self.secondary = secondary + self.tertiary = tertiary + self.quaternary = quaternary + } + } + + public let accent: UIColor + public let like: UIColor + public let done: UIColor + + public let label: ColorSet4 + public let background: ColorSet3 + + public func callAsFunction(_ keyPath: KeyPath) -> Color { + Color(self[keyPath: keyPath.appending(path: \.primary)]) + } + + public func callAsFunction(_ keyPath: KeyPath) -> Color { + Color(self[keyPath: keyPath.appending(path: \.primary)]) + } + + public func callAsFunction(_ keyPath: KeyPath) -> Color { + Color(self[keyPath: keyPath]) + } +} + +extension ColorTheme { + public static var current: ColorTheme { + Environment(\.colorTheme).wrappedValue + } + + public static let system: Self = .init( + accent: .systemBlue, + like: .systemRed, + done: .systemGreen, + label: .init( + primary: .label, + secondary: .secondaryLabel, + tertiary: .tertiaryLabel, + quaternary: .quaternaryLabel + ), + background: .init( + primary: .systemBackground, + secondary: .secondarySystemBackground, + tertiary: .tertiarySystemBackground + ) + ) + + public static let systemTweaked: Self = .init( + accent: .systemBlue, + like: .systemRed, + done: .systemGreen, + label: .init( + primary: .label, + secondary: .label.withAlphaComponent(0.7), + tertiary: .tertiaryLabel, + quaternary: .quaternaryLabel + ), + background: .init( + primary: .systemBackground, + secondary: .secondarySystemBackground, + tertiary: .tertiarySystemBackground + ) + ) + + public static func dynamic( + light: ColorTheme, + dark: ColorTheme + ) -> ColorTheme { + func color(for keyPath: KeyPath) -> UIColor { + UIColor { traits in + traits.userInterfaceStyle == .dark + ? dark[keyPath: keyPath] + : light[keyPath: keyPath] + } + } + + return .init( + accent: color(for: \.accent), + like: color(for: \.like), + done: color(for: \.done), + label: .init( + primary: color(for: \.label.primary), + secondary: color(for: \.label.secondary), + tertiary: color(for: \.label.tertiary), + quaternary: color(for: \.label.quaternary) + ), + background: .init( + primary: color(for: \.background.primary), + secondary: color(for: \.background.secondary), + tertiary: color(for: \.background.tertiary) + ) + ) + } +} + +// MARK: - Environment + +extension EnvironmentValues { + public var colorTheme: ColorTheme { + get { self[ColorTheme.self] } + set { self[ColorTheme.self] = newValue} + } +} + +extension ColorTheme: EnvironmentKey { + public static var defaultValue: Self { .systemTweaked } +} + diff --git a/Example/Sources/AppUI/ViewModifiers/ScaledFont.swift b/Example/Sources/AppUI/ViewModifiers/ScaledFont.swift new file mode 100644 index 0000000..cfb10ff --- /dev/null +++ b/Example/Sources/AppUI/ViewModifiers/ScaledFont.swift @@ -0,0 +1,43 @@ +import SwiftUI + +extension View { + public func scaledFont( + ofSize size: Double, + weight: Font.Weight = .regular, + design: Font.Design = .default + ) -> some View { + return modifier(ScaledFont( + size: size, + weight: weight, + design: design + )) + } +} + +private struct ScaledFont: ViewModifier { + // tracks dynamic font size changes + @Environment(\.sizeCategory) + private var sizeCategory + + private var size: Double + private var weight: Font.Weight + private var design: Font.Design + + init( + size: Double, + weight: Font.Weight, + design: Font.Design + ) { + self.size = size + self.weight = weight + self.design = design + } + + public func body(content: Content) -> some View { + return content.font(.system( + size: UIFontMetrics.default.scaledValue(for: size), + weight: weight, + design: design + )) + } +} diff --git a/Example/Sources/AuthFeature/AuthFeature+SignUp.swift b/Example/Sources/AuthFeature/AuthFeature+SignUp.swift index 7066cd8..4b5b13b 100644 --- a/Example/Sources/AuthFeature/AuthFeature+SignUp.swift +++ b/Example/Sources/AuthFeature/AuthFeature+SignUp.swift @@ -8,6 +8,7 @@ extension AuthFeature { public init() {} } + @CasePathable public enum Action: Equatable { } diff --git a/Example/Sources/AuthFeature/AuthFeature.swift b/Example/Sources/AuthFeature/AuthFeature.swift index 5fceea1..d0bdfaa 100644 --- a/Example/Sources/AuthFeature/AuthFeature.swift +++ b/Example/Sources/AuthFeature/AuthFeature.swift @@ -8,6 +8,7 @@ public struct AuthFeature { case signUp(SignUp.State = .init()) } + @CasePathable public enum Action: Equatable { case signIn(SignIn.Action) case signUp(SignUp.Action) diff --git a/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift index aa13e3b..2af23c4 100644 --- a/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift +++ b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift @@ -1,5 +1,5 @@ import _ComposableArchitecture -import Foundation +import LocalExtensions import AppModels import TweetsListFeature import UserSettingsFeature @@ -16,6 +16,7 @@ public struct CurrentUserProfileFeature { case userSettings(UserSettingsFeature.State) } + @CasePathable public enum Action: Equatable { case avatarPreivew(Never) case userSettings(UserSettingsFeature.Action) @@ -54,10 +55,17 @@ public struct CurrentUserProfileFeature { } } + @CasePathable public enum Action: Equatable { case destination(PresentationAction) case tweetsList(TweetsListFeature.Action) case tapOnAvatar + case delegate(Delegate) + + @CasePathable + public enum Delegate: Equatable { + case openProfile(USID) + } } public var body: some ReducerOf { @@ -79,6 +87,10 @@ public struct CurrentUserProfileFeature { destination: Destination.init ) + Pullback(\.tweetsList.delegate.openProfile) { state, id in + return .send(.delegate(.openProfile(id))) + } + Scope( state: \State.tweetsList, action: \.tweetsList, diff --git a/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift index abd40a4..1035c30 100644 --- a/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift +++ b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift @@ -24,34 +24,49 @@ public struct ExternalUserProfileFeature { } } + @CasePathable public enum Action: Equatable { case avatarPreview(PresentationAction) case tweetsList(TweetsListFeature.Action) - case openDetail(for: USID) - case openProfile(USID) case tapOnAvatar case tapFollow + case delegate(Delegate) + + @CasePathable + public enum Delegate: Equatable { + case openDetail(USID) + case openProfile(USID) + } } public var body: some ReducerOf { - Reduce { state, action in - switch action { - case .tapOnAvatar: - state.avatarPreview = state.model.avatarURL - return .none + CombineReducers { + Reduce { state, action in + switch action { + case .tapOnAvatar: + state.avatarPreview = state.model.avatarURL + return .none - case .tapFollow: - state.model.isFollowedByYou.toggle() - return .none + case .tapFollow: + state.model.isFollowedByYou.toggle() + return .none - case let .tweetsList(.tweets(.element(_, .openDetail(id)))): - return .send(.openDetail(for: id)) + default: + return .none + } + } + Reduce { state, action in + switch action { + case let .tweetsList(.delegate(.openDetail(id))): + return .send(.delegate(.openDetail(id))) - case let .tweetsList(.tweets(.element(_, .openProfile(id)))): - return .send(.openProfile(id)) + case let .tweetsList(.delegate(.openProfile(id))): + guard id != state.model.id else { return .none } + return .send(.delegate(.openProfile(id))) - default: - return .none + default: + return .none + } } } .ifLet( diff --git a/Example/Sources/FeedTabFeature/FeedTabFeature.swift b/Example/Sources/FeedTabFeature/FeedTabFeature.swift index 4c70f35..598ee85 100644 --- a/Example/Sources/FeedTabFeature/FeedTabFeature.swift +++ b/Example/Sources/FeedTabFeature/FeedTabFeature.swift @@ -1,6 +1,7 @@ import _ComposableArchitecture import UserProfileFeature import TweetsFeedFeature +import LocalExtensions @Reducer public struct FeedTabFeature { @@ -13,12 +14,30 @@ public struct FeedTabFeature { case profile(UserProfileFeature.State) } + @CasePathable public enum Action: Equatable { case feed(TweetsFeedFeature.Action) case profile(UserProfileFeature.Action) + case delegate(Delegate) + + @CasePathable + public enum Delegate: Equatable { + case openProfile(USID) + } } public var body: some ReducerOf { + Reduce { state, action in + switch action { + case + let .feed(.delegate(.openProfile(id))), + let .profile(.delegate(.openProfile(id))): + return .send(.delegate(.openProfile(id))) + + default: + return .none + } + } Scope( state: /State.feed, action: /Action.feed, @@ -46,6 +65,7 @@ public struct FeedTabFeature { } } + @CasePathable public enum Action: Equatable { case feed(TweetsFeedFeature.Action) case path(StackAction) @@ -61,8 +81,8 @@ public struct FeedTabFeature { Reduce { state, action in switch action { case - let .feed(.openProfile(id)), - let .path(.element(_, action: .feed(.openProfile(id)))): + let .feed(.delegate(.openProfile(id))), + let .path(.element(_, action: .delegate(.openProfile(id)))): state.path.append(.profile(.external(.init(model: .init( id: id, username: "\(id)" @@ -73,11 +93,11 @@ public struct FeedTabFeature { return .none } } - .forEach( - \State.path, - action: \.path, - destination: Path.init - ) + .forEach( + \State.path, + action: \.path, + destination: Path.init + ) } } } diff --git a/Example/Sources/MainFeature/MainFeature.swift b/Example/Sources/MainFeature/MainFeature.swift index 9530724..7ca3ab7 100644 --- a/Example/Sources/MainFeature/MainFeature.swift +++ b/Example/Sources/MainFeature/MainFeature.swift @@ -7,8 +7,8 @@ public struct MainFeature { @ObservableState public struct State: Equatable { public init( - feed: FeedTabFeature.State, - profile: ProfileTabFeature.State, + feed: FeedTabFeature.State = .init(), + profile: ProfileTabFeature.State = .init(), selectedTab: Tab = .feed ) { self.feed = feed diff --git a/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift b/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift index 7cca49b..38057bb 100644 --- a/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift +++ b/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift @@ -10,6 +10,7 @@ public struct ProfileAndFeedPivot { case profile(UserProfileFeature.State) } + @CasePathable public enum Action: Equatable { case feed(TweetsFeedFeature.Action) case profile(UserProfileFeature.Action) diff --git a/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift b/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift index 9b97613..58d9a4d 100644 --- a/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift +++ b/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift @@ -18,6 +18,7 @@ public struct ProfileFeedFeature { } } + @CasePathable public enum Action: Equatable { case tweets(IdentifiedActionOf) case openProfile(USID) diff --git a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift index cea0711..8a1ddf5 100644 --- a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift +++ b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift @@ -19,6 +19,7 @@ public struct ProfileTabFeature { case profile(CurrentUserProfileFeature.State) } + @CasePathable public enum Action: Equatable { case auth(AuthFeature.Action) case profile(CurrentUserProfileFeature.Action) @@ -60,7 +61,7 @@ public struct ProfileTabFeature { public var body: some ReducerOf { Reduce { state, action in switch action { - case let .path(.element(_, action: .feed(.openProfile(id)))): + case let .path(.element(_, action: .feed(.delegate(.openProfile(id))))): state.path.append(.profile(.external(.init(model: .init( id: id, username: "\(id)" diff --git a/Example/Sources/TweetDetailFeature/TweetDetailController.swift b/Example/Sources/TweetDetailFeature/TweetDetailController.swift index 0611cc4..93e453c 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailController.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailController.swift @@ -22,6 +22,11 @@ public final class TweetDetailController: ComposableViewControllerOf? + public init( source: TweetFeature.State, - replies: TweetsListFeature.State, - detail: TweetDetailFeature.State? = nil + replies: TweetsListFeature.State = .init(), + detail: TweetDetailFeature.State? = nil, + alert: AlertState? = nil ) { self.source = source self.replies = replies self.detail = detail + self.alert = alert } } + @CasePathable public enum Action: Equatable { case source(TweetFeature.Action) case replies(TweetsListFeature.Action) case detail(PresentationAction) - case openProfile(USID) - case openDetail(TweetDetailFeature.State) + case fetchMoreReplies + + case alert(Alert) + case delegate(Delegate) + case event(Event) + + @CasePathable + public enum Delegate: Equatable { + case openProfile(USID) + } + + @CasePathable + public enum Alert: Equatable { + case close + case didTapRecoveryOption(String) + } + + @CasePathable + public enum Event: Equatable { + case didAppear + case didFetchReplies(Result<[TweetModel], APIClient.Error>) + } } @Dependency(\.apiClient) @@ -43,37 +69,78 @@ public struct TweetDetailFeature { CombineReducers { Reduce { state, action in switch action { + case .source(.tapOnAuthor): + return .send(.delegate(.openProfile(state.source.id))) + case - let .source(.openProfile(id)), - let .replies(.openProfile(id)), - let .detail(.presented(.openProfile(id))): - return .send(.openProfile(id)) + let .replies(.delegate(.openProfile(id))), + let .detail(.presented(.delegate(.openProfile(id)))): + return .send(.delegate(.openProfile(id))) - case let .replies(.openDetail(id)): - guard let selectedTweet = state.replies.tweets[id: id] - else { return .none } + default: + return .none + } + } + + Pullback(\.replies.delegate.openDetail) { state, id in + guard let tweet = state.replies.tweets[id: id] + else { return .none } + state.detail = .init(source: tweet) + return .none + } + + Pullback(\.event.didAppear) { state in + return .send(.fetchMoreReplies) + } + + Reduce { state, action in + switch action { + case .fetchMoreReplies: + let id = state.source.id + let repliesCount = state.replies.tweets.count return .run { send in - do { - let replies = try await apiClient.tweet.fetchReplies(for: id).get() - await send(.openDetail(.init(source: selectedTweet, replies: .init( - tweets: .init(uniqueElements: replies.map { $0.convert(to: .tweetFeature) })) - ))) - } catch { - #warning("Error is not handled") - fatalError(error.localizedDescription) - } + await send(.event(.didFetchReplies( + apiClient.tweet.fetchReplies( + for: id, + page: repliesCount / 10, + limit: 10 + ) + ))) } - case let .openDetail(detail): - state.detail = detail - return .none + case let .event(.didFetchReplies(replies)): + switch replies { + case let .success(replies): + let tweets = replies.map { $0.convert(to: .tweetFeature) } + state.replies.tweets.append(contentsOf: tweets) + return .none + + case let .failure(error): + state.alert = makeAlert(for: error) + return .none + } default: return .none } } + Reduce { state, action in + switch action { + case .alert(.close): + state.alert = nil + return .none + + case let .alert(.didTapRecoveryOption(deeplink)): + #warning("TODO: Handle deeplink") + return .none + + default: + return .none + } + } + Scope( state: \State.source, action: \.source, @@ -92,4 +159,17 @@ public struct TweetDetailFeature { destination: { TweetDetailFeature() } ) } + + func makeAlert(for error: APIClient.Error) -> AlertState { + .init( + title: { + TextState(error.message) + }, + actions: { + ButtonState(role: .cancel, action: .send(.close)) { + TextState("") + } + } + ) + } } diff --git a/Example/Sources/TweetFeature/TweetFeature.swift b/Example/Sources/TweetFeature/TweetFeature.swift index 49ad243..fe7cdcc 100644 --- a/Example/Sources/TweetFeature/TweetFeature.swift +++ b/Example/Sources/TweetFeature/TweetFeature.swift @@ -1,6 +1,7 @@ import _ComposableArchitecture import LocalExtensions import AppModels +import APIClient @Reducer public struct TweetFeature { @@ -12,20 +13,24 @@ public struct TweetFeature { public struct AuthorState: Equatable { public var id: USID public var avatarURL: URL? + public var displayName: String public var username: String public init( id: USID, avatarURL: URL? = nil, + displayName: String = "", username: String ) { self.id = id self.avatarURL = avatarURL + self.displayName = displayName self.username = username } } public var id: USID + public var createdAt: Date public var replyTo: USID? public var repliesCount: Int public var isLiked: Bool @@ -37,6 +42,7 @@ public struct TweetFeature { public init( id: USID, + createdAt: Date = .now, replyTo: USID? = nil, repliesCount: Int = 0, isLiked: Bool = false, @@ -47,6 +53,7 @@ public struct TweetFeature { text: String ) { self.id = id + self.createdAt = createdAt self.replyTo = replyTo self.repliesCount = repliesCount self.isLiked = isLiked @@ -58,27 +65,49 @@ public struct TweetFeature { } } - public enum Action: Equatable { + @CasePathable + public enum Action: Equatable, BindableAction { case tap case tapOnAuthor - case openDetail(for: USID) - case openProfile(USID) + case reply, toggleLike, repost, share + case binding(BindingAction) } - public func reduce( - into state: inout State, - action: Action - ) -> Effect { - switch action { - case .tap: - return .send(.openDetail(for: state.id)) + @Dependency(\.apiClient) + var apiClient - case .tapOnAuthor: - return.send(.openProfile(state.author.id)) + public var body: some ReducerOf { + Reduce { state, action in + #warning("Cancel pending effects as needed") + switch action { + case .reply: + return .none + case .toggleLike: + let id = state.id + let newIsLiked = !state.isLiked + let oldLikesCount = state.likesCount - default: - return .none + state.isLiked = newIsLiked + state.likesCount += newIsLiked ? 1 : -1 + + return .run { send in + do { try await apiClient.tweet.like(id: id, value: newIsLiked).get() } + catch { + await send(.binding(.set(\.isLiked, !newIsLiked))) + await send(.binding(.set(\.likesCount, oldLikesCount))) + } + } + case .repost: + return .none + + case .share: + return .none + + default: + return .none + } } + BindingReducer() } } @@ -86,6 +115,7 @@ extension Convertion where From == TweetModel, To == TweetFeature.State { public static var tweetFeature: Convertion { return .init { .init( id: $0.id, + createdAt: $0.createdAt, replyTo: $0.replyTo, repliesCount: $0.repliesCount, isLiked: $0.isLiked, @@ -103,6 +133,7 @@ extension Convertion where From == TweetModel.AuthorModel, To == TweetFeature.St return .init { .init( id: $0.id, avatarURL: $0.avatarURL, + displayName: $0.displayName, username: $0.username )} } diff --git a/Example/Sources/TweetFeature/TweetView.swift b/Example/Sources/TweetFeature/TweetView.swift index 1ed7a34..d955f73 100644 --- a/Example/Sources/TweetFeature/TweetView.swift +++ b/Example/Sources/TweetFeature/TweetView.swift @@ -1,38 +1,148 @@ import _ComposableArchitecture import SwiftUI +import AppUI public struct TweetView: ComposableView { - let store: StoreOf + private let store: StoreOf + + @Environment(\.colorTheme) + var color + + private let dateFormatter = DateFormatter { $0 + .dateStyle(.short) + } public init(_ store: StoreOf) { self.store = store } public var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 16) { - Circle() // Avatar - .fill(Color(.label).opacity(0.3)) - .frame(width: 54, height: 54) - Text("@" + store.author.username.lowercased()).bold() - Spacer() + _body + .scaledFont(ofSize: 14) + .padding(.horizontal) + .background( + color(\.background.primary) + .onTapGesture { + store.send(.tap) + } + ) + } + + @ViewBuilder + private var _body: some View { + HStack(alignment: .top) { + makeAvatar(store.author.avatarURL) + VStack(alignment: .leading, spacing: 7) { + makeHeader( + displayName: store.author.displayName, + username: store.author.username, + creationDate: store.createdAt + ) + makeContent(store.text) + GeometryReader { proxy in + HStack(spacing: 0) { + makeButton( + systemIcon: "message", + tint: color(\.label.secondary), + counter: store.repliesCount, + action: .reply + ) + .frame(width: proxy.size.width / 6, alignment: .leading) + makeButton( + systemIcon: store.isLiked ? "heart.fill" : "heart", + tint: store.isLiked ? color(\.like) : color(\.label.secondary), + counter: store.likesCount, + action: .toggleLike + ) + .frame(width: proxy.size.width / 3, alignment: .center) + makeButton( + systemIcon: "arrow.2.squarepath", + tint: store.isReposted ? color(\.done) : color(\.label.secondary), + counter: store.repostsCount, + action: .repost + ) + .frame(width: proxy.size.width / 3, alignment: .center) + makeButton( + systemIcon: "square.and.arrow.up", + tint: color(\.label.secondary), + action: .share + ) + .frame(width: proxy.size.width / 6, alignment: .trailing) + } + } + .frame(height: 18) } + } + } + + @ViewBuilder + private func makeAvatar( + _ avatarURL: URL? + ) -> some View { + Circle() // Avatar + .stroke(color(\.label.secondary).opacity(0.3)) + .frame(width: 44, height: 44) .contentShape(Rectangle()) .onTapGesture { store.send(.tapOnAuthor) } - Text(store.text) - .onTapGesture { - store.send(.tap) - } + } + + @ViewBuilder + private func makeHeader( + displayName: String, + username: String, + creationDate: Date + ) -> some View { + HStack { + if displayName.isNotEmpty { + Text(displayName) + .fontWeight(.bold) + .foregroundStyle(color(\.label)) + .layoutPriority(2) + Text("@" + username.lowercased()) + } else { + Text("@" + username.lowercased()) + .fontWeight(.bold) + .foregroundStyle(color(\.label)) + .layoutPriority(2) + } + Text("• \(dateFormatter.string(from: creationDate))") + .layoutPriority(1) } - .padding(.horizontal) - .background( - Color(.systemBackground) - .onTapGesture { - store.send(.tap) + .foregroundStyle(color(\.label.secondary)) + .fontWeight(.light) + .lineLimit(1) + } + + @ViewBuilder + private func makeContent( + _ text: String + ) -> some View { + Text(text) + .foregroundStyle(color(\.label)) + } + + @ViewBuilder + private func makeButton( + systemIcon: String, + tint: Color, + counter: Int? = nil, + action: Action + ) -> some View { + Button(action: { store.send(action) }) { + HStack(spacing: 4) { + Image(systemName: systemIcon) + + if let counter, counter > 0 { + Text(counter.description) + .scaledFont(ofSize: 12) + .transition(.scale) } - ) + } + } + .tint(tint) + .foregroundStyle(tint) } } diff --git a/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift index 1bf2575..b3268d4 100644 --- a/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift @@ -21,6 +21,11 @@ public final class TweetsFeedController: ComposableViewControllerOf? + public init( list: TweetsListFeature.State = .init(), - detail: TweetDetailFeature.State? = nil + detail: TweetDetailFeature.State? = nil, + alert: AlertState? = nil ) { self.list = list self.detail = detail } } + @CasePathable public enum Action: Equatable { case list(TweetsListFeature.Action) case detail(PresentationAction) - case openProfile(USID) - case openDetail(TweetDetailFeature.State) + case fetchMoreTweets + case alert(Alert) + case event(Event) + case delegate(Delegate) + + @CasePathable + public enum Alert: Equatable { + case close + case didTapRecoveryOption(String) + } + + @CasePathable + public enum Event: Equatable { + case didAppear + case didFetchTweets(Result<[TweetModel], APIClient.Error>) + } + + @CasePathable + public enum Delegate: Equatable { + case openProfile(USID) + } } @Dependency(\.apiClient) var apiClient public var body: some ReducerOf { - Reduce { state, action in - switch action { - case - let .list(.tweets(.element(_, .openProfile(id)))), - let .detail(.presented(.openProfile(id))): - return .send(.openProfile(id)) - - case let .list(.tweets(.element(itemID, .openDetail))): - return .run { send in - do { - let tweet = try await apiClient.tweet.fetch(id: itemID).get() - let replies = try await apiClient.tweet.fetchReplies(for: itemID).get() - - await send(.openDetail(.init( - source: tweet.convert(to: .tweetFeature), - replies: .init(tweets: .init( - uniqueElements: replies.map { $0.convert(to: .tweetFeature) } - )) + CombineReducers { + Reduce { state, action in + switch action { + case + let .list(.delegate(.openProfile(id))), + let .detail(.presented(.delegate(.openProfile(id)))): + return .send(.delegate(.openProfile(id))) + + case let .list(.delegate(.openDetail(id))): + guard let tweet = state.list.tweets[id: id] + else { return .none } + + state.detail = .init(source: tweet) + return .none + + default: + return .none + } + } + + Pullback(\.event.didAppear) { state in + return .send(.fetchMoreTweets) + } + + Reduce { state, action in + switch action { + case .fetchMoreTweets: + let tweetsCount = state.list.tweets.count + return .run { send in + await send(.event(.didFetchTweets( + apiClient.feed.fetchTweets( + page: tweetsCount / 10, + limit: 10 + ) ))) - } catch { - #warning("Error is not handled") - fatalError("ErrorIsNotHandled: \(error.localizedDescription)") } + case let .event(.didFetchTweets(tweets)): + switch tweets { + case let .success(tweets): + let tweets = tweets.map { $0.convert(to: .tweetFeature) } + state.list.tweets.append(contentsOf: tweets) + return .none + + case let .failure(error): + state.alert = makeAlert(for: error) + return .none + } + + default: + return .none } + } + + Reduce { state, action in + switch action { + case .alert(.close): + state.alert = nil + return .none - case let .openDetail(detail): - state.detail = detail - return .none + case let .alert(.didTapRecoveryOption(deeplink)): + #warning("TODO: Handle deeplink") + return .none - default: - return .none + default: + return .none + } } } .ifLet( @@ -80,4 +140,17 @@ public struct TweetsFeedFeature { child: TweetsListFeature.init ) } + + func makeAlert(for error: APIClient.Error) -> AlertState { + .init( + title: { + TextState(error.message) + }, + actions: { + ButtonState(role: .cancel, action: .send(.close)) { + TextState("") + } + } + ) + } } diff --git a/Example/Sources/TweetsListFeature/TweetsListFeature.swift b/Example/Sources/TweetsListFeature/TweetsListFeature.swift index d35a7b4..8aa9c13 100644 --- a/Example/Sources/TweetsListFeature/TweetsListFeature.swift +++ b/Example/Sources/TweetsListFeature/TweetsListFeature.swift @@ -15,20 +15,26 @@ public struct TweetsListFeature { public var tweets: IdentifiedArrayOf } + @CasePathable public enum Action: Equatable { case tweets(IdentifiedActionOf) - case openDetail(for: USID) - case openProfile(USID) + case delegate(Delegate) + + @CasePathable + public enum Delegate: Equatable { + case openDetail(USID) + case openProfile(USID) + } } public var body: some ReducerOf { Reduce { state, action in switch action { - case let .tweets(.element(_, .openProfile(id))): - return .send(.openProfile(id)) + case let .tweets(.element(id, .tap)): + return .send(.delegate(.openDetail(id))) - case let .tweets(.element(_, .openDetail(id))): - return .send(.openDetail(for: id)) + case let .tweets(.element(id, .tapOnAuthor)): + return .send(.delegate(.openProfile(id))) default: return .none diff --git a/Example/Sources/UserProfileFeature/UserProfileFeature.swift b/Example/Sources/UserProfileFeature/UserProfileFeature.swift index 3aeb3a8..ad4d47e 100644 --- a/Example/Sources/UserProfileFeature/UserProfileFeature.swift +++ b/Example/Sources/UserProfileFeature/UserProfileFeature.swift @@ -1,6 +1,7 @@ import _ComposableArchitecture import ExternalUserProfileFeature import CurrentUserProfileFeature +import LocalExtensions @Reducer public struct UserProfileFeature { @@ -12,22 +13,39 @@ public struct UserProfileFeature { case current(CurrentUserProfileFeature.State) } + @CasePathable public enum Action: Equatable { case external(ExternalUserProfileFeature.Action) case current(CurrentUserProfileFeature.Action) - } + case delegate(Delegate) + @CasePathable + public enum Delegate: Equatable { + case openProfile(USID) + } + } + public var body: some ReducerOf { - EmptyReducer() - .ifCaseLet( - \.external, - action: \.external, - then: ExternalUserProfileFeature.init - ) - .ifCaseLet( - \.current, - action: \.current, - then: CurrentUserProfileFeature.init - ) + Reduce { state, action in + switch action { + case + let .current(.delegate(.openProfile(id))), + let .external(.delegate(.openProfile(id))): + return .send(.delegate(.openProfile(id))) + + default: + return .none + } + } + .ifCaseLet( + \.external, + action: \.external, + then: ExternalUserProfileFeature.init + ) + .ifCaseLet( + \.current, + action: \.current, + then: CurrentUserProfileFeature.init + ) } } diff --git a/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift b/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift index 3a63ed9..0f8c1bc 100644 --- a/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift +++ b/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift @@ -16,6 +16,7 @@ public struct UserSettingsFeature { } } + @CasePathable public enum Action: Equatable {} public var body: some ReducerOf { From 9c975ec8cdb254a3a2a7a5ce282aece5c9a9e409 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 30 Dec 2023 14:44:48 +0100 Subject: [PATCH 25/43] fix: Sync back TweetDetail state --- .../TweetDetailFeature/TweetDetailFeature.swift | 16 +++++++++++++++- .../TweetsFeedFeature/TweetsFeedFeature.swift | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift index f2fb7d0..2ed6c6f 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift @@ -156,8 +156,9 @@ public struct TweetDetailFeature { .ifLet( \State.$detail, action: \.detail, - destination: { TweetDetailFeature() } + destination: TweetDetailFeature.init ) + .syncTweetDetailSource(\.$detail, with: \.replies) } func makeAlert(for error: APIClient.Error) -> AlertState { @@ -173,3 +174,16 @@ public struct TweetDetailFeature { ) } } + +extension Reducer { + public func syncTweetDetailSource( + _ toTweetDetail: @escaping (State) -> PresentationState, + with toTweetsListState: WritableKeyPath + ) -> some ReducerOf { + onChange(of: { toTweetDetail($0).wrappedValue?.source }) { state, old, new in + guard let tweet = new else { return .none } + state[keyPath: toTweetsListState].tweets[id: tweet.id] = tweet + return .none + } + } +} diff --git a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift index ab1832f..a3fdf83 100644 --- a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift @@ -133,6 +133,7 @@ public struct TweetsFeedFeature { action: \.detail, destination: TweetDetailFeature.init ) + .syncTweetDetailSource(\.$detail, with: \.list) Scope( state: \State.list, From e2043a8f50dc6fa6bfe875c98f038a78e92033d2 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 30 Dec 2023 15:33:49 +0100 Subject: [PATCH 26/43] feat: ProfileTabController --- Example/Sources/AppModels/Exports.swift | 15 +++- .../Versions/V1/V1+TweetModel.swift | 2 +- Example/Sources/MainFeature/MainFeature.swift | 7 ++ .../MainFeature/MainViewController.swift | 12 ++- .../ProfileTabController.swift | 55 ++++++++++++++ .../ProfileTabFeature/ProfileTabFeature.swift | 76 +++++++++++++++---- 6 files changed, 148 insertions(+), 19 deletions(-) create mode 100644 Example/Sources/ProfileTabFeature/ProfileTabController.swift diff --git a/Example/Sources/AppModels/Exports.swift b/Example/Sources/AppModels/Exports.swift index 606d2b6..7000fbc 100644 --- a/Example/Sources/AppModels/Exports.swift +++ b/Example/Sources/AppModels/Exports.swift @@ -1,5 +1,6 @@ import _Dependencies import LocalExtensions +import Combine extension DependencyValues { public var currentUser: CurrentUserIDContainer { @@ -9,11 +10,19 @@ extension DependencyValues { } public struct CurrentUserIDContainer { - @Reference - public var id: USID? + private let _idSubject: CurrentValueSubject + + public var id: USID? { + get { _idSubject.value } + nonmutating set { _idSubject.send(newValue) } + } + + public var idPublisher: some Publisher { + return _idSubject + } public init(id: USID? = nil) { - self.id = id + self._idSubject = .init(id) } } diff --git a/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift b/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift index a6a3608..d127c1b 100644 --- a/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift +++ b/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift @@ -3,7 +3,7 @@ import LocalExtensions extension DatabaseSchema.V1 { @Model - public final class TweetModel: Equatable, Identifiable, Sendable { + public final class TweetModel: Equatable, Identifiable, @unchecked Sendable { @Attribute(.unique) public let id: String public var createdAt: Date diff --git a/Example/Sources/MainFeature/MainFeature.swift b/Example/Sources/MainFeature/MainFeature.swift index 7ca3ab7..4e34500 100644 --- a/Example/Sources/MainFeature/MainFeature.swift +++ b/Example/Sources/MainFeature/MainFeature.swift @@ -27,10 +27,17 @@ public struct MainFeature { } } + @CasePathable public enum Action: Equatable, BindableAction { case feed(FeedTabFeature.Action) case profile(ProfileTabFeature.Action) case binding(BindingAction) + case event(Event) + + @CasePathable + public enum Event: Equatable { + case didAppear + } } public init() {} diff --git a/Example/Sources/MainFeature/MainViewController.swift b/Example/Sources/MainFeature/MainViewController.swift index 6c71c03..f478f3f 100644 --- a/Example/Sources/MainFeature/MainViewController.swift +++ b/Example/Sources/MainFeature/MainViewController.swift @@ -8,7 +8,7 @@ import ProfileTabFeature public final class MainViewController: ComposableTabBarControllerOf, UITabBarControllerDelegate { let feedTabController: FeedTabController = .init() - let profileTabController: UIViewController = .init() + let profileTabController: ProfileTabController = .init() public override func _init() { super._init() @@ -38,11 +38,21 @@ public final class MainViewController: ComposableTabBarControllerOf ) } + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.store?.send(.event(.didAppear)) + } + public override func scope(_ store: Store?) { feedTabController.setStore(store?.scope( state: \.feed, action: \.feed )) + + profileTabController.setStore(store?.scope( + state: \.profile, + action: \.profile + )) } public override func bind( diff --git a/Example/Sources/ProfileTabFeature/ProfileTabController.swift b/Example/Sources/ProfileTabFeature/ProfileTabController.swift new file mode 100644 index 0000000..f9a3990 --- /dev/null +++ b/Example/Sources/ProfileTabFeature/ProfileTabController.swift @@ -0,0 +1,55 @@ +import _ComposableArchitecture +import UIKit +import SwiftUI +import Combine +import CombineExtensions +import Capture +import CombineNavigation +import DeclarativeConfiguration +import AppUI + +#warning("Implement ProfileTabController") +@RoutingController +public final class ProfileTabController: ComposableViewControllerOf { + let label: UILabel = .init { $0 + .translatesAutoresizingMaskIntoConstraints(false) + .textColor(ColorTheme.current.label.primary) + } + + public override func viewDidLoad() { + super.viewDidLoad() + view.addSubview(label) + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + view.backgroundColor = ColorTheme.current.background.primary + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.store?.send(.event(.didAppear)) + } + + public override func scope(_ store: Store?) { + + } + + public override func bind( + _ publisher: StorePublisher, + into cancellables: inout Set + ) { + publisher.root + .sinkValues(capture { _self, root in + switch root { + case .auth(.signIn): + _self.label.text = "Sign In" + case .auth(.signUp): + _self.label.text = "Sign Up" + case let .profile(state): + _self.label.text = state.model.username + } + }) + .store(in: &cancellables) + } +} diff --git a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift index 8a1ddf5..c7020e3 100644 --- a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift +++ b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift @@ -4,6 +4,7 @@ import ProfileAndFeedPivot import UserProfileFeature import TweetsFeedFeature import CurrentUserProfileFeature +import LocalExtensions @Reducer public struct ProfileTabFeature { @@ -23,9 +24,14 @@ public struct ProfileTabFeature { public enum Action: Equatable { case auth(AuthFeature.Action) case profile(CurrentUserProfileFeature.Action) + case setState(State) } public var body: some ReducerOf { + Pullback(\.setState) { state, newState in + state = newState + return .none + } Scope( state: /State.auth, action: /Action.auth, @@ -55,34 +61,76 @@ public struct ProfileTabFeature { @CasePathable public enum Action: Equatable { + case root(Root.Action) case path(StackAction) + case event(Event) + + @CasePathable + public enum Event: Equatable { + case didAppear + case didChangeUserID(USID?) + } } + @Dependency(\.currentUser) + var currentUser + + @Dependency(\.apiClient) + var apiClient + public var body: some ReducerOf { + Pullback(\.event.didAppear) { state in + return .publisher { + currentUser.idPublisher + .map(Action.Event.didChangeUserID) + .map(Action.event) + } + } + Pullback(\.event.didChangeUserID) { state, id in + guard let id else { + return .send(.root(.setState(.auth(.signIn())))) + } + + return .run { send in + switch await apiClient.user.fetch(id: id) { + case let .success(user): + await send(.root(.setState(.profile(.init(model: user))))) + + case let .failure(error): + #warning("Handle error with alert") + await send(.root(.setState(.auth(.signIn())))) + } + } + } Reduce { state, action in switch action { case let .path(.element(_, action: .feed(.delegate(.openProfile(id))))): state.path.append(.profile(.external(.init(model: .init( - id: id, - username: "\(id)" - ))))) + id: id, + username: "\(id)" + ))))) return .none -// case let .path(.element(stackID, .profile(.user(.tweetsList(.tweets(.element(_, .tap))))))): -// guard case let .profile(.external(profile)) = state.path[id: stackID] -// else { return .none } -// -// state.path.append(.feed(.init(list: profile.tweetsList))) -// return .none + // case let .path(.element(stackID, .profile(.user(.tweetsList(.tweets(.element(_, .tap))))))): + // guard case let .profile(.external(profile)) = state.path[id: stackID] + // else { return .none } + // + // state.path.append(.feed(.init(list: profile.tweetsList))) + // return .none default: return .none } } - .forEach( - \State.path, - action: \.path, - destination: Path.init - ) + .forEach( + \State.path, + action: \.path, + destination: Path.init + ) + Scope( + state: \.root, + action: \.root, + child: Root.init + ) } } From a73cca6adddd425a4a296fbc3cc95164ace279fc Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sat, 30 Dec 2023 15:34:40 +0100 Subject: [PATCH 27/43] feat(TweetsListFeature): Content placeholder --- .../TweetDetailFeature.swift | 1 + .../TweetsFeedFeature/TweetsFeedFeature.swift | 6 +++- .../TweetsListFeature/TweetsListFeature.swift | 13 +++++++- .../TweetsListFeature/TweetsListView.swift | 30 +++++++++++++------ 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift index 2ed6c6f..bf286bd 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift @@ -114,6 +114,7 @@ public struct TweetDetailFeature { case let .success(replies): let tweets = replies.map { $0.convert(to: .tweetFeature) } state.replies.tweets.append(contentsOf: tweets) + state.replies.placeholder = .text() return .none case let .failure(error): diff --git a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift index a3fdf83..faf203b 100644 --- a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift @@ -81,7 +81,10 @@ public struct TweetsFeedFeature { } Pullback(\.event.didAppear) { state in - return .send(.fetchMoreTweets) + return .run { send in + try await Task.sleep(nanoseconds: 2_500_000_000) + await send(.fetchMoreTweets) + } } Reduce { state, action in @@ -101,6 +104,7 @@ public struct TweetsFeedFeature { case let .success(tweets): let tweets = tweets.map { $0.convert(to: .tweetFeature) } state.list.tweets.append(contentsOf: tweets) + state.list.placeholder = .text() return .none case let .failure(error): diff --git a/Example/Sources/TweetsListFeature/TweetsListFeature.swift b/Example/Sources/TweetsListFeature/TweetsListFeature.swift index 8aa9c13..d08414f 100644 --- a/Example/Sources/TweetsListFeature/TweetsListFeature.swift +++ b/Example/Sources/TweetsListFeature/TweetsListFeature.swift @@ -8,11 +8,22 @@ public struct TweetsListFeature { @ObservableState public struct State: Equatable { - public init(tweets: IdentifiedArrayOf = []) { + @ObservableState + public enum Placeholder: Equatable { + case text(String = "Nothing here yet 😢") + case activityIndicator + } + + public init( + tweets: IdentifiedArrayOf = [], + placeholder: Placeholder? = .activityIndicator + ) { self.tweets = tweets + self.placeholder = placeholder } public var tweets: IdentifiedArrayOf + public var placeholder: Placeholder? } @CasePathable diff --git a/Example/Sources/TweetsListFeature/TweetsListView.swift b/Example/Sources/TweetsListFeature/TweetsListView.swift index 9d6c77c..3ba2e20 100644 --- a/Example/Sources/TweetsListFeature/TweetsListView.swift +++ b/Example/Sources/TweetsListFeature/TweetsListView.swift @@ -10,16 +10,28 @@ public struct TweetsListView: ComposableView { } public var body: some View { - ScrollView(.vertical) { - LazyVStack(spacing: 24) { - ForEachStore( - store.scope( - state: \.tweets, - action: \.tweets - ), - content: TweetView.init - ) + if store.tweets.isNotEmpty { + ScrollView(.vertical) { + LazyVStack(spacing: 24) { + ForEach(store.tweets) { tweet in + if let store = store.scope(state: \.tweets[id: tweet.id], action: \.tweets[id: tweet.id]) { + TweetView(store) + } + } + } + } + } else { + ZStack { + switch store.placeholder { + case let .text(text): + Text(text) + case .activityIndicator: + ProgressView() + case .none: + EmptyView() + } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } } From bbf8063648a24ecceb242b8675b22d0d370d3ac3 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 31 Dec 2023 01:20:05 +0100 Subject: [PATCH 28/43] feat: API Improvements - Use CasePaths reflection to get rid of Route hashability constraint for navigationDestinations - Get rid of redundant Hashable conformance of Route in navigationStacks - Get rid of CombineExtensions dependency --- Package.swift | 24 +++--- .../CocoaViewController+API.swift | 5 +- .../CombineNavigationRouter+API.swift | 78 +++++-------------- .../Internal/CombineNavigationRouter.swift | 4 +- .../RoutingControllerTests.swift | 4 +- .../RoutingControllerTreeTests.swift | 2 +- 6 files changed, 38 insertions(+), 79 deletions(-) diff --git a/Package.swift b/Package.swift index 9c2c6e2..b118e2b 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,10 @@ let package = Package( ), ], dependencies: [ + .package( + url: "https://github.com/apple/swift-docc-plugin.git", + .upToNextMajor(from: "1.3.0") + ), .package( url: "https://github.com/capturecontext/swift-capture.git", .upToNextMajor(from: "3.0.1") @@ -27,25 +31,21 @@ let package = Package( url: "https://github.com/capturecontext/cocoa-aliases.git", .upToNextMajor(from: "2.0.5") ), - .package( - url: "https://github.com/capturecontext/combine-extensions.git", - .upToNextMinor(from: "0.1.0") - ), .package( url: "https://github.com/capturecontext/swift-foundation-extensions.git", .upToNextMinor(from: "0.4.0") ), .package( - url: "https://github.com/stackotter/swift-macro-toolkit.git", - .upToNextMinor(from: "0.3.0") + url: "https://github.com/pointfreeco/swift-case-paths", + .upToNextMajor(from: "1.0.0") ), .package( url: "https://github.com/pointfreeco/swift-macro-testing.git", .upToNextMinor(from: "0.2.0") ), .package( - url: "https://github.com/apple/swift-docc-plugin.git", - .upToNextMajor(from: "1.3.0") + url: "https://github.com/stackotter/swift-macro-toolkit.git", + .upToNextMinor(from: "0.3.0") ), ], targets: [ @@ -58,12 +58,12 @@ let package = Package( package: "swift-capture" ), .product( - name: "CocoaAliases", - package: "cocoa-aliases" + name: "CasePaths", + package: "swift-case-paths" ), .product( - name: "CombineExtensions", - package: "combine-extensions" + name: "CocoaAliases", + package: "cocoa-aliases" ), .product( name: "FoundationExtensions", diff --git a/Sources/CombineNavigation/CocoaViewController+API.swift b/Sources/CombineNavigation/CocoaViewController+API.swift index 808c74e..218b753 100644 --- a/Sources/CombineNavigation/CocoaViewController+API.swift +++ b/Sources/CombineNavigation/CocoaViewController+API.swift @@ -1,7 +1,7 @@ #if canImport(UIKit) && !os(watchOS) import Capture import CocoaAliases -import CombineExtensions +import Combine import FoundationExtensions // MARK: - Public API @@ -14,7 +14,7 @@ extension RoutingController { public func navigationStack< P: Publisher, C: Collection & Equatable, - Route: Hashable + Route >( _ publisher: P, switch destination: @escaping (Destinations, Route) -> any GrouppedDestinationProtocol, @@ -90,7 +90,6 @@ extension RoutingController { switch destination: @escaping (Destinations, Route) -> SingleDestinationProtocol, onPop: @escaping () -> Void ) -> AnyCancellable where - Route: Hashable, P.Output == Route?, P.Failure == Never { diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift index 69bb96a..053ca1e 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift @@ -1,8 +1,9 @@ #if canImport(UIKit) && !os(watchOS) import Capture import CocoaAliases -import CombineExtensions +import Combine import FoundationExtensions +@_spi(Reflection) import CasePaths // MARK: - Public API @@ -13,8 +14,8 @@ extension CombineNavigationRouter { @usableFromInline func navigationStack< P: Publisher, - C: Collection & Equatable, - Route: Hashable + C: Collection, + Route >( _ publisher: P, switch destination: @escaping (Route) -> any GrouppedDestinationProtocol, @@ -40,7 +41,7 @@ extension CombineNavigationRouter { func navigationStack< P: Publisher, C: Collection & Equatable, - Route: Hashable + Route >( _ publisher: P, switch controller: @escaping (Route, C.Index) -> CocoaViewController, @@ -80,8 +81,8 @@ extension CombineNavigationRouter { P.Failure == Never, IDs.Element: Hashable { - navigationStack( - publisher: publisher, + _navigationStack( + publisher: publisher.removeDuplicates(by: { ids($0) == ids($1) }), routes: capture(orReturn: []) { _self, stack in ids(stack).compactMap { id in route(stack, id).map { route in @@ -116,8 +117,8 @@ extension CombineNavigationRouter { P.Failure == Never, IDs.Element: Hashable { - navigationStack( - publisher: publisher, + _navigationStack( + publisher: publisher.removeDuplicates(by: { ids($0) == ids($1) }), routes: capture(orReturn: []) { _self, stack in ids(stack).compactMap { id in route(stack, id).map { route in @@ -131,7 +132,7 @@ extension CombineNavigationRouter { /// Subscribes on publisher of navigation stack state @usableFromInline - func navigationStack< + func _navigationStack< P: Publisher, Stack, DestinationID @@ -144,7 +145,7 @@ extension CombineNavigationRouter { P.Failure == Never { return publisher - .sinkValues(capture { _self, stack in + .sink(receiveValue: capture { _self, stack in let managedRoutes = routes(stack) _self.setRoutes( @@ -183,24 +184,6 @@ extension CombineNavigationRouter { ) } - /// Subscribes on publisher of navigation destination state - @usableFromInline - func navigationDestination( - _ id: AnyHashable, - isPresented publisher: P, - controller: @escaping () -> CocoaViewController, - onPop: @escaping () -> Void - ) -> AnyCancellable where - P.Output == Bool, - P.Failure == Never - { - navigationDestination( - publisher.map { $0 ? id : nil }, - switch: { _ in controller() }, - onPop: onPop - ) - } - /// Subscribes on publisher of navigation destination state @usableFromInline func navigationDestination( @@ -208,21 +191,21 @@ extension CombineNavigationRouter { switch destination: @escaping (Route) -> SingleDestinationProtocol, onPop: @escaping () -> Void ) -> AnyCancellable where - Route: Hashable, P.Output == Optional, P.Failure == Never { publisher + .removeDuplicates(by: { $0.flatMap(enumTag) == $1.flatMap(enumTag) }) .map { [weak self] (route) -> NavigationRoute? in guard let self, let route else { return nil } let destination = destination(route) return self.makeNavigationRoute( - for: route, + for: enumTag(route), controller: destination._initControllerIfNeeded, invalidationHandler: destination._invalidateDestination ) } - .sinkValues(capture { _self, route in + .sink(receiveValue: capture { _self, route in _self.setRoutes( route.map { [$0] }.or([]), onPop: route.map { route in @@ -234,34 +217,11 @@ extension CombineNavigationRouter { ) }) } +} - /// Subscribes on publisher of navigation destination state - @usableFromInline - func navigationDestination( - _ publisher: P, - switch controller: @escaping (Route) -> CocoaViewController, - onPop: @escaping () -> Void - ) -> AnyCancellable where - Route: Hashable, - P.Output == Optional, - P.Failure == Never - { - publisher - .map { [weak self] (route) -> NavigationRoute? in - guard let self, let route else { return nil } - return self.makeNavigationRoute(for: route) { controller(route) } - } - .sinkValues(capture { _self, route in - _self.setRoutes( - route.map { [$0] }.or([]), - onPop: route.map { route in - return { poppedRoutes in - let shouldTriggerPopHandler = poppedRoutes.contains(where: { $0 === route }) - if shouldTriggerPopHandler { onPop() } - } - } - ) - }) - } +/// Index of enum case in its declaration +@usableFromInline +internal func enumTag(_ `case`: Case) -> UInt32? { + EnumMetadata(Case.self)?.tag(of: `case`) } #endif diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift index 06ee658..9850572 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift @@ -114,7 +114,7 @@ extension CombineNavigationRouter { } else { node.publisher(for: \.navigationController) .compactMap { $0 } - .sinkValues(capture { $0.syncNavigationStack(using: $1) }) + .sink(receiveValue: capture { $0.syncNavigationStack(using: $1) }) .store(in: &navigationControllerCancellable) } } @@ -123,7 +123,7 @@ extension CombineNavigationRouter { navigationControllerCancellable = nil navigation.popPublisher - .sinkValues(capture { _self, controllers in + .sink(receiveValue: capture { _self, controllers in let routes = _self.routes.reduce(into: ( kept: [NavigationRoute](), popped: [NavigationRoute]() diff --git a/Tests/CombineNavigationTests/RoutingControllerTests.swift b/Tests/CombineNavigationTests/RoutingControllerTests.swift index 9be1f6b..f90978f 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTests.swift @@ -167,7 +167,7 @@ fileprivate class TreeViewController: CocoaViewController { && lhs.flatMap(\.stack).map(ObjectIdentifier.init) == rhs.flatMap(\.stack).map(ObjectIdentifier.init) - }.sinkValues(capture { _self, destination in + }.sink(receiveValue: capture { _self, destination in self.scope(_self.viewModel) }) .store(in: &cancellables) @@ -299,7 +299,7 @@ fileprivate class StackViewController: CocoaViewController { && lhs.compactMap(\.stack).map(ObjectIdentifier.init) == rhs.compactMap(\.stack).map(ObjectIdentifier.init) - }.sinkValues(capture { _self, destination in + }.sink(receiveValue: capture { _self, destination in self.scope(_self.viewModel) }) .store(in: &cancellables) diff --git a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift index 8bb8b18..42ad12a 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift @@ -104,7 +104,7 @@ fileprivate class TreeViewController: CocoaViewController { func bind>(_ publisher: P) { navigationDestination( - publisher.map(\.destination?.tag).removeDuplicates(), + publisher.map(\.destination), switch: { destinations, route in switch route { case .orderDetail: From 97881a26fd52789084d4e308d9272f97fc46f74f Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 31 Dec 2023 01:32:12 +0100 Subject: [PATCH 29/43] feat: RoutingController macro improvements --- .../Destinations/StackDestination.swift | 3 + .../Destinations/TreeDestination.swift | 3 + .../RoutingControllerMacro.swift | 4 +- .../RoutingControllerMacroTests.swift | 67 +++++++++++++++++-- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/Sources/CombineNavigation/Destinations/StackDestination.swift b/Sources/CombineNavigation/Destinations/StackDestination.swift index 7ef376e..5d59a14 100644 --- a/Sources/CombineNavigation/Destinations/StackDestination.swift +++ b/Sources/CombineNavigation/Destinations/StackDestination.swift @@ -15,6 +15,9 @@ public protocol GrouppedDestinationProtocol { } /// Wrapper for creating and accessing managed navigation stack controllers +/// +/// > ⚠️ Sublasses or typealiases must contain "StackDestination" in their name +/// > to be processed by `@RoutingController` macro @propertyWrapper open class StackDestination< DestinationID: Hashable, diff --git a/Sources/CombineNavigation/Destinations/TreeDestination.swift b/Sources/CombineNavigation/Destinations/TreeDestination.swift index 4c05b0b..7b26050 100644 --- a/Sources/CombineNavigation/Destinations/TreeDestination.swift +++ b/Sources/CombineNavigation/Destinations/TreeDestination.swift @@ -13,6 +13,9 @@ public protocol SingleDestinationProtocol { } /// Wrapper for creating and accessing managed navigation destination controller +/// +/// > ⚠️ Sublasses or typealiases must contain "TreeDestination" in their name +/// > to be processed by `@RoutingController` macro @propertyWrapper open class TreeDestination: Weakifiable, diff --git a/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift index be87927..36f51be 100644 --- a/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift +++ b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift @@ -65,7 +65,9 @@ extension RoutingControllerMacro: ExtensionMacro { let isNavigationChild = variable.attributes.contains { attribute in switch attribute { - case let .attribute(attribute) where attribute.name.name.hasSuffix("Destination"): + case let .attribute(attribute) where + attribute.name.name.contains("TreeDestination") || + attribute.name.name.contains("StackDestination"): return true default: return false diff --git a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift index 1708f7e..ca504b8 100644 --- a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift +++ b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift @@ -214,23 +214,23 @@ final class RoutingControllerMacroTests: XCTestCase { } } - func testAttachmentToClass_CustomDestinations() { + func testAttachmentToClass_CustomTreeDestinations() { assertMacro { """ @RoutingController final class CustomController { - @CustomDestination + @CustomTreeDestination var firstDetailController: CocoaViewController? - @CustomDestination + @CustomTreeDestination var secondDetailController: CocoaViewController? } """ } expansion: { """ final class CustomController { - @CustomDestination + @CustomTreeDestination var firstDetailController: CocoaViewController? - @CustomDestination + @CustomTreeDestination var secondDetailController: CocoaViewController? } @@ -242,10 +242,10 @@ final class RoutingControllerMacroTests: XCTestCase { /// Use in `navigationDestination`/`navigationStack` methods to map /// routes to specific destinations using `destinations` method public struct Destinations { - @CustomDestination + @CustomTreeDestination var firstDetailController: CocoaViewController? - @CustomDestination + @CustomTreeDestination var secondDetailController: CocoaViewController? public subscript(_ id: some Hashable) -> UIViewController? { @@ -263,4 +263,57 @@ final class RoutingControllerMacroTests: XCTestCase { """ } } + + func testAttachmentToClass_CustomStackDestinations() { + assertMacro { + """ + @RoutingController + final class CustomController { + @CustomStackDestination + var firstDetailController: [Int: CocoaViewController] + + @CustomTreeDestinationOf + var secondDetailController + } + """ + } expansion: { + """ + final class CustomController { + @CustomStackDestination + var firstDetailController: [Int: CocoaViewController] + + @CustomTreeDestinationOf + var secondDetailController + } + + extension CustomController: CombineNavigation.RoutingController { + /// Container for captured destinations without referring to self + /// + /// > Generated by `CombineNavigationMacros.RoutingController` macro + /// + /// Use in `navigationDestination`/`navigationStack` methods to map + /// routes to specific destinations using `destinations` method + public struct Destinations { + @CustomStackDestination + var firstDetailController: [Int: CocoaViewController] + + + @CustomTreeDestinationOf + var secondDetailController + + public subscript(_ id: Int) -> UIViewController? { + return firstDetailController[id] + } + } + + public func _makeDestinations() -> Destinations { + return Destinations( + firstDetailController: $firstDetailController, + secondDetailController: $secondDetailController + ) + } + } + """ + } + } } From dd6a47ee9e034774882f05fbce3357d88308a2bf Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 31 Dec 2023 03:13:25 +0100 Subject: [PATCH 30/43] feat: RoutingController macro improvements --- .../RoutingControllerMacro.swift | 46 ++++++------- .../RoutingControllerMacroTests.swift | 68 ++++++++++++++++--- 2 files changed, 76 insertions(+), 38 deletions(-) diff --git a/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift index 36f51be..d5eab97 100644 --- a/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift +++ b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift @@ -118,48 +118,40 @@ extension RoutingControllerMacro: ExtensionMacro { return decl } - - typealias StackDestination = (identifier: String, idType: String) - let stackDestinations: [StackDestination] = try navigationDestinations.compactMap { decl in + let stackDestinations: [String] = navigationDestinations.compactMap { decl in guard let attribute = decl.attributes.first?.attribute, - attribute.name.name.hasSuffix("StackDestination"), + attribute.name.name.contains("StackDestination"), let identifier = decl.bindings.first?.identifier - else { return StackDestination?.none } - - let idType: String = if let genericArgument = attribute.name.genericArguments?.first { - genericArgument.description - } else if let typeDecl = decl.bindings.first?.type?._syntax.as(DictionaryTypeSyntax.self) { - typeDecl.key.description - } else { - throw DiagnosticsError(diagnostics: [ - .requiresDictionaryLiteralForStackDestination(attribute._syntax) - ]) - } + else { return .none } - return StackDestination(identifier, idType) + return identifier } - - - if let firstStackDestination = stackDestinations.first { + if !stackDestinations.isEmpty { let erasedIDType = "some Hashable" - let commonIDType = stackDestinations.allSatisfy { destination in - destination.idType == firstStackDestination.idType - } ? firstStackDestination.idType : nil + + let castedIDDecl: DeclSyntax = """ + func controller( + for s: StackDestination + ) -> UIViewController? { + return (id as? ID).flatMap { + s.wrappedValue[$0] + } + } + """ var stackDestinationsCoalecing: String { - let subscriptCall = commonIDType.map { _ in "[id]" } ?? "[id as! $0.idType]" return stackDestinations - .map { "\($0.identifier)\(subscriptCall)" } + .map { "controller(for: _\($0))" } .joined(separator: "\n\t?? ") } - let inputType = commonIDType ?? erasedIDType - let destinationsStructSubscriptDecl: DeclSyntax = """ - \npublic subscript(_ id: \(raw: inputType)) -> UIViewController? { + \npublic subscript(_ id: \(raw: erasedIDType)) -> UIViewController? { + \(castedIDDecl) + return \(raw: stackDestinationsCoalecing) } """ diff --git a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift index ca504b8..6030434 100644 --- a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift +++ b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift @@ -174,9 +174,17 @@ final class RoutingControllerMacroTests: XCTestCase { @StackDestination var secondDetailController: [Int: CocoaViewController] - public subscript(_ id: Int) -> UIViewController? { - return firstDetailController[id] - ?? secondDetailController[id] + public subscript(_ id: some Hashable) -> UIViewController? { + func controller( + for s: StackDestination + ) -> UIViewController? { + return (id as? ID).flatMap { + s.wrappedValue[$0] + } + } + + return controller(for: _firstDetailController) + ?? controller(for: _secondDetailController) } } @@ -202,14 +210,43 @@ final class RoutingControllerMacroTests: XCTestCase { var firstDetailController: Dictionary } """ - } diagnostics: { + } expansion: { """ - @RoutingController final class CustomController { @StackDestination - ╰─ 🛑 `@StackDestination` requires explicit wrapper type or dictionary type literal declaration for value. var firstDetailController: Dictionary } + + extension CustomController: CombineNavigation.RoutingController { + /// Container for captured destinations without referring to self + /// + /// > Generated by `CombineNavigationMacros.RoutingController` macro + /// + /// Use in `navigationDestination`/`navigationStack` methods to map + /// routes to specific destinations using `destinations` method + public struct Destinations { + @StackDestination + var firstDetailController: Dictionary + + public subscript(_ id: some Hashable) -> UIViewController? { + func controller( + for s: StackDestination + ) -> UIViewController? { + return (id as? ID).flatMap { + s.wrappedValue[$0] + } + } + + return controller(for: _firstDetailController) + } + } + + public func _makeDestinations() -> Destinations { + return Destinations( + firstDetailController: $firstDetailController + ) + } + } """ } } @@ -272,7 +309,7 @@ final class RoutingControllerMacroTests: XCTestCase { @CustomStackDestination var firstDetailController: [Int: CocoaViewController] - @CustomTreeDestinationOf + @CustomStackDestinationOf var secondDetailController } """ @@ -282,7 +319,7 @@ final class RoutingControllerMacroTests: XCTestCase { @CustomStackDestination var firstDetailController: [Int: CocoaViewController] - @CustomTreeDestinationOf + @CustomStackDestinationOf var secondDetailController } @@ -298,11 +335,20 @@ final class RoutingControllerMacroTests: XCTestCase { var firstDetailController: [Int: CocoaViewController] - @CustomTreeDestinationOf + @CustomStackDestinationOf var secondDetailController - public subscript(_ id: Int) -> UIViewController? { - return firstDetailController[id] + public subscript(_ id: some Hashable) -> UIViewController? { + func controller( + for s: StackDestination + ) -> UIViewController? { + return (id as? ID).flatMap { + s.wrappedValue[$0] + } + } + + return controller(for: _firstDetailController) + ?? controller(for: _secondDetailController) } } From 854a540271cbf24e99fe897cdce2b873a55e008b Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 31 Dec 2023 04:03:11 +0100 Subject: [PATCH 31/43] fix: navigationDestination duplicates removal --- .../Internal/CombineNavigationRouter+API.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift index 053ca1e..e600173 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift @@ -195,7 +195,11 @@ extension CombineNavigationRouter { P.Failure == Never { publisher - .removeDuplicates(by: { $0.flatMap(enumTag) == $1.flatMap(enumTag) }) + .removeDuplicates(by: { + let wrapped: Bool = enumTag($0) == enumTag($1) + let unwrapped: Bool = $0.flatMap(enumTag) == $1.flatMap(enumTag) + return wrapped && unwrapped + }) .map { [weak self] (route) -> NavigationRoute? in guard let self, let route else { return nil } let destination = destination(route) From aa7172411e892c7b82a7b5b1e7db02d27fec548d Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 31 Dec 2023 04:46:34 +0100 Subject: [PATCH 32/43] feat: Improve Destinations initialization --- Package.swift | 8 ++++++++ .../Destinations/StackDestination.swift | 10 +++++++++- .../Destinations/TreeDestination.swift | 10 +++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index b118e2b..be03dc1 100644 --- a/Package.swift +++ b/Package.swift @@ -35,6 +35,10 @@ let package = Package( url: "https://github.com/capturecontext/swift-foundation-extensions.git", .upToNextMinor(from: "0.4.0") ), + .package( + url: "https://github.com/capturecontext/swift-declarative-configuration.git", + .upToNextMinor(from: "0.3.3") + ), .package( url: "https://github.com/pointfreeco/swift-case-paths", .upToNextMajor(from: "1.0.0") @@ -69,6 +73,10 @@ let package = Package( name: "FoundationExtensions", package: "swift-foundation-extensions" ), + .product( + name: "DeclarativeConfiguration", + package: "swift-declarative-configuration" + ), ] ), .macro( diff --git a/Sources/CombineNavigation/Destinations/StackDestination.swift b/Sources/CombineNavigation/Destinations/StackDestination.swift index 5d59a14..3b071f7 100644 --- a/Sources/CombineNavigation/Destinations/StackDestination.swift +++ b/Sources/CombineNavigation/Destinations/StackDestination.swift @@ -3,6 +3,7 @@ import Capture import CocoaAliases import Combine import FoundationExtensions +import DeclarativeConfiguration public protocol GrouppedDestinationProtocol { associatedtype DestinationID: Hashable @@ -69,7 +70,14 @@ open class StackDestination< open class func initController( for id: DestinationID ) -> Controller { - return Controller() + if + let controllerType = (Controller.self as? ConfigInitializable.Type), + let controller = controllerType.init() as? Controller + { + return controller + } else { + return Controller() + } } @_spi(Internals) diff --git a/Sources/CombineNavigation/Destinations/TreeDestination.swift b/Sources/CombineNavigation/Destinations/TreeDestination.swift index 7b26050..9470272 100644 --- a/Sources/CombineNavigation/Destinations/TreeDestination.swift +++ b/Sources/CombineNavigation/Destinations/TreeDestination.swift @@ -3,6 +3,7 @@ import Capture import CocoaAliases import Combine import FoundationExtensions +import DeclarativeConfiguration public protocol SingleDestinationProtocol { @_spi(Internals) @@ -61,7 +62,14 @@ open class TreeDestination: @_spi(Internals) @inlinable open class func initController() -> Controller { - return Controller() + if + let controllerType = (Controller.self as? ConfigInitializable.Type), + let controller = controllerType.init() as? Controller + { + return controller + } else { + return Controller() + } } @_spi(Internals) From 67a26a116d38370e5cfd3ddc3bd3ade8a65450cd Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 31 Dec 2023 05:00:18 +0100 Subject: [PATCH 33/43] feat: Improve Destinations initialization --- Package.swift | 8 -------- .../Destinations/DestinationInitializableController.swift | 8 ++++++++ .../CombineNavigation/Destinations/StackDestination.swift | 3 +-- .../CombineNavigation/Destinations/TreeDestination.swift | 3 +-- 4 files changed, 10 insertions(+), 12 deletions(-) create mode 100644 Sources/CombineNavigation/Destinations/DestinationInitializableController.swift diff --git a/Package.swift b/Package.swift index be03dc1..b118e2b 100644 --- a/Package.swift +++ b/Package.swift @@ -35,10 +35,6 @@ let package = Package( url: "https://github.com/capturecontext/swift-foundation-extensions.git", .upToNextMinor(from: "0.4.0") ), - .package( - url: "https://github.com/capturecontext/swift-declarative-configuration.git", - .upToNextMinor(from: "0.3.3") - ), .package( url: "https://github.com/pointfreeco/swift-case-paths", .upToNextMajor(from: "1.0.0") @@ -73,10 +69,6 @@ let package = Package( name: "FoundationExtensions", package: "swift-foundation-extensions" ), - .product( - name: "DeclarativeConfiguration", - package: "swift-declarative-configuration" - ), ] ), .macro( diff --git a/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift b/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift new file mode 100644 index 0000000..8227ac3 --- /dev/null +++ b/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift @@ -0,0 +1,8 @@ +#if canImport(UIKit) && !os(watchOS) && canImport(SwiftUI) +import SwiftUI +import CocoaAliases + +public protocol DestinationInitializableControllerProtocol: CocoaViewController { + init() +} +#endif diff --git a/Sources/CombineNavigation/Destinations/StackDestination.swift b/Sources/CombineNavigation/Destinations/StackDestination.swift index 3b071f7..662ba15 100644 --- a/Sources/CombineNavigation/Destinations/StackDestination.swift +++ b/Sources/CombineNavigation/Destinations/StackDestination.swift @@ -3,7 +3,6 @@ import Capture import CocoaAliases import Combine import FoundationExtensions -import DeclarativeConfiguration public protocol GrouppedDestinationProtocol { associatedtype DestinationID: Hashable @@ -71,7 +70,7 @@ open class StackDestination< for id: DestinationID ) -> Controller { if - let controllerType = (Controller.self as? ConfigInitializable.Type), + let controllerType = (Controller.self as? DestinationInitializableControllerProtocol.Type), let controller = controllerType.init() as? Controller { return controller diff --git a/Sources/CombineNavigation/Destinations/TreeDestination.swift b/Sources/CombineNavigation/Destinations/TreeDestination.swift index 9470272..6b7e36d 100644 --- a/Sources/CombineNavigation/Destinations/TreeDestination.swift +++ b/Sources/CombineNavigation/Destinations/TreeDestination.swift @@ -3,7 +3,6 @@ import Capture import CocoaAliases import Combine import FoundationExtensions -import DeclarativeConfiguration public protocol SingleDestinationProtocol { @_spi(Internals) @@ -63,7 +62,7 @@ open class TreeDestination: @inlinable open class func initController() -> Controller { if - let controllerType = (Controller.self as? ConfigInitializable.Type), + let controllerType = (Controller.self as? DestinationInitializableControllerProtocol.Type), let controller = controllerType.init() as? Controller { return controller From c08cc892b843675b1d97a520dfb54e001636f37a Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 31 Dec 2023 05:10:03 +0100 Subject: [PATCH 34/43] feat: Improve Destinations initialization --- .../Destinations/DestinationInitializableController.swift | 2 +- Sources/CombineNavigation/Destinations/StackDestination.swift | 2 +- Sources/CombineNavigation/Destinations/TreeDestination.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift b/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift index 8227ac3..23b12fa 100644 --- a/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift +++ b/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift @@ -3,6 +3,6 @@ import SwiftUI import CocoaAliases public protocol DestinationInitializableControllerProtocol: CocoaViewController { - init() + static func _init_for_destination() -> CocoaViewController } #endif diff --git a/Sources/CombineNavigation/Destinations/StackDestination.swift b/Sources/CombineNavigation/Destinations/StackDestination.swift index 662ba15..3b27a94 100644 --- a/Sources/CombineNavigation/Destinations/StackDestination.swift +++ b/Sources/CombineNavigation/Destinations/StackDestination.swift @@ -71,7 +71,7 @@ open class StackDestination< ) -> Controller { if let controllerType = (Controller.self as? DestinationInitializableControllerProtocol.Type), - let controller = controllerType.init() as? Controller + let controller = controllerType._init_for_destination() as? Controller { return controller } else { diff --git a/Sources/CombineNavigation/Destinations/TreeDestination.swift b/Sources/CombineNavigation/Destinations/TreeDestination.swift index 6b7e36d..a7722c5 100644 --- a/Sources/CombineNavigation/Destinations/TreeDestination.swift +++ b/Sources/CombineNavigation/Destinations/TreeDestination.swift @@ -63,7 +63,7 @@ open class TreeDestination: open class func initController() -> Controller { if let controllerType = (Controller.self as? DestinationInitializableControllerProtocol.Type), - let controller = controllerType.init() as? Controller + let controller = controllerType._init_for_destination() as? Controller { return controller } else { From 178e9d98e8c4a21d82ba6de54f807b364c8ec0fe Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Sun, 31 Dec 2023 05:52:11 +0100 Subject: [PATCH 35/43] feat(Example): Basic TweetReply implementation --- .../xcschemes/LocalExtensions.xcscheme | 66 +++++++++++++ Example/Package.swift | 20 ++++ .../AppFeature/Bootstrap/SceneDelegate.swift | 4 +- Example/Sources/AppUI/Colors/ColorTheme.swift | 2 + .../FeedTabFeature/FeedTabController.swift | 8 +- .../MainFeature/MainViewController.swift | 43 ++++---- .../ProfileFeedFeature/ProfileFeedView.swift | 17 ---- .../TweetDetailController.swift | 29 ++++-- .../TweetDetailFeature.swift | 65 +++++++++++-- .../CreateTweetFeature.swift | 34 +++++++ .../TweetReplyFeature/CreateTweetView.swift | 71 ++++++++++++++ .../SimpleTweetPreviewView.swift | 97 +++++++++++++++++++ .../TweetsFeedController.swift | 4 +- .../_Extensions/LocalExtensions/Exports.swift | 1 + 14 files changed, 401 insertions(+), 60 deletions(-) create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/LocalExtensions.xcscheme create mode 100644 Example/Sources/TweetReplyFeature/CreateTweetFeature.swift create mode 100644 Example/Sources/TweetReplyFeature/CreateTweetView.swift create mode 100644 Example/Sources/TweetReplyFeature/SimpleTweetPreviewView.swift diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/LocalExtensions.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/LocalExtensions.xcscheme new file mode 100644 index 0000000..b298888 --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/LocalExtensions.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Package.swift b/Example/Package.swift index 7640bf4..d9b50a1 100644 --- a/Example/Package.swift +++ b/Example/Package.swift @@ -12,6 +12,10 @@ let package = Package( url: "https://github.com/capturecontext/composable-architecture-extensions.git", branch: "observation-beta" ), + .package( + url: "https://github.com/capturecontext/combine-extensions.git", + .upToNextMinor(from: "0.1.0") + ), .package( url: "https://github.com/pointfreeco/swift-dependencies.git", .upToNextMajor(from: "1.0.0") @@ -37,6 +41,10 @@ let package = Package( name: "LocalExtensions", product: .library(.static), dependencies: [ + .product( + name: "CombineExtensions", + package: "combine-extensions" + ), .product( name: "FoundationExtensions", package: "swift-foundation-extensions" @@ -140,6 +148,16 @@ let package = Package( ] ), + .target( + name: "TweetReplyFeature", + product: .library(.static), + dependencies: [ + .target("TweetFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + .target( name: "CurrentUserProfileFeature", product: .library(.static), @@ -156,6 +174,7 @@ let package = Package( name: "DatabaseSchema", product: .library(.static), dependencies: [ + .dependency("_Dependencies"), .localExtensions ] ), @@ -243,6 +262,7 @@ let package = Package( .target("APIClient"), .target("TweetFeature"), .target("TweetsListFeature"), + .target("TweetReplyFeature"), .dependency("_ComposableArchitecture"), .localExtensions, ] diff --git a/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift index 811f970..61f1806 100644 --- a/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift +++ b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift @@ -27,9 +27,7 @@ public class SceneDelegate: UIResponder, UIWindowSceneDelegate { let window = UIWindow(windowScene: scene) self.window = window - window.rootViewController = UINavigationController( - rootViewController: controller - ) + window.rootViewController = controller window.makeKeyAndVisible() diff --git a/Example/Sources/AppUI/Colors/ColorTheme.swift b/Example/Sources/AppUI/Colors/ColorTheme.swift index 9822e7f..cb3502a 100644 --- a/Example/Sources/AppUI/Colors/ColorTheme.swift +++ b/Example/Sources/AppUI/Colors/ColorTheme.swift @@ -58,7 +58,9 @@ public struct ColorTheme { } extension ColorTheme { + #warning("Find a way to update current value for SUI and Cocoa") public static var current: ColorTheme { + // Won't be updated in Cocoa Environment(\.colorTheme).wrappedValue } diff --git a/Example/Sources/FeedTabFeature/FeedTabController.swift b/Example/Sources/FeedTabFeature/FeedTabController.swift index d3c290d..09ce30a 100644 --- a/Example/Sources/FeedTabFeature/FeedTabController.swift +++ b/Example/Sources/FeedTabFeature/FeedTabController.swift @@ -9,11 +9,11 @@ import TweetsFeedFeature public final class FeedTabController: ComposableViewControllerOf { let contentController: TweetsFeedController = .init() - @ComposableStackDestination - var feedControllers: [StackElementID: TweetsFeedController] + @ComposableStackDestination + var feedControllers - @ComposableStackDestination({ _ in .init(rootView: nil) }) - var profileControllers: [StackElementID: ComposableHostingController] + @ComposableViewStackDestination + var profileControllers public override func viewDidLoad() { super.viewDidLoad() diff --git a/Example/Sources/MainFeature/MainViewController.swift b/Example/Sources/MainFeature/MainViewController.swift index f478f3f..236e328 100644 --- a/Example/Sources/MainFeature/MainViewController.swift +++ b/Example/Sources/MainFeature/MainViewController.swift @@ -13,26 +13,33 @@ public final class MainViewController: ComposableTabBarControllerOf public override func _init() { super._init() + let feedNavigation = UINavigationController( + rootViewController: feedTabController.configured { $0 + .title("Example") + .set { $0.tabBarItem = .init( + title: "Feed", + image: UIImage(systemName: "house"), + selectedImage: UIImage(systemName: "house.fill") + ) } + } + ) + + let profileNavigation = UINavigationController( + rootViewController: profileTabController.configured { $0 + .set { $0.tabBarItem = .init( + title: "Profile", + image: UIImage(systemName: "person"), + selectedImage: UIImage(systemName: "person.fill") + ) } + } + ) + + feedNavigation.navigationBar.prefersLargeTitles = true + setViewControllers( [ - UINavigationController( - rootViewController: feedTabController.configured { $0 - .set { $0.tabBarItem = .init( - title: "Feed", - image: UIImage(systemName: "house"), - selectedImage: UIImage(systemName: "house.fill") - ) } - } - ), - UINavigationController( - rootViewController: profileTabController.configured { $0 - .set { $0.tabBarItem = .init( - title: "Profile", - image: UIImage(systemName: "person"), - selectedImage: UIImage(systemName: "person.fill") - ) } - } - ) + feedNavigation, + profileNavigation ], animated: false ) diff --git a/Example/Sources/ProfileFeedFeature/ProfileFeedView.swift b/Example/Sources/ProfileFeedFeature/ProfileFeedView.swift index 3ca2eb5..41b84a3 100644 --- a/Example/Sources/ProfileFeedFeature/ProfileFeedView.swift +++ b/Example/Sources/ProfileFeedFeature/ProfileFeedView.swift @@ -24,20 +24,3 @@ public struct ProfileFeedView: ComposableView { } } } - -#Preview { - NavigationStack { - ProfileFeedView(Store( - initialState: .init( - tweets: [ - .mock(), - .mock(), - .mock(), - .mock(), - .mock() - ] - ), - reducer: ProfileFeedFeature.init - )) - } -} diff --git a/Example/Sources/TweetDetailFeature/TweetDetailController.swift b/Example/Sources/TweetDetailFeature/TweetDetailController.swift index 93e453c..d9fe14e 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailController.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailController.swift @@ -5,14 +5,18 @@ import Combine import CombineExtensions import Capture import CombineNavigation +import TweetReplyFeature @RoutingController public final class TweetDetailController: ComposableViewControllerOf { - let host = ComposableHostingController(rootView: nil) + let host = ComposableHostingController() @ComposableTreeDestination var detailController: TweetDetailController? + @ComposableViewTreeDestination + var tweetReplyController + public override func viewDidLoad() { super.viewDidLoad() self.addChild(host) @@ -30,9 +34,15 @@ public final class TweetDetailController: ComposableViewControllerOf ) { navigationDestination( - isPresented: \.detail.isNotNil, - destination: $detailController, - popAction: .detail(.dismiss) + state: \State.$destination, + switch: { destinations, route in + switch route { + case .tweetReply: + destinations.$tweetReplyController + case .detail: + destinations.$detailController + } + }, + popAction: .destination(.dismiss) ) .store(in: &cancellables) } diff --git a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift index bf286bd..24f37ac 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift @@ -4,18 +4,47 @@ import AppModels import TweetFeature import TweetsListFeature import APIClient +import TweetReplyFeature @Reducer public struct TweetDetailFeature { public init() {} + @Reducer + public struct Destination { + @ObservableState + public enum State: Equatable { + case detail(TweetDetailFeature.State) + case tweetReply(TweetReplyFeature.State) + } + + @CasePathable + public enum Action: Equatable { + case detail(TweetDetailFeature.Action) + case tweetReply(TweetReplyFeature.Action) + } + + public var body: some ReducerOf { + Scope( + state: \.detail, + action: \.detail, + child: TweetDetailFeature.init + ) + Scope( + state: \.tweetReply, + action: \.tweetReply, + child: TweetReplyFeature.init + ) + } + } + @ObservableState public struct State: Equatable { public var source: TweetFeature.State public var replies: TweetsListFeature.State @Presents - public var detail: TweetDetailFeature.State? + public var destination: Destination.State? @Presents public var alert: AlertState? @@ -23,12 +52,12 @@ public struct TweetDetailFeature { public init( source: TweetFeature.State, replies: TweetsListFeature.State = .init(), - detail: TweetDetailFeature.State? = nil, + destination: Destination.State? = nil, alert: AlertState? = nil ) { self.source = source self.replies = replies - self.detail = detail + self.destination = destination self.alert = alert } } @@ -37,7 +66,7 @@ public struct TweetDetailFeature { public enum Action: Equatable { case source(TweetFeature.Action) case replies(TweetsListFeature.Action) - case detail(PresentationAction) + case destination(PresentationAction) case fetchMoreReplies case alert(Alert) @@ -74,7 +103,7 @@ public struct TweetDetailFeature { case let .replies(.delegate(.openProfile(id))), - let .detail(.presented(.delegate(.openProfile(id)))): + let .destination(.presented(.detail(.delegate(.openProfile(id))))): return .send(.delegate(.openProfile(id))) default: @@ -82,11 +111,27 @@ public struct TweetDetailFeature { } } + Reduce { state, action in + switch action { + case .source(.reply): + state.destination = .tweetReply(.init(source: state.source, replyText: "")) + return .none + + case let .replies(.tweets(.element(id, .reply))): + guard let tweet = state.replies.tweets[id: id] else { return .none } + state.destination = .tweetReply(.init(source: tweet, replyText: "")) + return .none + + default: + return .none + } + } + Pullback(\.replies.delegate.openDetail) { state, id in guard let tweet = state.replies.tweets[id: id] else { return .none } - state.detail = .init(source: tweet) + state.destination = .detail(.init(source: tweet)) return .none } @@ -155,11 +200,11 @@ public struct TweetDetailFeature { ) } .ifLet( - \State.$detail, - action: \.detail, - destination: TweetDetailFeature.init + \State.$destination, + action: \.destination, + destination: Destination.init ) - .syncTweetDetailSource(\.$detail, with: \.replies) + .syncTweetDetailSource(\.$destination.detail, with: \.replies) } func makeAlert(for error: APIClient.Error) -> AlertState { diff --git a/Example/Sources/TweetReplyFeature/CreateTweetFeature.swift b/Example/Sources/TweetReplyFeature/CreateTweetFeature.swift new file mode 100644 index 0000000..948eb11 --- /dev/null +++ b/Example/Sources/TweetReplyFeature/CreateTweetFeature.swift @@ -0,0 +1,34 @@ +import _ComposableArchitecture +import TweetFeature +import LocalExtensions + +@Reducer +public struct TweetReplyFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var source: TweetFeature.State + public var avatarURL: URL? + public var replyText: String + + public init( + source: TweetFeature.State, + avatarURL: URL? = nil, + replyText: String + ) { + self.source = source + self.avatarURL = avatarURL + self.replyText = replyText + } + } + + @CasePathable + public enum Action: Equatable, BindableAction { + case binding(BindingAction) + } + + public var body: some ReducerOf { + BindingReducer() + } +} diff --git a/Example/Sources/TweetReplyFeature/CreateTweetView.swift b/Example/Sources/TweetReplyFeature/CreateTweetView.swift new file mode 100644 index 0000000..ae43d28 --- /dev/null +++ b/Example/Sources/TweetReplyFeature/CreateTweetView.swift @@ -0,0 +1,71 @@ +import _ComposableArchitecture +import SwiftUI +import TweetFeature + +public struct TweetReplyView: ComposableView { + private let store: StoreOf + + @Environment(\.colorTheme) + private var color + + @FocusState + private var focused: Bool + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + _body + } + + private var _body: some View { + ScrollView(.vertical) { + VStack(spacing: 0) { + makeTweetPreview() + makeTweetInputField() + Spacer(minLength: 8) + } + .padding(.horizontal) + } + .toolbar { + Button("Tweet") { + #warning("Handle tweet action") + } + } + .toolbarRole(.navigationStack) + .onAppear { focused = true } + } + + @ViewBuilder + private func makeTweetPreview() -> some View { + SimpleTweetPreviewView(store.scope( + state: \.source, + action: \.never + )) + } + + @ViewBuilder + private func makeTweetInputField() -> some View { + HStack(alignment: .top) { + makeAvatar(nil) + TextEditor(text: Binding( + get: { store.replyText }, + set: { store.send(.binding(.set(\.replyText, $0))) }) + ) + .focused($focused) + .textEditorStyle(PlainTextEditorStyle()) + .scrollDisabled(true) + } + } + + @ViewBuilder + private func makeAvatar( + _ avatarURL: URL? + ) -> some View { + Circle() + .stroke(color(\.label.secondary).opacity(0.3)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } +} diff --git a/Example/Sources/TweetReplyFeature/SimpleTweetPreviewView.swift b/Example/Sources/TweetReplyFeature/SimpleTweetPreviewView.swift new file mode 100644 index 0000000..9cb16ac --- /dev/null +++ b/Example/Sources/TweetReplyFeature/SimpleTweetPreviewView.swift @@ -0,0 +1,97 @@ +import _ComposableArchitecture +import SwiftUI +import AppUI +import TweetFeature + +public struct SimpleTweetPreviewView: ComposableView { + private let store: Store + + @Environment(\.colorTheme) + var color + + private let dateFormatter = DateFormatter { $0 + .dateStyle(.short) + } + + public init(_ store: Store) { + self.store = store + } + + public var body: some View { + _body + .scaledFont(ofSize: 14) + .background(color(\.background.primary)) + } + + @ViewBuilder + private var _body: some View { + HStack(alignment: .top) { + VStack(spacing: 0) { + makeAvatar(store.author.avatarURL) + Rectangle() + .fill(color(\.label.tertiary)) + .frame(width: 2) + .frame(minHeight: 0) + .padding(.trailing, 2) + .padding(.vertical, 6) + } + VStack(alignment: .leading, spacing: 7) { + makeHeader( + displayName: store.author.displayName, + username: store.author.username, + creationDate: store.createdAt + ) + makeContent(store.text) + Text("Replying to @\(store.author.username)") + .scaledFont(ofSize: 9) + .foregroundStyle(color(\.label.secondary)) + .id("replying_to") + } + } + } + + @ViewBuilder + private func makeAvatar( + _ avatarURL: URL? + ) -> some View { + Circle() + .stroke(color(\.label.secondary).opacity(0.3)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + + @ViewBuilder + private func makeHeader( + displayName: String, + username: String, + creationDate: Date + ) -> some View { + HStack { + if displayName.isNotEmpty { + Text(displayName) + .fontWeight(.bold) + .foregroundStyle(color(\.label)) + .layoutPriority(2) + Text("@" + username.lowercased()) + } else { + Text("@" + username.lowercased()) + .fontWeight(.bold) + .foregroundStyle(color(\.label)) + .layoutPriority(2) + } + Text("• \(dateFormatter.string(from: creationDate))") + .layoutPriority(1) + } + .foregroundStyle(color(\.label.secondary)) + .fontWeight(.light) + .lineLimit(1) + } + + @ViewBuilder + private func makeContent( + _ text: String + ) -> some View { + Text(text) + .foregroundStyle(color(\.label)) + } +} diff --git a/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift index b3268d4..ba37844 100644 --- a/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift @@ -7,7 +7,7 @@ import TweetDetailFeature @RoutingController public final class TweetsFeedController: ComposableViewControllerOf { - let host = ComposableHostingController(rootView: nil) + let host = ComposableHostingController() @ComposableTreeDestination var detailController: TweetDetailController? @@ -43,7 +43,7 @@ public final class TweetsFeedController: ComposableViewControllerOf ) { navigationDestination( - isPresented: \.detail.isNotNil, + isPresented: \.$detail.wrappedValue.isNotNil, destination: $detailController, popAction: .detail(.dismiss) ) diff --git a/Example/Sources/_Extensions/LocalExtensions/Exports.swift b/Example/Sources/_Extensions/LocalExtensions/Exports.swift index 2095f85..ee0b4f0 100644 --- a/Example/Sources/_Extensions/LocalExtensions/Exports.swift +++ b/Example/Sources/_Extensions/LocalExtensions/Exports.swift @@ -1,2 +1,3 @@ @_exported import FoundationExtensions @_exported import IdentifiedCollections +@_exported import CombineExtensions From 23464ece343bb562a76e343d439e0c0e3873833b Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Mon, 1 Jan 2024 15:40:05 +0100 Subject: [PATCH 36/43] feat: TweetReplyFeature initial integration --- .../xcshareddata/xcschemes/APIClient.xcscheme | 66 +++ .../xcschemes/DatabaseSchema.xcscheme | 66 +++ .../Sources/APIClient/APIClient+Live.swift | 536 +++++++++--------- .../Bootstrap/PrefillDatabase.swift | 130 +++-- Example/Sources/AppModels/UserInfoModel.swift | 6 +- Example/Sources/DatabaseSchema/Database.swift | 22 +- .../DatabaseSchema+ModelContext.swift | 8 +- Example/Sources/DatabaseSchema/Exports.swift | 15 + .../Versions/V1/V1+TweetModel.swift | 18 +- .../Versions/V1/V1+UserModel.swift | 16 +- .../TweetDetailFeature.swift | 5 + .../SimpleTweetPreviewView.swift | 1 + ...tFeature.swift => TweetReplyFeature.swift} | 18 + ...teTweetView.swift => TweetReplyView.swift} | 3 +- .../TweetsFeedFeature/TweetsFeedFeature.swift | 2 +- .../_Extensions/LocalExtensions/Exports.swift | 4 + 16 files changed, 572 insertions(+), 344 deletions(-) create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/APIClient.xcscheme create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/DatabaseSchema.xcscheme rename Example/Sources/TweetReplyFeature/{CreateTweetFeature.swift => TweetReplyFeature.swift} (63%) rename Example/Sources/TweetReplyFeature/{CreateTweetView.swift => TweetReplyView.swift} (95%) diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/APIClient.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/APIClient.xcscheme new file mode 100644 index 0000000..a4b5eab --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/APIClient.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/DatabaseSchema.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/DatabaseSchema.xcscheme new file mode 100644 index 0000000..37038fc --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/DatabaseSchema.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Sources/APIClient/APIClient+Live.swift b/Example/Sources/APIClient/APIClient+Live.swift index 8438dc4..16f77c7 100644 --- a/Example/Sources/APIClient/APIClient+Live.swift +++ b/Example/Sources/APIClient/APIClient+Live.swift @@ -1,4 +1,4 @@ -import _Dependencies +import Dependencies import SwiftData import LocalExtensions import DatabaseSchema @@ -6,57 +6,91 @@ import AppModels extension APIClient: DependencyKey { public static var liveValue: APIClient { - @Reference - var currentUser: DatabaseSchema.UserModel? + return .init( + auth: .backendLike(), + feed: .backendLike(), + tweet: .backendLike(), + user: .backendLike() + ) + } +} + +extension ModelContext { + fileprivate var currentUser: DatabaseSchema.UserModel? { @Dependency(\.currentUser) var userIDContainer - let trackedCurrentUser = _currentUser.onSet { user in - userIDContainer.id = user?.id.usid() - } + guard let currentUserID = userIDContainer.id?.rawValue + else { return nil } - return .init( - auth: .backendLike( - currentUser: trackedCurrentUser - ), - feed: .backendLike( - currentUser: trackedCurrentUser - ), - tweet: .backendLike( - currentUser: trackedCurrentUser + return try? fetch( + DatabaseSchema.UserModel.self, + #Predicate { $0.id == currentUserID } + ).first + } +} + +extension DatabaseSchema.TweetModel { + public func toAPIModel() -> AppModels.TweetModel { + @Dependency(\.currentUser) + var currentUser + + return TweetModel( + id: id.usid(), + author: .init( + id: author!.id.usid(), + avatarURL: author!.avatarURL, + displayName: author!.displayName, + username: author!.username ), - user: .backendLike( - currentUser: trackedCurrentUser - ) + createdAt: createdAt, + replyTo: replySource?.id.usid(), + repliesCount: replies.count, + isLiked: currentUser.id.map { userID in + likes.contains { (like: DatabaseSchema.UserModel) in + like.id == userID.rawValue + } + }.or(false), + likesCount: likes.count, + isReposted: currentUser.id.map { userID in + reposts.contains { (repost: DatabaseSchema.TweetModel) in + repost.author!.id == userID.rawValue + } + }.or(false), + repostsCount: reposts.count, + text: content ) } } extension APIClient.Auth { - static func backendLike( - currentUser: Reference - ) -> Self { + static func backendLike() -> Self { .init( signIn: .init { input in return await Result { @Dependency(\.database) var database - let pwHash = try Data.sha256(input.password).unwrap().get() + @Dependency(\.currentUser) + var currentUser - let username = input.username - guard let user = try await database.context.fetch( - DatabaseSchema.UserModel.self, - #Predicate { $0.username == username } - ).first - else { throw .usernameNotFound } + try await database.withContext { context in + let pwHash = try Data.sha256(input.password).unwrap().get() - guard user.password == pwHash else { - throw .wrongPassword - } + let username = input.username + guard let user = try context.fetch( + DatabaseSchema.UserModel.self, + #Predicate { $0.username == username } + ).first + else { throw .usernameNotFound } + + guard user.password == pwHash else { + throw .wrongPassword + } - currentUser.wrappedValue = user + currentUser.id = user.id.usid() + } }.mapError(APIClient.Error.init) }, signUp: .init { input in @@ -64,71 +98,59 @@ extension APIClient.Auth { @Dependency(\.database) var database - let username = input.username - let userExists = try await database.context.fetch( - DatabaseSchema.UserModel.self, - #Predicate { $0.username == username } - ).isNotEmpty + @Dependency(\.currentUser) + var currentUser - guard !userExists - else { throw .userAlreadyExists } + try await database.withContext { context in + let username = input.username + let userExists = try context.fetch( + DatabaseSchema.UserModel.self, + #Predicate { $0.username == username } + ).isNotEmpty + + guard !userExists + else { throw .userAlreadyExists } - let pwHash = try Data.sha256(input.password).unwrap().get() + let pwHash = try Data.sha256(input.password).unwrap().get() - let user = DatabaseSchema.UserModel( - id: USID(), - username: input.username, - password: pwHash - ) + let user = DatabaseSchema.UserModel( + id: USID(), + username: input.username, + password: pwHash + ) - await database.context.insert(user) - try await database.context.save() - currentUser.wrappedValue = user + context.insert(user) + try context.save() + currentUser.id = user.id.usid() + } }.mapError(APIClient.Error.init) }, logout: .init { _ in - currentUser.wrappedValue = nil + @Dependency(\.currentUser) + var currentUser + + currentUser.id = nil } ) } } extension APIClient.Feed { - static func backendLike( - currentUser: Reference - ) -> Self { - .init( + static func backendLike() -> APIClient.Feed { + return .init( fetchTweets: .init { input in return await Result { @Dependency(\.database) var database - return try await database.context.fetch( - DatabaseSchema.TweetModel.self, - #Predicate { $0.replySource == nil } - ) - .dropFirst(input.page * input.limit) - .prefix(input.limit) - .map { tweet in - return TweetModel( - id: tweet.id.usid(), - author: .init( - id: tweet.author.id.usid(), - avatarURL: tweet.author.avatarURL, - displayName: tweet.author.displayName, - username: tweet.author.username - ), - createdAt: tweet.createdAt, - replyTo: tweet.replySource?.id.usid(), - repliesCount: tweet.replies.count, - isLiked: currentUser.wrappedValue.map { user in - tweet.likes.contains { $0.id == user.id } - }.or(false), - likesCount: tweet.likes.count, - isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), - repostsCount: tweet.reposts.count, - text: tweet.content + return try await database.withContext { context in + return try context.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.replySource == nil } ) + .dropFirst(input.page * input.limit) + .prefix(input.limit) + .map { $0.toAPIModel() } } }.mapError(APIClient.Error.init) } @@ -137,40 +159,24 @@ extension APIClient.Feed { } extension APIClient.Tweet { - static func backendLike( - currentUser: Reference - ) -> Self { + static func backendLike() -> Self { .init( fetch: .init { input in return await Result { @Dependency(\.database) var database - let tweetID = input.rawValue - guard let tweet = try await database.context.fetch( - DatabaseSchema.TweetModel.self, - #Predicate { $0.id == tweetID } - ).first - else { throw .tweetNotFound } - - return TweetModel( - id: tweet.id.usid(), - author: .init( - id: tweet.author.id.usid(), - avatarURL: tweet.author.avatarURL, - displayName: tweet.author.displayName, - username: tweet.author.username - ), - createdAt: tweet.createdAt, - replyTo: tweet.replySource?.id.usid(), - repliesCount: tweet.replies.count, - isLiked: currentUser.wrappedValue.map { user in - tweet.likes.contains { $0 === user } - }.or(false), - likesCount: tweet.likes.count, - isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), - repostsCount: tweet.reposts.count, - text: tweet.content - ) + + return try await database.withContext { context in + let tweetID = input.rawValue + + guard let tweet = try context.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw .tweetNotFound } + + return tweet.toAPIModel() + } }.mapError(APIClient.Error.init) }, like: .init { input in @@ -178,28 +184,30 @@ extension APIClient.Tweet { @Dependency(\.database) var database - guard let user = currentUser.wrappedValue - else { throw .unauthenticatedRequest("like a tweet") } + try await database.withContext { context in + guard let user = context.currentUser + else { throw .unauthenticatedRequest("like a tweet") } - let shouldLike = input.value - let isLiked = user.likedTweets.contains(where: { $0.id == input.id.rawValue }) + let shouldLike = input.value + let tweetID = input.id.rawValue + let isLiked = user.likedTweets.contains(where: { $0.id == tweetID }) - guard shouldLike != isLiked else { return } + guard shouldLike != isLiked else { return } - if shouldLike { - let tweetID = input.id.rawValue - guard let tweet = try await database.context.fetch( - DatabaseSchema.TweetModel.self, - #Predicate { $0.id == tweetID } - ).first - else { throw .tweetNotFound } + if shouldLike { + guard let tweet = try context.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw .tweetNotFound } - user.likedTweets.append(tweet) - } else { - user.likedTweets.removeAll { $0.id == input.id.rawValue } - } + user.likedTweets.append(tweet) + } else { + user.likedTweets.removeAll { $0.id == tweetID } + } - try await database.context.save() + try context.save() + } }.mapError(APIClient.Error.init) }, post: .init { input in @@ -207,17 +215,20 @@ extension APIClient.Tweet { @Dependency(\.database) var database - guard let user = currentUser.wrappedValue - else { throw .unauthenticatedRequest("post a tweet") } + try await database.withContext { context in + guard let user = context.currentUser + else { throw .unauthenticatedRequest("post a tweet") } - await database.context.insert(DatabaseSchema.TweetModel( - id: USID(), - createdAt: .now, - author: user, - content: input - )) + DatabaseSchema.TweetModel( + id: USID(), + createdAt: .now, + content: input + ) + .insert(to: context) + .update(\.author, with: { $0 = user }) - try await database.context.save() + try context.save() + } }.mapError(APIClient.Error.init) }, repost: .init { input in @@ -225,24 +236,28 @@ extension APIClient.Tweet { @Dependency(\.database) var database - guard let user = currentUser.wrappedValue - else { throw .unauthenticatedRequest("repost a tweet") } + try await database.withContext { context in + guard let user = context.currentUser + else { throw .unauthenticatedRequest("repost a tweet") } - let tweetID = input.id.rawValue - guard let originalTweet = try await database.context.fetch( - DatabaseSchema.TweetModel.self, - #Predicate { $0.id == tweetID } - ).first - else { throw .tweetNotFound } + let tweetID = input.id.rawValue + guard let originalTweet = try context.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw .tweetNotFound } - originalTweet.reposts.append(DatabaseSchema.TweetModel( - id: USID(), - createdAt: .now, - author: user, - content: input.content - )) + DatabaseSchema.TweetModel( + id: USID(), + createdAt: .now, + content: input.content + ) + .insert(to: context) + .update(\.author, with: { $0 = user }) + .update(\.repostSource, with: { $0 = originalTweet }) - try await database.context.save() + try context.save() + } }.mapError(APIClient.Error.init) }, reply: .init { input in @@ -250,24 +265,28 @@ extension APIClient.Tweet { @Dependency(\.database) var database - guard let user = currentUser.wrappedValue - else { throw .unauthenticatedRequest("reply to a tweet") } + try await database.withContext { context in + guard let user = context.currentUser + else { throw .unauthenticatedRequest("reply to a tweet") } - let tweetID = input.id.rawValue - guard let originalTweet = try await database.context.fetch( - DatabaseSchema.TweetModel.self, - #Predicate { $0.id == tweetID } - ).first - else { throw .tweetNotFound } + let tweetID = input.id.rawValue + guard let originalTweet = try context.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw .tweetNotFound } - originalTweet.replies.append(DatabaseSchema.TweetModel( - id: USID(), - createdAt: .now, - author: user, - content: input.content - )) + DatabaseSchema.TweetModel( + id: USID(), + createdAt: .now, + content: input.content + ) + .insert(to: context) + .update(\.author, with: { $0 = user }) + .update(\.replySource, with: { $0 = originalTweet }) - try await database.context.save() + try context.save() + } }.mapError(APIClient.Error.init) }, delete: .init { input in @@ -275,18 +294,21 @@ extension APIClient.Tweet { @Dependency(\.database) var database - guard let user = currentUser.wrappedValue - else { throw .unauthenticatedRequest("delete tweets") } + try await database.withContext { context in + guard let user = context.currentUser + else { throw .unauthenticatedRequest("delete tweets") } - guard let tweetToDelete = try await database.context.fetch( - DatabaseSchema.TweetModel.self, - #Predicate { $0.id == input.rawValue } - ).first - else { throw .tweetNotFound } + let tweetID = input.rawValue + guard let tweetToDelete = try context.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw .tweetNotFound } - user.tweets.removeAll { $0 === tweetToDelete } + user.tweets.removeAll { $0.id == tweetToDelete.id } - try await database.context.save() + try context.save() + } }.mapError(APIClient.Error.init) }, report: .init { input in @@ -298,37 +320,19 @@ extension APIClient.Tweet { @Dependency(\.database) var database - let tweetID = input.id.rawValue - guard let tweet = try await database.context.fetch( - DatabaseSchema.TweetModel.self, - #Predicate { $0.id == tweetID } - ).first - else { throw .tweetNotFound } + return try await database.withContext { context in + let tweetID = input.id.rawValue + guard let tweet = try context.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw .tweetNotFound } - return tweet.replies - .dropFirst(input.page * input.limit) - .prefix(input.limit) - .map { tweet in - return TweetModel( - id: tweet.id.usid(), - author: .init( - id: tweet.author.id.usid(), - avatarURL: tweet.author.avatarURL, - displayName: tweet.author.displayName, - username: tweet.author.username - ), - createdAt: tweet.createdAt, - replyTo: tweet.replySource?.id.usid(), - repliesCount: tweet.replies.count, - isLiked: currentUser.wrappedValue.map { user in - tweet.likes.contains { $0.id == user.id } - }.or(false), - likesCount: tweet.likes.count, - isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), - repostsCount: tweet.reposts.count, - text: tweet.content - ) - } + return tweet.replies + .dropFirst(input.page * input.limit) + .prefix(input.limit) + .map { $0.toAPIModel() } + } }.mapError(APIClient.Error.init) } ) @@ -336,32 +340,38 @@ extension APIClient.Tweet { } extension APIClient.User { - static func backendLike( - currentUser: Reference - ) -> Self { + static func backendLike() -> Self { .init( - fetch: .init { id in + fetch: .init { input in return await Result { @Dependency(\.database) var database - guard let user = try await database.context.fetch( - DatabaseSchema.UserModel.self, - #Predicate { $0.id == id.rawValue } - ).first - else { throw .userNotFound } - - return UserInfoModel( - id: user.id.usid(), - username: user.username, - displayName: user.displayName, - bio: user.bio, - avatarURL: user.avatarURL, - isFollowingYou: currentUser.wrappedValue?.followers.contains { $0 === user } ?? false, - isFollowedByYou: user.followers.contains { $0 === currentUser.wrappedValue }, - followsCount: user.follows.count, - followersCount: user.followers.count - ) + return try await database.withContext { context in + let currentUser = context.currentUser + let userID = input.rawValue + guard let user = try context.fetch( + DatabaseSchema.UserModel.self, + #Predicate { $0.id == userID } + ).first + else { throw .userNotFound } + + return UserInfoModel( + id: user.id.usid(), + username: user.username, + displayName: user.displayName, + bio: user.bio, + avatarURL: user.avatarURL, + isFollowingYou: currentUser.map { currentUser in + currentUser.followers.contains { $0.id == user.id } + }.or(false), + isFollowedByYou: currentUser.map { currentUser in + user.followers.contains { $0.id == currentUser.id } + }.or(false), + followsCount: user.follows.count, + followersCount: user.followers.count + ) + } }.mapError(APIClient.Error.init) }, follow: .init { input in @@ -369,28 +379,31 @@ extension APIClient.User { @Dependency(\.database) var database - guard let user = currentUser.wrappedValue - else { throw .unauthenticatedRequest("follow or unfollow profiles") } + try await database.withContext { context in + guard let user = context.currentUser + else { throw .unauthenticatedRequest("follow or unfollow profiles") } - let shouldFollow = input.value - let isFollowing = user.follows.contains(where: { $0.id == input.id.rawValue }) + let userID = input.id.rawValue + let shouldFollow = input.value + let isFollowing = user.follows.contains(where: { $0.id == userID }) - guard shouldFollow != isFollowing else { return } + guard shouldFollow != isFollowing else { return } - if shouldFollow { - let userID = input.id.rawValue - guard let userToFollow = try await database.context.fetch( - DatabaseSchema.UserModel.self, - #Predicate { $0.id == userID } - ).first - else { throw .userNotFound } + if shouldFollow { + guard let userToFollow = try context.fetch( + DatabaseSchema.UserModel.self, + #Predicate { $0.id == userID } + ).first + else { throw .userNotFound } - user.follows.append(userToFollow) - } else { - user.follows.removeAll { $0.id == input.id.rawValue } + user.follows.append(userToFollow) + } else { + user.follows.removeAll { $0.id == userID } + } + + try context.save() } - try await database.context.save() }.mapError(APIClient.Error.init) }, report: .init { input in @@ -401,37 +414,20 @@ extension APIClient.User { return await Result { @Dependency(\.database) var database - let userID = input.id.rawValue - guard let user = try await database.context.fetch( - DatabaseSchema.UserModel.self, - #Predicate { $0.id == userID } - ).first - else { throw .userNotFound } - - return user.tweets - .dropFirst(input.page * input.limit) - .prefix(input.limit) - .map { tweet in - return TweetModel( - id: tweet.id.usid(), - author:.init( - id: tweet.author.id.usid(), - avatarURL: tweet.author.avatarURL, - displayName: tweet.author.displayName, - username: tweet.author.username - ), - createdAt: tweet.createdAt, - replyTo: tweet.replySource?.id.usid(), - repliesCount: tweet.replies.count, - isLiked: currentUser.wrappedValue.map { user in - tweet.likes.contains { $0 === user } - }.or(false), - likesCount: tweet.likes.count, - isReposted: currentUser.wrappedValue.map(tweet.reposts.map(\.author).contains).or(false), - repostsCount: tweet.reposts.count, - text: tweet.content - ) - } + + return try await database.withContext { context in + let userID = input.id.rawValue + guard let user = try context.fetch( + DatabaseSchema.UserModel.self, + #Predicate { $0.id == userID } + ).first + else { throw .userNotFound } + + return user.tweets + .dropFirst(input.page * input.limit) + .prefix(input.limit) + .map { $0.toAPIModel() } + } }.mapError(APIClient.Error.init) } ) diff --git a/Example/Sources/AppFeature/Bootstrap/PrefillDatabase.swift b/Example/Sources/AppFeature/Bootstrap/PrefillDatabase.swift index 1a0fb4c..480e64b 100644 --- a/Example/Sources/AppFeature/Bootstrap/PrefillDatabase.swift +++ b/Example/Sources/AppFeature/Bootstrap/PrefillDatabase.swift @@ -8,48 +8,106 @@ let defaultPassword = "psswrd" func prefillDatabaseIfNeeded(autoSignIn: Bool) async throws { @Dependency(\.database) var database + + try await database.withContext { modelContext in + + let currentUser = DatabaseSchema.UserModel( + id: USID(), + username: defaultUsername, + password: .sha256(defaultPassword)!, + displayName: "Capture Context", + bio: "We do cool stuff 😎🤘" + ) + + let otherUser1 = DatabaseSchema.UserModel( + id: USID(), + username: "johndoe", + password: .sha256(defaultPassword)!, + displayName: "John Doe" + ) + + let otherUser2 = DatabaseSchema.UserModel( + id: USID(), + username: "janedoe", + password: .sha256(defaultPassword)!, + displayName: "Jane Doe" + ) + + modelContext.insert(currentUser) + modelContext.insert(otherUser1) + modelContext.insert(otherUser2) + + var tweet = DatabaseSchema.TweetModel + .init( + id: .uuid(), + content: "Hello, World!" + ) + .insert(to: modelContext) + .update(\.author, with: { $0 = otherUser1 }) + .update(\.likes, with: { $0.append(contentsOf: [currentUser]) }) + + tweet = DatabaseSchema.TweetModel + .init( + id: .uuid(), + content: "Hello, @\(otherUser1.username)!" + ) + .insert(to: modelContext) + .update(\.author, with: { $0 = currentUser }) + .update(\.replySource, with: { $0 = tweet }) + .update(\.likes, with: { $0.append(contentsOf: [otherUser1]) }) + + + tweet = DatabaseSchema.TweetModel + .init( + id: .uuid(), + content: "Hello, First World!" + ) + .insert(to: modelContext) + .update(\.author, with: { $0 = currentUser }) + .update(\.likes, with: { $0.append(contentsOf: [otherUser1, otherUser2]) }) + + + tweet = DatabaseSchema.TweetModel + .init( + id: .uuid(), + content: "Hello, Second World!" + ) + .insert(to: modelContext) + .update(\.author, with: { $0 = otherUser1 }) + .update(\.replySource, with: { $0 = tweet }) + + + tweet = DatabaseSchema.TweetModel + .init( + id: .uuid(), + content: "Hello, Third World!" + ) + .insert(to: modelContext) + .update(\.author, with: { $0 = otherUser2 }) + .update(\.replySource, with: { $0 = tweet }) + .update(\.likes, with: { $0.append(contentsOf: [otherUser1, otherUser2]) }) - let modelContext = await database.context - - let currentUser = DatabaseSchema.UserModel( - id: USID(), - username: defaultUsername, - password: .sha256(defaultPassword)!, - displayName: "Capture Context" - ) - - let tweet = DatabaseSchema.TweetModel( - id: USID(), - author: currentUser, - likes: [currentUser], - content: "Hello, First World!" - ) - - let reply1 = DatabaseSchema.TweetModel( - id: USID(), - author: currentUser, - replySource: tweet, - content: "Hello, Second World!" - ) - - let reply2 = DatabaseSchema.TweetModel( - id: USID(), - author: currentUser, - replySource: reply1, - content: "Hello, Third World!" - ) - - modelContext.insert(reply2) - - try modelContext.save() - + try modelContext.save() + } + guard autoSignIn else { return } - + @Dependency(\.apiClient) var apiClient - + try await apiClient.auth.signIn( username: defaultUsername, password: defaultPassword ).get() } + +extension DatabaseSchema.TweetModel { + static func makeTweet( + with content: String + ) -> DatabaseSchema.TweetModel { + DatabaseSchema.TweetModel( + id: USID(), + content: content + ) + } +} diff --git a/Example/Sources/AppModels/UserInfoModel.swift b/Example/Sources/AppModels/UserInfoModel.swift index 9e2246c..dc55801 100644 --- a/Example/Sources/AppModels/UserInfoModel.swift +++ b/Example/Sources/AppModels/UserInfoModel.swift @@ -11,7 +11,7 @@ public struct UserInfoModel: Equatable, Identifiable, ConvertibleModel { public var followsCount: Int public var followersCount: Int public var tweetsCount: Int - + public init( id: USID, username: String, @@ -21,8 +21,8 @@ public struct UserInfoModel: Equatable, Identifiable, ConvertibleModel { isFollowingYou: Bool = false, isFollowedByYou: Bool = false, followsCount: Int = 0, - followersCount: Int = 0, - tweetsCount: Int = 0 + followersCount: Int = 0, + tweetsCount: Int = 0 ) { self.id = id self.username = username diff --git a/Example/Sources/DatabaseSchema/Database.swift b/Example/Sources/DatabaseSchema/Database.swift index 1ab5f0a..006d600 100644 --- a/Example/Sources/DatabaseSchema/Database.swift +++ b/Example/Sources/DatabaseSchema/Database.swift @@ -1,10 +1,22 @@ import _Dependencies public actor Database: Sendable { - public let context: ModelContext + private let container: ModelContainer - public init(context: ModelContext) { - self.context = context + public init(container: ModelContainer) { + self.container = container + } + + @discardableResult + public func withContainer(_ operation: (ModelContainer) async throws -> T) async rethrows -> T { + return try await operation(container) + } + + @discardableResult + public func withContext(_ operation: (ModelContext) async throws -> T) async rethrows -> T { + return try await withContainer { container in + try await operation(ModelContext(container)) + } } } @@ -15,11 +27,11 @@ extension ModelContext: @unchecked Sendable {} extension Database: DependencyKey { public static var liveValue: Database { - try! .init(context: DatabaseSchema.createModelContext(.file())) + try! .init(container: DatabaseSchema.createModelContainer(.file())) } public static var previewValue: Database { - try! .init(context: DatabaseSchema.createModelContext(.inMemory)) + try! .init(container: DatabaseSchema.createModelContainer(.inMemory)) } } diff --git a/Example/Sources/DatabaseSchema/DatabaseSchema+ModelContext.swift b/Example/Sources/DatabaseSchema/DatabaseSchema+ModelContext.swift index 9e362dd..c78a6cf 100644 --- a/Example/Sources/DatabaseSchema/DatabaseSchema+ModelContext.swift +++ b/Example/Sources/DatabaseSchema/DatabaseSchema+ModelContext.swift @@ -7,10 +7,10 @@ extension DatabaseSchema { case file(URL = .applicationSupportDirectory.appending(path: "db.store")) } - public static func createModelContext( + public static func createModelContainer( _ persistance: ModelPersistance - ) throws -> ModelContext { - + ) throws -> ModelContainer { + let config = switch persistance { case .inMemory: ModelConfiguration(isStoredInMemoryOnly: true) @@ -24,6 +24,6 @@ extension DatabaseSchema { configurations: config ) - return ModelContext(container) + return container } } diff --git a/Example/Sources/DatabaseSchema/Exports.swift b/Example/Sources/DatabaseSchema/Exports.swift index 94fd61c..d573933 100644 --- a/Example/Sources/DatabaseSchema/Exports.swift +++ b/Example/Sources/DatabaseSchema/Exports.swift @@ -11,6 +11,21 @@ extension PersistentModel { public typealias Fetch = FetchDescriptor public typealias Sort = SortDescriptor public typealias Predicate = Foundation.Predicate + + @discardableResult + public func insert(to context: ModelContext) -> Self { + context.insert(self) + return self + } + + @discardableResult + public func update( + _ keyPath: ReferenceWritableKeyPath, + with closure: (inout Value) -> Void + ) -> Self { + closure(&self[keyPath: keyPath]) + return self + } } extension ModelContext { diff --git a/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift b/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift index d127c1b..92f2f28 100644 --- a/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift +++ b/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift @@ -8,8 +8,7 @@ extension DatabaseSchema.V1 { public let id: String public var createdAt: Date - @Relationship(inverse: \UserModel.tweets) - public var author: UserModel + public var author: UserModel? @Relationship public var repostSource: TweetModel? @@ -31,23 +30,14 @@ extension DatabaseSchema.V1 { public init( id: USID, createdAt: Date = .now, - author: UserModel, - repostSource: TweetModel? = nil, - replySource: TweetModel? = nil, - replies: [TweetModel] = [], - reposts: [TweetModel] = [], - likes: [UserModel] = [], content: String ) { self.id = id.rawValue self.createdAt = createdAt - self.author = author - self.repostSource = repostSource - self.replySource = replySource - self.replies = replies - self.reposts = reposts - self.likes = likes self.content = content + self.replies = [] + self.reposts = [] + self.likes = [] } } } diff --git a/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift b/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift index 197d271..5f8263f 100644 --- a/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift +++ b/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift @@ -15,7 +15,7 @@ extension DatabaseSchema.V1 { public var bio: String public var avatarURL: URL? - @Relationship(deleteRule: .cascade) + @Relationship(deleteRule: .cascade, inverse: \TweetModel.author) public var tweets: [TweetModel] @Relationship @@ -33,11 +33,7 @@ extension DatabaseSchema.V1 { password: Data, displayName: String = "", bio: String = "", - avatarURL: URL? = nil, - tweets: [TweetModel] = [], - likedTweets: [TweetModel] = [], - follows: [UserModel] = [], - followers: [UserModel] = [] + avatarURL: URL? = nil ) { self.id = id.rawValue self.username = username @@ -45,10 +41,10 @@ extension DatabaseSchema.V1 { self.displayName = displayName self.bio = bio self.avatarURL = avatarURL - self.tweets = tweets - self.likedTweets = likedTweets - self.follows = follows - self.followers = followers + self.tweets = [] + self.likedTweets = [] + self.follows = [] + self.followers = [] } } } diff --git a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift index 24f37ac..8c19a8a 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift @@ -187,6 +187,11 @@ public struct TweetDetailFeature { } } + Pullback(\.destination.presented.tweetReply.tweet) { state in + #warning("Observe posting result instead of action and update child tweets") + return .send(.destination(.dismiss)) + } + Scope( state: \State.source, action: \.source, diff --git a/Example/Sources/TweetReplyFeature/SimpleTweetPreviewView.swift b/Example/Sources/TweetReplyFeature/SimpleTweetPreviewView.swift index 9cb16ac..66259fc 100644 --- a/Example/Sources/TweetReplyFeature/SimpleTweetPreviewView.swift +++ b/Example/Sources/TweetReplyFeature/SimpleTweetPreviewView.swift @@ -19,6 +19,7 @@ public struct SimpleTweetPreviewView: ComposableView { public var body: some View { _body + .frame(maxWidth: .infinity, alignment: .leading) .scaledFont(ofSize: 14) .background(color(\.background.primary)) } diff --git a/Example/Sources/TweetReplyFeature/CreateTweetFeature.swift b/Example/Sources/TweetReplyFeature/TweetReplyFeature.swift similarity index 63% rename from Example/Sources/TweetReplyFeature/CreateTweetFeature.swift rename to Example/Sources/TweetReplyFeature/TweetReplyFeature.swift index 948eb11..82a172a 100644 --- a/Example/Sources/TweetReplyFeature/CreateTweetFeature.swift +++ b/Example/Sources/TweetReplyFeature/TweetReplyFeature.swift @@ -26,9 +26,27 @@ public struct TweetReplyFeature { @CasePathable public enum Action: Equatable, BindableAction { case binding(BindingAction) + case tweet } + @Dependency(\.apiClient) + var apiClient + + @Dependency(\.currentUser) + var currentUser + public var body: some ReducerOf { + Pullback(\.tweet) { state in + guard state.replyText.isNotEmpty else { return .none } + let state = state + return .run { send in + #warning("Handle error") + _ = await apiClient.tweet.reply( + to: state.source.id, + with: state.replyText + ) + } + } BindingReducer() } } diff --git a/Example/Sources/TweetReplyFeature/CreateTweetView.swift b/Example/Sources/TweetReplyFeature/TweetReplyView.swift similarity index 95% rename from Example/Sources/TweetReplyFeature/CreateTweetView.swift rename to Example/Sources/TweetReplyFeature/TweetReplyView.swift index ae43d28..b4db09a 100644 --- a/Example/Sources/TweetReplyFeature/CreateTweetView.swift +++ b/Example/Sources/TweetReplyFeature/TweetReplyView.swift @@ -30,8 +30,9 @@ public struct TweetReplyView: ComposableView { } .toolbar { Button("Tweet") { - #warning("Handle tweet action") + store.send(.tweet) } + .disabled(store.replyText.isEmpty) } .toolbarRole(.navigationStack) .onAppear { focused = true } diff --git a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift index faf203b..7387ba6 100644 --- a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift @@ -20,7 +20,7 @@ public struct TweetsFeedFeature { public var alert: AlertState? public init( - list: TweetsListFeature.State = .init(), + list: TweetsListFeature.State = .init(), detail: TweetDetailFeature.State? = nil, alert: AlertState? = nil ) { diff --git a/Example/Sources/_Extensions/LocalExtensions/Exports.swift b/Example/Sources/_Extensions/LocalExtensions/Exports.swift index ee0b4f0..433b363 100644 --- a/Example/Sources/_Extensions/LocalExtensions/Exports.swift +++ b/Example/Sources/_Extensions/LocalExtensions/Exports.swift @@ -1,3 +1,7 @@ @_exported import FoundationExtensions @_exported import IdentifiedCollections @_exported import CombineExtensions + +extension USID { + public static func uuid() -> Self { .init(UUID()) } +} From 34291b89c03d451e3537904444c0300caa6a5924 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Wed, 3 Jan 2024 17:26:55 +0100 Subject: [PATCH 37/43] feat: presentationDestination initial implementation --- Makefile | 5 +- Package.swift | 2 +- Sources/CombineNavigation/Bootstrap.swift | 3 +- .../PresentationDestination.swift | 126 ++++++++++++++++++ .../Destinations/StackDestination.swift | 8 +- .../Destinations/TreeDestination.swift | 8 +- .../CombineNavigationRouter+API.swift | 71 +++++++--- .../Internal/CombineNavigationRouter.swift | 76 ++++++++++- .../Internal/Helpers/EnumTag.swift | 20 +++ .../NavigationAnimation.swift | 19 +++ ...+API.swift => RoutingController+API.swift} | 64 +++++++-- ...CocoaViewController+DismissPublisher.swift | 73 ++++++++++ .../UINavigationController+PopPublisher.swift | 13 +- .../RoutingControllerMacro.swift | 3 +- .../RoutingControllerMacroTests.swift | 101 ++++++++++++++ .../PresentationDestinationTests.swift | 87 ++++++++++++ .../RoutingControllerPresentationTests.swift | 124 +++++++++++++++++ .../RoutingControllerTests.swift | 1 - 18 files changed, 754 insertions(+), 50 deletions(-) create mode 100644 Sources/CombineNavigation/Destinations/PresentationDestination.swift create mode 100644 Sources/CombineNavigation/Internal/Helpers/EnumTag.swift rename Sources/CombineNavigation/{CocoaViewController+API.swift => RoutingController+API.swift} (57%) create mode 100644 Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift rename Sources/CombineNavigation/{ => Swizzling}/UINavigationController+PopPublisher.swift (93%) create mode 100644 Tests/CombineNavigationTests/Destinations/PresentationDestinationTests.swift create mode 100644 Tests/CombineNavigationTests/RoutingControllerPresentationTests.swift diff --git a/Makefile b/Makefile index a8c2481..d01e032 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ test: xcodebuild \ - -scheme CombineNavigation \ + -scheme "combine-cocoa-navigation" \ -destination platform="iOS Simulator,name=iPhone 15 Pro,OS=17.0" \ test | xcpretty && exit 0 + +test-macro: + swift test --filter CombineNavigationMacrosTests diff --git a/Package.swift b/Package.swift index b118e2b..e30b9d5 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,7 @@ let package = Package( .library( name: "CombineNavigation", targets: ["CombineNavigation"] - ), + ) ], dependencies: [ .package( diff --git a/Sources/CombineNavigation/Bootstrap.swift b/Sources/CombineNavigation/Bootstrap.swift index 4a6d674..0fc1d6c 100644 --- a/Sources/CombineNavigation/Bootstrap.swift +++ b/Sources/CombineNavigation/Bootstrap.swift @@ -2,6 +2,7 @@ import CocoaAliases public func bootstrap() { - UINavigationController.swizzle + CocoaViewController.bootstrapDismissPublisher + UINavigationController.bootstrapPopPublisher } #endif diff --git a/Sources/CombineNavigation/Destinations/PresentationDestination.swift b/Sources/CombineNavigation/Destinations/PresentationDestination.swift new file mode 100644 index 0000000..1e049a1 --- /dev/null +++ b/Sources/CombineNavigation/Destinations/PresentationDestination.swift @@ -0,0 +1,126 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +public protocol _PresentationDestinationProtocol: AnyObject { + @_spi(Internals) + func _initControllerIfNeeded() -> CocoaViewController + + @_spi(Internals) + func _invalidate() + + @_spi(Internals) + var _currentController: CocoaViewController? { get } +} + +/// Wrapper for creating and accessing managed navigation destination controller +/// +/// > ⚠️ Sublasses or typealiases must contain "TreeDestination" in their name +/// > to be processed by `@RoutingController` macro +@propertyWrapper +open class PresentationDestination: + Weakifiable, + _PresentationDestinationProtocol +{ + @_spi(Internals) + open var _controller: Controller? + + @_spi(Internals) + public var _currentController: CocoaViewController? { _controller } + + open var wrappedValue: Controller? { _controller } + + @inlinable + open var projectedValue: PresentationDestination { self } + + @usableFromInline + internal var _initControllerOverride: (() -> Controller)? + + @usableFromInline + internal var _configuration: ((Controller) -> Void)? + + /// Sets instance-specific override for creating a new controller + /// + /// This override has the highest priority when creating a new controller + /// + /// To disable isntance-specific override pass `nil` to this method + @inlinable + public func overrideInitController( + with closure: (() -> Controller)? + ) { + _initControllerOverride = closure + } + + /// Sets instance-specific configuration for controllers + @inlinable + public func setConfiguration( + _ closure: ((Controller) -> Void)? + ) { + _configuration = closure + closure.map { configure in + wrappedValue.map(configure) + } + } + + @_spi(Internals) + @inlinable + open class func initController() -> Controller { + if + let controllerType = (Controller.self as? DestinationInitializableControllerProtocol.Type), + let controller = controllerType._init_for_destination() as? Controller + { + return controller + } else { + return Controller() + } + } + + @_spi(Internals) + @inlinable + open func configureController(_ controller: Controller) {} + + /// Creates a new instance + public init() {} + + /// Creates a new instance with instance-specific override for creating a new controller + /// + /// This override has the highest priority when creating a new controller, default one is just `Controller()` + /// **which can lead to crashes if controller doesn't have an empty init** + /// + /// Default implementation is suitable for most controllers, however if you have a controller which + /// doesn't have a custom init you'll have to use this method or if you have a base controller that + /// requires custom init it'll be beneficial for you to create a custom subclass of TreeDestination + /// and override it's `initController` class method, you can find an example in tests. + @inlinable + public convenience init(_ initControllerOverride: @escaping () -> Controller) { + self.init() + self.overrideInitController(with: initControllerOverride) + } + + @_spi(Internals) + @inlinable + public func _initControllerIfNeeded() -> CocoaViewController { + self.callAsFunction() + } + + @_spi(Internals) + @inlinable + open func _invalidate() { + self._controller = nil + } + + /// Returns wrappedValue if present, intializes and configures a new instance otherwise + public func callAsFunction() -> Controller { + let controller = wrappedValue ?? { + let controller = _initControllerOverride?() ?? Self.initController() + configureController(controller) + _configuration?(controller) + self._controller = controller + return controller + }() + return controller + } +} +#endif diff --git a/Sources/CombineNavigation/Destinations/StackDestination.swift b/Sources/CombineNavigation/Destinations/StackDestination.swift index 3b27a94..29fcd5c 100644 --- a/Sources/CombineNavigation/Destinations/StackDestination.swift +++ b/Sources/CombineNavigation/Destinations/StackDestination.swift @@ -4,14 +4,14 @@ import CocoaAliases import Combine import FoundationExtensions -public protocol GrouppedDestinationProtocol { +public protocol _StackDestinationProtocol: AnyObject { associatedtype DestinationID: Hashable @_spi(Internals) func _initControllerIfNeeded(for id: DestinationID) -> CocoaViewController @_spi(Internals) - func _invalidateDestination(for id: DestinationID) + func _invalidate(_ id: DestinationID) } /// Wrapper for creating and accessing managed navigation stack controllers @@ -22,7 +22,7 @@ public protocol GrouppedDestinationProtocol { open class StackDestination< DestinationID: Hashable, Controller: CocoaViewController ->: Weakifiable, GrouppedDestinationProtocol { +>: Weakifiable, _StackDestinationProtocol { @_spi(Internals) open var _controllers: [DestinationID: Controller] = [:] @@ -114,7 +114,7 @@ open class StackDestination< @_spi(Internals) @inlinable - open func _invalidateDestination(for id: DestinationID) { + open func _invalidate(_ id: DestinationID) { self._controllers.removeValue(forKey: id) } diff --git a/Sources/CombineNavigation/Destinations/TreeDestination.swift b/Sources/CombineNavigation/Destinations/TreeDestination.swift index a7722c5..cd8c2e0 100644 --- a/Sources/CombineNavigation/Destinations/TreeDestination.swift +++ b/Sources/CombineNavigation/Destinations/TreeDestination.swift @@ -4,12 +4,12 @@ import CocoaAliases import Combine import FoundationExtensions -public protocol SingleDestinationProtocol { +public protocol _TreeDestinationProtocol: AnyObject { @_spi(Internals) func _initControllerIfNeeded() -> CocoaViewController @_spi(Internals) - func _invalidateDestination() + func _invalidate() } /// Wrapper for creating and accessing managed navigation destination controller @@ -19,7 +19,7 @@ public protocol SingleDestinationProtocol { @propertyWrapper open class TreeDestination: Weakifiable, - SingleDestinationProtocol + _TreeDestinationProtocol { @_spi(Internals) open var _controller: Controller? @@ -101,7 +101,7 @@ open class TreeDestination: @_spi(Internals) @inlinable - open func _invalidateDestination() { + open func _invalidate() { self._controller = nil } diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift index e600173..9bdfa34 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift @@ -3,11 +3,8 @@ import Capture import CocoaAliases import Combine import FoundationExtensions -@_spi(Reflection) import CasePaths -// MARK: - Public API - -// MARK: navigationStack +// MARK: - navigationStack extension CombineNavigationRouter { /// Subscribes on publisher of navigation stack state @@ -18,7 +15,7 @@ extension CombineNavigationRouter { Route >( _ publisher: P, - switch destination: @escaping (Route) -> any GrouppedDestinationProtocol, + switch destination: @escaping (Route) -> any _StackDestinationProtocol, onPop: @escaping ([C.Index]) -> Void ) -> Cancellable where P.Output == C, @@ -74,7 +71,7 @@ extension CombineNavigationRouter { _ publisher: P, ids: @escaping (Stack) -> IDs, route: @escaping (Stack, IDs.Element) -> Route?, - switch destination: @escaping (Route) -> any GrouppedDestinationProtocol, + switch destination: @escaping (Route) -> any _StackDestinationProtocol, onPop: @escaping ([IDs.Element]) -> Void ) -> Cancellable where P.Output == Stack, @@ -90,7 +87,7 @@ extension CombineNavigationRouter { return _self.makeNavigationRoute( for: id, controller: { destination._initControllerIfNeeded(for: id) }, - invalidationHandler: { destination._invalidateDestination(for: id) } + invalidationHandler: { destination._invalidate(id) } ) } } @@ -152,10 +149,13 @@ extension CombineNavigationRouter { managedRoutes, onPop: managedRoutes.isNotEmpty ? { poppedRoutes in - onPop(poppedRoutes.compactMap { route in + let routes = poppedRoutes.compactMap { (route) -> DestinationID? in guard managedRoutes.contains(where: { $0 === route }) else { return nil } return route.id as? DestinationID - }) + } + + poppedRoutes.forEach { $0.invalidationHandler?() } + onPop(routes) } : nil ) @@ -163,7 +163,7 @@ extension CombineNavigationRouter { } } -// MARK: navigationDestination +// MARK: - navigationDestination extension CombineNavigationRouter { /// Subscribes on publisher of navigation destination state @@ -171,7 +171,7 @@ extension CombineNavigationRouter { func navigationDestination( _ id: AnyHashable, isPresented publisher: P, - destination: SingleDestinationProtocol, + destination: _TreeDestinationProtocol, onPop: @escaping () -> Void ) -> AnyCancellable where P.Output == Bool, @@ -188,7 +188,7 @@ extension CombineNavigationRouter { @usableFromInline func navigationDestination( _ publisher: P, - switch destination: @escaping (Route) -> SingleDestinationProtocol, + switch destination: @escaping (Route) -> _TreeDestinationProtocol, onPop: @escaping () -> Void ) -> AnyCancellable where P.Output == Optional, @@ -206,7 +206,7 @@ extension CombineNavigationRouter { return self.makeNavigationRoute( for: enumTag(route), controller: destination._initControllerIfNeeded, - invalidationHandler: destination._invalidateDestination + invalidationHandler: destination._invalidate ) } .sink(receiveValue: capture { _self, route in @@ -215,6 +215,7 @@ extension CombineNavigationRouter { onPop: route.map { route in return { poppedRoutes in let shouldTriggerPopHandler = poppedRoutes.contains(where: { $0 === route }) + poppedRoutes.forEach { $0.invalidationHandler?() } if shouldTriggerPopHandler { onPop() } } } @@ -223,9 +224,45 @@ extension CombineNavigationRouter { } } -/// Index of enum case in its declaration -@usableFromInline -internal func enumTag(_ `case`: Case) -> UInt32? { - EnumMetadata(Case.self)?.tag(of: `case`) +// MARK: - presentationDestination + +extension CombineNavigationRouter { + /// Subscribes on publisher of navigation destination state + @usableFromInline + func presentationDestination( + _ id: AnyHashable, + isPresented publisher: P, + destination: _PresentationDestinationProtocol, + onDismiss: @escaping () -> Void + ) -> AnyCancellable where + P.Output == Bool, + P.Failure == Never + { + presentationDestination( + publisher.map { $0 ? id : nil }, + switch: { _ in destination }, + onDismiss: onDismiss + ) + } + + /// Subscribes on publisher of navigation destination state + @usableFromInline + func presentationDestination( + _ publisher: P, + switch destination: @escaping (Route) -> _PresentationDestinationProtocol, + onDismiss: @escaping () -> Void + ) -> AnyCancellable where + P.Output == Optional, + P.Failure == Never + { + publisher + .removeDuplicates(by: Optional.compareTagsEqual) + .sink(receiveValue: capture { _self, route in + _self.present( + route.map(destination), + onDismiss: onDismiss + ) + }) + } } #endif diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift index 9850572..6fdf960 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift @@ -30,7 +30,7 @@ extension CombineNavigationRouter { let routingControllerID: ObjectIdentifier private(set) var routedControllerID: ObjectIdentifier? private let controller: () -> CocoaViewController? - private let invalidationHandler: (() -> Void)? + let invalidationHandler: (() -> Void)? init( id: AnyHashable, @@ -64,10 +64,14 @@ final class CombineNavigationRouter: Weakifiable { } fileprivate var navigationControllerCancellable: AnyCancellable? + fileprivate var windowCancellable: AnyCancellable? + fileprivate var destinationDismissCancellable: AnyCancellable? + fileprivate var destinationPopCancellable: AnyCancellable? fileprivate var popHandler: (([NavigationRoute]) -> Void)? fileprivate var routes: [NavigationRoute] = [] + fileprivate var presentedDestination: _PresentationDestinationProtocol? fileprivate init(_ node: CocoaViewController?) { self.node = node @@ -89,6 +93,16 @@ final class CombineNavigationRouter: Weakifiable { self.requestNavigationStackSync() } + func present( + _ presentationDestination: _PresentationDestinationProtocol?, + onDismiss: @escaping () -> Void + ) { + self.requestSetPresentationDestination( + presentationDestination, + onDismiss: onDismiss + ) + } + func makeNavigationRoute( for id: ID, controller: @escaping () -> CocoaViewController?, @@ -106,6 +120,64 @@ final class CombineNavigationRouter: Weakifiable { // MARK: Navigation stack sync extension CombineNavigationRouter { + fileprivate func requestSetPresentationDestination( + _ destination: _PresentationDestinationProtocol?, + onDismiss: @escaping () -> Void + ) { + guard let node else { return } + + if node.view.window != nil { + _setPresentationDestination( + destination, + onDismiss: onDismiss + ) + } else { + node.view.publisher(for: \.window) + .filter(\.isNotNil) + .sink(receiveValue: capture { _self, _ in + _self._setPresentationDestination( + destination, + onDismiss: onDismiss + ) + }) + .store(in: &windowCancellable) + } + } + + private func _setPresentationDestination( + _ newDestination: _PresentationDestinationProtocol?, + onDismiss: @escaping () -> Void + ) { + self.windowCancellable = nil + + let oldDestination = self.presentedDestination + + guard oldDestination !== newDestination else { return } + + // Dont track dismiss if it was triggered + // manually from this method + self.destinationDismissCancellable = nil + + let __presentNewDestinationIfNeeded: () -> Void = { + if let destination = newDestination { + let controller = destination._initControllerIfNeeded() + controller.dismissPublisher + .sink(receiveValue: onDismiss) + .store(in: &self.destinationDismissCancellable) + self.node.present(controller) + } + + self.presentedDestination = newDestination + oldDestination?._invalidate() + } + + if node.presentedViewController != nil { + node.dismiss(completion: __presentNewDestinationIfNeeded) + } else { + __presentNewDestinationIfNeeded() + } + } + fileprivate func requestNavigationStackSync() { guard let node else { return } @@ -137,7 +209,7 @@ extension CombineNavigationRouter { _self.routes = routes.kept _self.popHandler?(routes.popped) }) - .store(in: &destinationDismissCancellable) + .store(in: &destinationPopCancellable) navigation.setViewControllers( buildNavigationStack() diff --git a/Sources/CombineNavigation/Internal/Helpers/EnumTag.swift b/Sources/CombineNavigation/Internal/Helpers/EnumTag.swift new file mode 100644 index 0000000..c578594 --- /dev/null +++ b/Sources/CombineNavigation/Internal/Helpers/EnumTag.swift @@ -0,0 +1,20 @@ +@_spi(Reflection) import CasePaths + +/// Index of enum case in its declaration +@usableFromInline +internal func enumTag(_ `case`: Case) -> UInt32? { + EnumMetadata(Case.self)?.tag(of: `case`) +} + +extension Optional { + /// Index of enum case in its declaration + @usableFromInline + internal static func compareTagsEqual( + lhs: Self, + rhs: Self + ) -> Bool { + let wrapped: Bool = enumTag(lhs) == enumTag(rhs) + let unwrapped: Bool = lhs.flatMap(enumTag) == rhs.flatMap(enumTag) + return wrapped && unwrapped + } +} diff --git a/Sources/CombineNavigation/NavigationAnimation.swift b/Sources/CombineNavigation/NavigationAnimation.swift index ad73fe5..248dfd0 100644 --- a/Sources/CombineNavigation/NavigationAnimation.swift +++ b/Sources/CombineNavigation/NavigationAnimation.swift @@ -2,6 +2,25 @@ import Combine import CocoaAliases +extension CocoaViewController { + public func present( + _ controller: CocoaViewController, + completion: (() -> Void)? = nil + ) { + present( + controller, + animated: NavigationAnimation.$isEnabled.get(), + completion: completion + ) + } + public func dismiss(completion: (() -> Void)? = nil) { + dismiss( + animated: NavigationAnimation.$isEnabled.get(), + completion: completion + ) + } +} + extension UINavigationController { @discardableResult public func popViewController() -> CocoaViewController? { diff --git a/Sources/CombineNavigation/CocoaViewController+API.swift b/Sources/CombineNavigation/RoutingController+API.swift similarity index 57% rename from Sources/CombineNavigation/CocoaViewController+API.swift rename to Sources/CombineNavigation/RoutingController+API.swift index 218b753..d4ff95e 100644 --- a/Sources/CombineNavigation/CocoaViewController+API.swift +++ b/Sources/CombineNavigation/RoutingController+API.swift @@ -17,7 +17,7 @@ extension RoutingController { Route >( _ publisher: P, - switch destination: @escaping (Destinations, Route) -> any GrouppedDestinationProtocol, + switch destination: @escaping (Destinations, Route) -> any _StackDestinationProtocol, onPop: @escaping ([C.Index]) -> Void ) -> Cancellable where P.Output == C, @@ -44,7 +44,7 @@ extension RoutingController { _ publisher: P, ids: @escaping (Stack) -> IDs, route: @escaping (Stack, IDs.Element) -> Route?, - switch destination: @escaping (Destinations, Route) -> any GrouppedDestinationProtocol, + switch destination: @escaping (Destinations, Route) -> any _StackDestinationProtocol, onPop: @escaping ([IDs.Element]) -> Void ) -> Cancellable where P.Output == Stack, @@ -69,7 +69,7 @@ extension RoutingController { public func navigationDestination( _ id: AnyHashable, isPresented publisher: P, - destination: SingleDestinationProtocol, + destination: _TreeDestinationProtocol, onPop: @escaping () -> Void ) -> AnyCancellable where P.Output == Bool, @@ -87,7 +87,7 @@ extension RoutingController { @inlinable public func navigationDestination( _ publisher: P, - switch destination: @escaping (Destinations, Route) -> SingleDestinationProtocol, + switch destination: @escaping (Destinations, Route) -> _TreeDestinationProtocol, onPop: @escaping () -> Void ) -> AnyCancellable where P.Output == Route?, @@ -101,13 +101,61 @@ extension RoutingController { } } +// MARK: - presentationDestination + +extension RoutingController { + @inlinable + public func presentationDestination( + _ id: AnyHashable, + isPresented publisher: P, + destination: _PresentationDestinationProtocol, + onDismiss: @escaping () -> Void + ) -> AnyCancellable where + P.Output == Bool, + P.Failure == Never + { + combineNavigationRouter.presentationDestination( + id, + isPresented: publisher, + destination: destination, + onDismiss: onDismiss + ) + } + + @inlinable + public func presentationDestination( + _ publisher: P, + switch destination: @escaping (Destinations, Route) -> _PresentationDestinationProtocol, + onDismiss: @escaping () -> Void + ) -> AnyCancellable where + P.Output == Route?, + P.Failure == Never + { + combineNavigationRouter.presentationDestination( + publisher, + switch: destinations(destination), + onDismiss: onDismiss + ) + } +} + // MARK: - Internal helpers extension RoutingController { @usableFromInline internal func destinations( - _ mapping: @escaping (Destinations, Route) -> SingleDestinationProtocol - ) -> (Route) -> SingleDestinationProtocol { + _ mapping: @escaping (Destinations, Route) -> _TreeDestinationProtocol + ) -> (Route) -> _TreeDestinationProtocol { + let destinations = _makeDestinations() + return { route in + mapping(destinations, route) + } + } + + @usableFromInline + internal func destinations( + _ mapping: @escaping (Destinations, Route) -> _PresentationDestinationProtocol + ) -> (Route) -> _PresentationDestinationProtocol { let destinations = _makeDestinations() return { route in mapping(destinations, route) @@ -116,8 +164,8 @@ extension RoutingController { @usableFromInline internal func destinations( - _ mapping: @escaping (Destinations, Route) -> any GrouppedDestinationProtocol - ) -> (Route) -> any GrouppedDestinationProtocol { + _ mapping: @escaping (Destinations, Route) -> any _StackDestinationProtocol + ) -> (Route) -> any _StackDestinationProtocol { let destinations = _makeDestinations() return { route in mapping(destinations, route) diff --git a/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift b/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift new file mode 100644 index 0000000..c678f6e --- /dev/null +++ b/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift @@ -0,0 +1,73 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +extension CocoaViewController { + @AssociatedObject(readonly: true) + fileprivate var dismissSubject: PassthroughSubject = .init() + + /// Publisher for dismiss + /// + /// Emits an event for **dismissed controller** on `dismiss` completion + /// + /// > It has different behavior from simply observing `dismiss(animated:completion:)` + /// > selector, the publisher is always called on the dismissed controller + /// + /// Underlying subject is triggered by swizzled methods in `CombineNavigation` module. + public var dismissPublisher: some Publisher { + return dismissSubject + } +} + +// MARK: - Swizzling +// Swizzle methods that may pop some viewControllers +// with tracking versions which forward popped controllers +// to UINavigationController.popSubject + +// Swift swizzling causes infinite recursion for objc methods +// +// Forum: +// https://forums.swift.org/t/dynamicreplacement-causes-infinite-recursion/52768 +// +// Swift issues: +// https://github.com/apple/swift/issues/62214 +// https://github.com/apple/swift/issues/53916 +// +// Have to use objc swizzling +// +//extension CocoaViewController { +// @_dynamicReplacement(for: dismiss(animated:completion:)) +// public func _trackedDismiss( +// animated: Bool, +// completion: (() -> Void)? = nil +// ) { +// dismiss(animated: animated) { +// self.dismissSubject.send(()) +// completion?() +// } +// } +//} + +extension CocoaViewController { + // Runs once in app lifetime + internal static let bootstrapDismissPublisher: Void = { + objc_exchangeImplementations( + #selector(dismiss(animated:completion:)), + #selector(__swizzledDismiss) + ) + }() + + @objc dynamic func __swizzledDismiss( + animated: Bool, + completion: (() -> Void)? + ) { + let dismissedController: UIViewController = presentedViewController ?? self + __swizzledDismiss(animated: animated, completion: { + dismissedController.dismissSubject.send(()) + completion?() + }) + } +} +#endif diff --git a/Sources/CombineNavigation/UINavigationController+PopPublisher.swift b/Sources/CombineNavigation/Swizzling/UINavigationController+PopPublisher.swift similarity index 93% rename from Sources/CombineNavigation/UINavigationController+PopPublisher.swift rename to Sources/CombineNavigation/Swizzling/UINavigationController+PopPublisher.swift index b1e5bae..b33d08a 100644 --- a/Sources/CombineNavigation/UINavigationController+PopPublisher.swift +++ b/Sources/CombineNavigation/Swizzling/UINavigationController+PopPublisher.swift @@ -5,15 +5,8 @@ import Combine import FoundationExtensions extension UINavigationController { - private typealias PopSubject = PassthroughSubject<[CocoaViewController], Never> - - private var popSubject: PopSubject { - getAssociatedObject(forKey: #function) ?? { - let subject = PopSubject() - setAssociatedObject(subject, forKey: #function) - return subject - }() - } + @AssociatedObject(readonly: true) + private var popSubject: PassthroughSubject<[CocoaViewController], Never> = .init() /// Publisher for popped controllers /// @@ -109,7 +102,7 @@ extension UINavigationController { extension UINavigationController { // Runs once in app lifetime - internal static let swizzle: Void = { + internal static let bootstrapPopPublisher: Void = { objc_exchangeImplementations( #selector(popViewController(animated:)), #selector(__swizzledPopViewController) diff --git a/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift index d5eab97..f8b153d 100644 --- a/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift +++ b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift @@ -67,7 +67,8 @@ extension RoutingControllerMacro: ExtensionMacro { switch attribute { case let .attribute(attribute) where attribute.name.name.contains("TreeDestination") || - attribute.name.name.contains("StackDestination"): + attribute.name.name.contains("StackDestination") || + attribute.name.name.contains("PresentationDestination"): return true default: return false diff --git a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift index 6030434..141f187 100644 --- a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift +++ b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift @@ -1,5 +1,6 @@ import XCTest import MacroTesting +#if canImport(CombineNavigationMacros) import CombineNavigationMacros final class RoutingControllerMacroTests: XCTestCase { @@ -139,6 +140,55 @@ final class RoutingControllerMacroTests: XCTestCase { """ } } + func testAttachmentToClass_PresentationDestinations() { + assertMacro { + """ + @RoutingController + final class CustomController { + @PresentationDestination + var firstDetailController: CocoaViewController? + @PresentationDestination + var secondDetailController: CocoaViewController? + } + """ + } expansion: { + """ + final class CustomController { + @PresentationDestination + var firstDetailController: CocoaViewController? + @PresentationDestination + var secondDetailController: CocoaViewController? + } + + extension CustomController: CombineNavigation.RoutingController { + /// Container for captured destinations without referring to self + /// + /// > Generated by `CombineNavigationMacros.RoutingController` macro + /// + /// Use in `navigationDestination`/`navigationStack` methods to map + /// routes to specific destinations using `destinations` method + public struct Destinations { + @PresentationDestination + var firstDetailController: CocoaViewController? + + @PresentationDestination + var secondDetailController: CocoaViewController? + + public subscript(_ id: some Hashable) -> UIViewController? { + return nil + } + } + + public func _makeDestinations() -> Destinations { + return Destinations( + firstDetailController: $firstDetailController, + secondDetailController: $secondDetailController + ) + } + } + """ + } + } func testAttachmentToClass_StackDestinations() { assertMacro { @@ -301,6 +351,56 @@ final class RoutingControllerMacroTests: XCTestCase { } } + func testAttachmentToClass_CustomPresentationDestinations() { + assertMacro { + """ + @RoutingController + final class CustomController { + @CustomPresentationDestination + var firstDetailController: CocoaViewController? + @CustomPresentationDestination + var secondDetailController: CocoaViewController? + } + """ + } expansion: { + """ + final class CustomController { + @CustomPresentationDestination + var firstDetailController: CocoaViewController? + @CustomPresentationDestination + var secondDetailController: CocoaViewController? + } + + extension CustomController: CombineNavigation.RoutingController { + /// Container for captured destinations without referring to self + /// + /// > Generated by `CombineNavigationMacros.RoutingController` macro + /// + /// Use in `navigationDestination`/`navigationStack` methods to map + /// routes to specific destinations using `destinations` method + public struct Destinations { + @CustomPresentationDestination + var firstDetailController: CocoaViewController? + + @CustomPresentationDestination + var secondDetailController: CocoaViewController? + + public subscript(_ id: some Hashable) -> UIViewController? { + return nil + } + } + + public func _makeDestinations() -> Destinations { + return Destinations( + firstDetailController: $firstDetailController, + secondDetailController: $secondDetailController + ) + } + } + """ + } + } + func testAttachmentToClass_CustomStackDestinations() { assertMacro { """ @@ -363,3 +463,4 @@ final class RoutingControllerMacroTests: XCTestCase { } } } +#endif diff --git a/Tests/CombineNavigationTests/Destinations/PresentationDestinationTests.swift b/Tests/CombineNavigationTests/Destinations/PresentationDestinationTests.swift new file mode 100644 index 0000000..e7ca37a --- /dev/null +++ b/Tests/CombineNavigationTests/Destinations/PresentationDestinationTests.swift @@ -0,0 +1,87 @@ +import XCTest +import CocoaAliases +@_spi(Internals) import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) +final class PresentationDestinationTests: XCTestCase { + func testMain() { + @TreeDestination + var sut: CustomViewController? + + @TreeDestination({ .init(value: 1) }) + var configuredSUT: CustomViewController? + + XCTAssertEqual(_sut().value, 0) + XCTAssertEqual(_configuredSUT().value, 1) + + XCTAssertEqual(_sut().isConfiguredByCustomNavigationChild, false) + XCTAssertEqual(_configuredSUT().isConfiguredByCustomNavigationChild, false) + } + + func testInheritance() { + @CustomPresentationDestination + var sut: CustomViewController? + + @CustomPresentationDestination({ .init(value: 2) }) + var configuredSUT: CustomViewController? + + XCTAssertEqual(_sut().value, 1) + XCTAssertEqual(_configuredSUT().value, 2) + + XCTAssertEqual(_sut().isConfiguredByCustomNavigationChild, true) + XCTAssertEqual(_configuredSUT().isConfiguredByCustomNavigationChild, true) + + // Should compile to pass the test + _sut.customNavigationChildSpecificMethod() + + // Should compile to pass the test + $sut.customNavigationChildSpecificMethod() + } +} + +fileprivate class CustomViewController: CocoaViewController { + var value: Int = 0 + var isConfiguredByCustomNavigationChild: Bool = false + + convenience init() { + self.init(value: 0) + } + + required init(value: Int) { + super.init(nibName: nil, bundle: nil) + self.value = value + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } +} + +@propertyWrapper +fileprivate final class CustomPresentationDestination< + Controller: CustomViewController +>: TreeDestination { + override var wrappedValue: Controller? { super.wrappedValue } + override var projectedValue: CustomPresentationDestination { super.projectedValue as! Self } + + func customNavigationChildSpecificMethod() { } + + /// Override this method to apply initial configuration to the controller + /// + /// `CombineNavigation` should be imported as `@_spi(Internals) import` + /// to override this declaration + override func configureController(_ controller: Controller) { + controller.isConfiguredByCustomNavigationChild = true + } + + /// This wrapper is binded to a custom controller type + /// so you can override wrapper's `initController` method + /// to call some specific initializer + /// + /// `CombineNavigation` should be imported as `@_spi(Internals) import` + /// to override this declaration + override class func initController() -> Controller { + .init(value: 1) + } +} +#endif diff --git a/Tests/CombineNavigationTests/RoutingControllerPresentationTests.swift b/Tests/CombineNavigationTests/RoutingControllerPresentationTests.swift new file mode 100644 index 0000000..d59340c --- /dev/null +++ b/Tests/CombineNavigationTests/RoutingControllerPresentationTests.swift @@ -0,0 +1,124 @@ +import XCTest +import CocoaAliases +import Capture +import Combine +@testable import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) + +// TODO: Test deinitialization +// Note: Manual check succeed ✅ + +final class RoutingControllePresentationTests: XCTestCase { + static override func setUp() { + CombineNavigation.bootstrap() + } + + func testMain() { + let window = UIWindow(frame: UIScreen.main.bounds) + let viewModel = PresentationViewModel() + let controller = PresentationViewController() + window.rootViewController = controller + window.makeKeyAndVisible() + _ = controller.view + controller.viewModel = viewModel + + // Disable navigation animation for tests + withoutNavigationAnimation { + XCTAssert(controller._topPresentedController === controller) + + viewModel.state.value.destination = .feedback() + XCTAssert(controller._topPresentedController === controller.feedbackController) + + viewModel.state.value.destination = .orderDetail() + XCTAssert(controller._topPresentedController === controller.orderDetailController) + + controller.dismiss() + XCTAssertEqual(viewModel.state.value.destination, .none) + + viewModel.state.value.destination = .feedback() + XCTAssert(controller._topPresentedController === controller.feedbackController) + + viewModel.state.value.destination = .none + XCTAssert(controller._topPresentedController === controller) + } + } +} + +fileprivate let testDestinationID = UUID() + +fileprivate class OrderDetailsController: CocoaViewController {} +fileprivate class FeedbackController: CocoaViewController { + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + print("Disappear") + } +} + +fileprivate class PresentationViewModel { + struct State { + enum Destination: Equatable { + /// UUID represents some state + case orderDetail(UUID = testDestinationID) + case feedback(UUID = testDestinationID) + } + + var destination: Destination? + } + + let state = CurrentValueSubject(.init()) + var animationsDisabled: Bool = false + + var publisher: some Publisher { + animationsDisabled + ? state + .withNavigationAnimation(false) + .eraseToAnyPublisher() + : state + .eraseToAnyPublisher() + } +} + +@RoutingController +fileprivate class PresentationViewController: CocoaViewController { + private var cancellables: Set = [] + + var viewModel: PresentationViewModel! { + didSet { bind(viewModel.publisher) } + } + + @PresentationDestination + var orderDetailController: OrderDetailsController? + + @PresentationDestination + var feedbackController: FeedbackController? + + func bind>(_ publisher: P) { + presentationDestination( + publisher.map(\.destination), + switch: { destinations, route in + switch route { + case .orderDetail: + destinations.$orderDetailController + case .feedback: + destinations.$feedbackController + } + }, + onDismiss: capture { _self in + _self.viewModel.state.value.destination = .none + } + ) + .store(in: &cancellables) + } +} +extension CocoaViewController { + var _topPresentedController: CocoaViewController? { + if let presentedViewController { + return presentedViewController._topPresentedController + } else { + return self + } + } +} + +#endif diff --git a/Tests/CombineNavigationTests/RoutingControllerTests.swift b/Tests/CombineNavigationTests/RoutingControllerTests.swift index f90978f..d4a8a5c 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTests.swift @@ -2,7 +2,6 @@ import XCTest import CocoaAliases import Capture import Combine -import CombineSchedulers @testable import CombineNavigation #if canImport(UIKit) && !os(watchOS) From fc685e71b56660f8894f6786c461b7fabfa4d542 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Wed, 3 Jan 2024 19:04:05 +0100 Subject: [PATCH 38/43] feat: Add todo warnings for presentaitonDestination --- Sources/CombineNavigation/Internal/CombineNavigationRouter.swift | 1 + .../Swizzling/CocoaViewController+DismissPublisher.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift index 6fdf960..b6a35c5 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift @@ -156,6 +156,7 @@ extension CombineNavigationRouter { // Dont track dismiss if it was triggered // manually from this method + #warning("Ensure that unrelated presentations are not affected") self.destinationDismissCancellable = nil let __presentNewDestinationIfNeeded: () -> Void = { diff --git a/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift b/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift index c678f6e..d5c053d 100644 --- a/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift +++ b/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift @@ -63,6 +63,7 @@ extension CocoaViewController { animated: Bool, completion: (() -> Void)? ) { + #warning("Handle dismiss for all nested presentations") let dismissedController: UIViewController = presentedViewController ?? self __swizzledDismiss(animated: animated, completion: { dismissedController.dismissSubject.send(()) From 698c653e704bde6eabfdd2090d1b5383f8cf928b Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Wed, 3 Jan 2024 19:04:38 +0100 Subject: [PATCH 39/43] feat(Example): Presentation integration --- .../xcschemes/CombineNavigation.xcscheme | 2 +- .../combine-cocoa-navigation.xcscheme | 2 +- .../xcshareddata/xcschemes/AppUI.xcscheme | 66 +++ .../xcschemes/ProfileAndFeedPivot.xcscheme | 66 +++ .../xcschemes/TweetDetailFeature.xcscheme | 66 +++ .../xcschemes/TweetReplyFeature.xcscheme | 66 +++ .../xcschemes/TweetsFeedFeature.xcscheme | 66 +++ .../_ComposableArchitecture.xcscheme | 66 +++ Example/Package.swift | 34 +- Example/Sources/AppUI/Button/Button+.swift | 20 + .../Sources/AppUI/Button/Button+Style.swift | 166 +++++++ .../Button/Button+TapAnimationProvider.swift | 247 ++++++++++ Example/Sources/AppUI/Button/Button.swift | 452 ++++++++++++++++++ .../HapticEngineClient.swift | 7 + .../HapticEngineClientLive.swift | 46 ++ .../HapticEngineClientOperations.swift | 34 ++ .../HapticFeedback+Factory.swift | 101 ++++ .../HapticEngineClient/HapticFeedback.swift | 14 + .../FeedTabFeature/FeedTabController.swift | 69 ++- .../FeedTabFeature/FeedTabFeature.swift | 82 ++-- .../ProfileAndFeedPivot.swift | 18 + .../ProfileTabFeature/ProfileTabFeature.swift | 16 +- .../TweetDetailController.swift | 31 +- .../TweetDetailFeature.swift | 145 ++++-- .../TweetPostFeature/TweetPostFeature.swift | 114 +++++ .../TweetPostFeature/TweetPostView.swift | 68 +++ .../TweetReplyFeature/TweetReplyFeature.swift | 114 ++++- .../TweetReplyFeature/TweetReplyView.swift | 4 + .../TweetsFeedController.swift | 18 + .../TweetsFeedFeature/TweetsFeedFeature.swift | 96 +++- .../TweetsListFeature/TweetsListFeature.swift | 4 +- .../UserProfileFeature.swift | 66 ++- .../UserProfileFeature/UserProfileView.swift | 8 + .../_Extensions/LocalExtensions/Unit.swift | 33 ++ 34 files changed, 2227 insertions(+), 180 deletions(-) create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/AppUI.xcscheme create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/ProfileAndFeedPivot.xcscheme create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetDetailFeature.xcscheme create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetReplyFeature.xcscheme create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetsFeedFeature.xcscheme create mode 100644 Example/.swiftpm/xcode/xcshareddata/xcschemes/_ComposableArchitecture.xcscheme create mode 100644 Example/Sources/AppUI/Button/Button+.swift create mode 100644 Example/Sources/AppUI/Button/Button+Style.swift create mode 100644 Example/Sources/AppUI/Button/Button+TapAnimationProvider.swift create mode 100644 Example/Sources/AppUI/Button/Button.swift create mode 100644 Example/Sources/AppUI/HapticEngineClient/HapticEngineClient.swift create mode 100644 Example/Sources/AppUI/HapticEngineClient/HapticEngineClientLive.swift create mode 100644 Example/Sources/AppUI/HapticEngineClient/HapticEngineClientOperations.swift create mode 100644 Example/Sources/AppUI/HapticEngineClient/HapticFeedback+Factory.swift create mode 100644 Example/Sources/AppUI/HapticEngineClient/HapticFeedback.swift create mode 100644 Example/Sources/TweetPostFeature/TweetPostFeature.swift create mode 100644 Example/Sources/TweetPostFeature/TweetPostView.swift create mode 100644 Example/Sources/_Extensions/LocalExtensions/Unit.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigation.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigation.xcscheme index 728cc3f..eada169 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigation.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigation.xcscheme @@ -1,6 +1,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/ProfileAndFeedPivot.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/ProfileAndFeedPivot.xcscheme new file mode 100644 index 0000000..67da82c --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/ProfileAndFeedPivot.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetDetailFeature.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetDetailFeature.xcscheme new file mode 100644 index 0000000..0e1a1ae --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetDetailFeature.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetReplyFeature.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetReplyFeature.xcscheme new file mode 100644 index 0000000..879d66c --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetReplyFeature.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetsFeedFeature.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetsFeedFeature.xcscheme new file mode 100644 index 0000000..39106e3 --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/TweetsFeedFeature.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/.swiftpm/xcode/xcshareddata/xcschemes/_ComposableArchitecture.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/_ComposableArchitecture.xcscheme new file mode 100644 index 0000000..8e2fdff --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/_ComposableArchitecture.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Package.swift b/Example/Package.swift index d9b50a1..97e607d 100644 --- a/Example/Package.swift +++ b/Example/Package.swift @@ -148,16 +148,6 @@ let package = Package( ] ), - .target( - name: "TweetReplyFeature", - product: .library(.static), - dependencies: [ - .target("TweetFeature"), - .dependency("_ComposableArchitecture"), - .localExtensions, - ] - ), - .target( name: "CurrentUserProfileFeature", product: .library(.static), @@ -194,8 +184,11 @@ let package = Package( name: "FeedTabFeature", product: .library(.static), dependencies: [ + .target("AppUI"), .target("UserProfileFeature"), .target("TweetsFeedFeature"), + .target("TweetPostFeature"), + .target("ProfileAndFeedPivot"), .dependency("_ComposableArchitecture"), .localExtensions, ] @@ -279,6 +272,27 @@ let package = Package( ] ), + .target( + name: "TweetPostFeature", + product: .library(.static), + dependencies: [ + .target("APIClient"), + .target("AppUI"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "TweetReplyFeature", + product: .library(.static), + dependencies: [ + .target("TweetFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + .target( name: "TweetsFeedFeature", product: .library(.static), diff --git a/Example/Sources/AppUI/Button/Button+.swift b/Example/Sources/AppUI/Button/Button+.swift new file mode 100644 index 0000000..96bfe16 --- /dev/null +++ b/Example/Sources/AppUI/Button/Button+.swift @@ -0,0 +1,20 @@ +#if os(iOS) +extension CustomButton { + @discardableResult + func applyingStyle(_ style: StyleModifier) -> CustomButton { + style.apply(to: self) + return self + } +} + +extension CustomButton.StyleModifier { + public static func rounded(radius: CGFloat = 12) -> Self { + .init { + $0.content.layer.scope { $0 + .cornerRadius(radius) + .masksToBounds(true) + } + } + } +} +#endif diff --git a/Example/Sources/AppUI/Button/Button+Style.swift b/Example/Sources/AppUI/Button/Button+Style.swift new file mode 100644 index 0000000..aa5d17b --- /dev/null +++ b/Example/Sources/AppUI/Button/Button+Style.swift @@ -0,0 +1,166 @@ +#if os(iOS) +import CocoaAliases +import LocalExtensions + +extension CustomButton { + public struct StyleModifier { + public let config: Config + + public init(_ config: (Config) -> Config) { + self.init(Config(config: config)) + } + + public init(_ config: Config) { + self.config = config + } + + public func apply(to button: CustomButton) { + config.configure(button) + } + } + + public struct DisableConfiguration { + internal init( + isEnabled: Bool, + content: Resettable, + overlay: Resettable + ) { + self.isEnabled = isEnabled + self.content = content + self.overlay = overlay + } + + public let isEnabled: Bool + public let content: Resettable + public let overlay: Resettable + } + + public struct PressConfiguration { + internal init( + isPressed: Bool, + content: Resettable, + overlay: Resettable + ) { + self.isPressed = isPressed + self.content = content + self.overlay = overlay + } + + public let isPressed: Bool + public let content: Resettable + public let overlay: Resettable + } + + public struct StyleManager { + public static func custom(_ update: @escaping (Configuration) -> Void) -> StyleManager { + return StyleManager(update: update) + } + + private let updateStyleForConfiguration: (Configuration) -> Void + + public init(update: @escaping (Configuration) -> Void) { + self.updateStyleForConfiguration = update + } + + func updateStyle(for configuration: Configuration) { + updateStyleForConfiguration(configuration) + } + } +} + +extension CustomButton.StyleManager where Configuration == CustomButton.DisableConfiguration { + public static var `default`: Self { .alpha(0.5) } + + public static var none: Self { .init { _ in } } + + public static func alpha(_ value: CGFloat) -> Self { + .init { configuration in + if configuration.isEnabled { + configuration.content.wrappedValue.alpha = 1 + } else { + configuration.content.wrappedValue.alpha = value + } + } + } + + public static func darken(_ modifier: CGFloat) -> Self { + .init { configuration in + configuration.overlay.wrappedValue.backgroundColor = .black + if configuration.isEnabled { + configuration.overlay.wrappedValue.alpha = 0 + } else { + configuration.overlay.wrappedValue.alpha = modifier + } + } + } + + public static func lighten(_ modifier: CGFloat) -> Self { + .init { configuration in + configuration.overlay.wrappedValue.backgroundColor = .white + if configuration.isEnabled { + configuration.overlay.wrappedValue.alpha = 0 + } else { + configuration.overlay.wrappedValue.alpha = modifier + } + } + } + + public static func scale(_ modifier: CGFloat) -> Self { + .init { configuration in + if configuration.isEnabled { + configuration.content.wrappedValue.transform = .identity + } else { + configuration.content.wrappedValue.transform = .init(scaleX: modifier, y: modifier) + } + } + } +} + +extension CustomButton.StyleManager where Configuration == CustomButton.PressConfiguration { + public static var `default`: Self { .alpha(0.2) } + + public static var none: Self { .init { _ in } } + + public static func alpha(_ value: CGFloat) -> Self { + .init { configuration in + if configuration.isPressed { + configuration.content.wrappedValue.alpha = value + } else { + configuration.content.wrappedValue.alpha = 1 + } + } + } + + public static func darken(_ modifier: CGFloat) -> Self { + .init { configuration in + configuration.overlay.wrappedValue.backgroundColor = .black + if configuration.isPressed { + configuration.overlay.wrappedValue.alpha = modifier + } else { + configuration.overlay.wrappedValue.alpha = 1 + } + } + } + + public static func lighten(_ modifier: CGFloat) -> Self { + .init { configuration in + configuration.overlay.wrappedValue.backgroundColor = .white + if configuration.isPressed { + configuration.overlay.wrappedValue.alpha = modifier + } else { + configuration.overlay.wrappedValue.alpha = 1 + } + } + } + + public static func scale(_ modifier: CGFloat) -> Self { + .init { configuration in + if configuration.isPressed { + configuration.content.wrappedValue.transform = .init(scaleX: modifier, y: modifier) + } else { + configuration.content.wrappedValue.transform = .identity + } + } + } +} +#endif diff --git a/Example/Sources/AppUI/Button/Button+TapAnimationProvider.swift b/Example/Sources/AppUI/Button/Button+TapAnimationProvider.swift new file mode 100644 index 0000000..32c534e --- /dev/null +++ b/Example/Sources/AppUI/Button/Button+TapAnimationProvider.swift @@ -0,0 +1,247 @@ +#if os(iOS) +import UIKit + +public struct Animator { + private let _run: () -> Void + private let _stop: () -> Void + private let _finish: () -> Void + + public func animate() { _run() } + public func stop() { _stop() } + public func finish() { _finish() } + + public init( + run: @escaping () -> Void, + stop: @escaping () -> Void, + finish: @escaping () -> Void + ) { + self._run = run + self._stop = stop + self._finish = finish + } + + public static let empty: Animator = Animator(run: {}, stop: {}, finish: {}) +} + +public protocol AnimatorProviderProtocol { + func makeAnimator(for animations: (() -> Void)?) -> Animator +} + +public struct InstantAnimatorProvider: AnimatorProviderProtocol { + public func makeAnimator(for animations: (() -> Void)? = nil) -> Animator { + Animator(run: { animations?() }, stop: {}, finish: {}) + } +} + +public struct UIViewPropertyAnimatorProvider: AnimatorProviderProtocol { + let duration: TimeInterval + let metadata: Metadata + let finalPosition: UIViewAnimatingPosition + + enum Metadata { + case timingParameters(UITimingCurveProvider) + case curve(UIView.AnimationCurve) + case controlPoints(CGPoint, CGPoint) + case dampingRatio(CGFloat) + } + + public init( + duration: TimeInterval, + timingParameters parameters: UITimingCurveProvider, + finalPosition: UIViewAnimatingPosition = .end + ) { + self.duration = duration + self.metadata = .timingParameters(parameters) + self.finalPosition = finalPosition + } + + /// All convenience initializers return an animator which is not running. + public init( + duration: TimeInterval, + curve: UIView.AnimationCurve, + finalPosition: UIViewAnimatingPosition = .end + ) { + self.duration = duration + self.metadata = .curve(curve) + self.finalPosition = finalPosition + } + + public init( + duration: TimeInterval, + controlPoint1 point1: CGPoint, + controlPoint2 point2: CGPoint, + finalPosition: UIViewAnimatingPosition = .end + ) { + self.duration = duration + self.metadata = .controlPoints(point1, point2) + self.finalPosition = finalPosition + } + + public init( + duration: TimeInterval, + dampingRatio ratio: CGFloat, + finalPosition: UIViewAnimatingPosition = .end + ) { + self.duration = duration + self.metadata = .dampingRatio(ratio) + self.finalPosition = finalPosition + } + + func makePropertyAnimator(for animations: (() -> Void)? = nil) -> UIViewPropertyAnimator { + switch metadata { + case let .timingParameters(parameters): + let animator = UIViewPropertyAnimator(duration: duration, timingParameters: parameters) + animator.addAnimations { animations?() } + return animator + + case let .curve(curve): + return UIViewPropertyAnimator( + duration: duration, + curve: curve, + animations: animations + ) + + case let .controlPoints(p1, p2): + return UIViewPropertyAnimator( + duration: duration, + controlPoint1: p1, + controlPoint2: p2, + animations: animations + ) + + case let .dampingRatio(ratio): + return UIViewPropertyAnimator( + duration: duration, + dampingRatio: ratio, + animations: animations + ) + } + } + + public func makeAnimator(for animations: (() -> Void)? = nil) -> Animator { + let animator = makePropertyAnimator(for: animations) + let finalPosition = self.finalPosition + return Animator( + run: { animator.startAnimation() }, + stop: { animator.stopAnimation(true) }, + finish: { animator.finishAnimation(at: finalPosition) } + ) + } +} + +public struct UIViewAnimatiorProvider: AnimatorProviderProtocol { + let duration: TimeInterval + let delay: TimeInterval + let options: UIView.AnimationOptions + let metadata: Metadata + + enum Metadata { + case spring(initialVelocity: CGFloat, damping: CGFloat) + case none + } + + public init( + duration: TimeInterval, + delay: TimeInterval = 0, + options: UIView.AnimationOptions = [] + ) { + self.duration = duration + self.delay = delay + self.options = options + self.metadata = .none + } + + public init( + duration: TimeInterval, + delay: TimeInterval = 0, + usingSpringWithDamping: CGFloat = 0.5, + initialSpringVelocity: CGFloat = 3, + options: UIView.AnimationOptions = [] + ) { + self.duration = duration + self.delay = delay + self.options = options + self.metadata = .spring( + initialVelocity: initialSpringVelocity, + damping: usingSpringWithDamping + ) + } + + public func makeAnimator( + for animations: (() -> Void)? = nil, + completion: @escaping ((Bool) -> Void) + ) -> Animator { + switch metadata { + case let .spring(initialVelocity, damping): + return Animator( + run: { + UIView.animate( + withDuration: duration, + delay: delay, + usingSpringWithDamping: damping, + initialSpringVelocity: initialVelocity, + options: options, + animations: { animations?() }, + completion: completion + ) + }, + stop: {}, + finish: {} + ) + + case .none: + return Animator( + run: { + UIView.animate( + withDuration: duration, + delay: delay, + options: options, + animations: { animations?() }, + completion: completion + ) + }, + stop: {}, + finish: {} + ) + } + } + + public func makeAnimator( + for animations: (() -> Void)? = nil + ) -> Animator { + switch metadata { + case let .spring(initialVelocity, damping): + return Animator( + run: { + UIView.animate( + withDuration: duration, + delay: delay, + usingSpringWithDamping: damping, + initialSpringVelocity: initialVelocity, + options: options, + animations: { animations?() }, + completion: nil + ) + }, + stop: {}, + finish: {} + ) + + case .none: + return Animator( + run: { + UIView.animate( + withDuration: duration, + delay: delay, + options: options, + animations: { animations?() }, + completion: nil + ) + }, + stop: {}, + finish: {} + ) + } + } +} +#endif diff --git a/Example/Sources/AppUI/Button/Button.swift b/Example/Sources/AppUI/Button/Button.swift new file mode 100644 index 0000000..88684ae --- /dev/null +++ b/Example/Sources/AppUI/Button/Button.swift @@ -0,0 +1,452 @@ +#if os(iOS) +import CocoaAliases +import CocoaExtensions +import DeclarativeConfiguration +import Capture + +extension CustomButton where Content == UILabel { + public func enable() { + isEnabled = true + } + + public func disable() { + isEnabled = false + } +} + +extension UIView { + public func pinToSuperview() { + guard let superview = superview else { return } + translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + topAnchor.constraint(equalTo: superview.topAnchor), + bottomAnchor.constraint(equalTo: superview.bottomAnchor), + leadingAnchor.constraint(equalTo: superview.leadingAnchor), + trailingAnchor.constraint(equalTo: superview.trailingAnchor) + ]) + } +} + +public final class CustomButton: CocoaView { + + // MARK: - Properties + + private let control = Control() + + public let content: Content + + public let overlay = UIView { + $0 + .backgroundColor(.clear) + .alpha(0) + } + + public var pressStartAnimationProvider: UIViewPropertyAnimatorProvider? + public var pressEndAnimationProvider: UIViewPropertyAnimatorProvider? = .init( + duration: 0.4, + curve: .easeOut + ) + private var pressStartAnimatior: UIViewPropertyAnimator? + private var pressEndAnimatior: UIViewPropertyAnimator? + + private var contentPressResettable: Resettable! + private var contentDisableResettable: Resettable! + + private var overlayPressResettable: Resettable! + private var overlayDisableResettable: Resettable! + + public var pressStyle: StyleManager = .default + public var disabledStyle: StyleManager = .default + + public var tapAreaOffset: UIEdgeInsets = .init(all: 8) + + public var haptic: HapticFeedback? { + get { control.haptic } + set { control.haptic = newValue } + } + + public var action: (() -> Void)? { + get { + control.$onAction.map { action in + { action(()) } + } + } + set { + onAction(perform: newValue) + } + } + + private var _isEnabled = true + public var isEnabled: Bool { + get { _isEnabled } + set { + _isEnabled = newValue + isUserInteractionEnabled = newValue + if !isEnabled { + pressEndAnimatior?.stopAnimation(true) + pressEndAnimatior?.finishAnimation(at: .current) + } + disabledStyle.updateStyle( + for: DisableConfiguration( + isEnabled: newValue, + content: contentDisableResettable, + overlay: overlayDisableResettable + ) + ) + } + } + + @PropertyProxy(\CustomButton.control.isEnabled) + public var isControlEnabled: Bool + + // MARK: - Initialization + + public convenience init(action: @escaping () -> Void = {}, content: () -> Content) { + self.init(content: content(), action: action) + } + + public convenience init(action: @escaping () -> Void) { + self.init(content: .init(), action: action) + } + + public convenience init() { + self.init(frame: .zero) + self.configure() + } + + public init(content: Content, action: @escaping () -> Void = {}) { + self.content = content + super.init(frame: .zero) + self.control.onAction(perform: action) + self.configure() + } + + public override init(frame: CGRect) { + self.content = .init() + super.init(frame: frame) + configure() + } + + public required init?(coder: NSCoder) { + self.content = .init() + super.init(coder: coder) + configure() + } + + deinit { + [pressStartAnimatior, pressEndAnimatior].forEach { animator in + animator?.stopAnimation(true) + animator?.finishAnimation(at: .current) + } + // swiftlint:disable:next unused_capture_list + DispatchQueue.main.async { [pressStartAnimatior, pressEndAnimatior] in } + } + + // MARK: - Hit test + + public override func point( + inside point: CGPoint, + with event: UIEvent? + ) -> Bool { + return CGRect( + x: bounds.origin.x - tapAreaOffset.left, + y: bounds.origin.y - tapAreaOffset.top, + width: bounds.width + tapAreaOffset.left + tapAreaOffset.right, + height: bounds.height + tapAreaOffset.top + tapAreaOffset.bottom + ).contains(point) + } + + public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let view = super.hitTest(point, with: event) + else { return nil } + + if view === self { return control } + return view + } + + // MARK: Initial configuration + + private func configure() { + content.removeFromSuperview() + control.removeFromSuperview() + + setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + addSubview(content) + addSubview(overlay) + addSubview(control) + + content.pinToSuperview() + overlay.pinToSuperview() + control.pinToSuperview() + + contentPressResettable = Resettable(content) + contentDisableResettable = Resettable(content) + + overlayPressResettable = Resettable(overlay) + overlayDisableResettable = Resettable(overlay) + + control.onPressBegin { [weak self] in + self?.animatePressBegin() + } + + control.onPressEnd { [weak self] in + self?.animatePressEnd() + } + } + + public override func layoutSubviews() { + content.frame = bounds + content.layoutIfNeeded() + overlay.frame = bounds + overlay.layoutIfNeeded() + control.frame = bounds + } + + @discardableResult + public func onAction(perform action: (() -> Void)?) -> CustomButton { + control.onAction( + perform: action.map { action in + { _ in action() } + } + ) + return self + } + + @discardableResult + public func appendAction(_ action: @escaping () -> Void) -> CustomButton { + control.onAction( + perform: control.$onAction.map { oldAction in + return { _ in + oldAction(()) + action() + } + } + ) + return self + } + + @discardableResult + public func prependAction(_ action: @escaping () -> Void) -> CustomButton { + control.onAction( + perform: control.$onAction.map { oldAction in + return { _ in + action() + oldAction(()) + } + } + ) + return self + } + + @discardableResult + public func onInternalAction(perform action: (() -> Void)?) -> CustomButton { + control.onInternalAction( + perform: action.map { action in + { _ in action() } + } + ) + return self + } + + @discardableResult + public func modifier(_ modifier: StyleModifier) -> CustomButton { + modifier.config.configured(self) + } + + @discardableResult + public func pressStyle(_ styleManager: StyleManager) -> CustomButton { + builder.pressStyle(styleManager).build() + } + + @discardableResult + public func disabledStyle(_ styleManager: StyleManager) -> CustomButton { + builder.disabledStyle(styleManager).build() + } + + @discardableResult + public func tapAreaOffset(_ size: CGSize) -> CustomButton { + return tapAreaOffset( + .init( + horizontal: size.width, + vertical: size.height + ) + ) + } + + @discardableResult + public func tapAreaOffset(_ offset: UIEdgeInsets) -> CustomButton { + builder.tapAreaOffset(offset).build() + } + + @discardableResult + public func haptic(_ haptic: HapticFeedback) -> CustomButton { + builder.haptic(haptic).build() + } + + @discardableResult + public func pressStartAnimator(_ provider: UIViewPropertyAnimatorProvider?) -> CustomButton { + builder.pressStartAnimationProvider(provider).build() + } + + @discardableResult + public func pressEndAnimator(_ provider: UIViewPropertyAnimatorProvider?) -> CustomButton { + builder.pressEndAnimationProvider(provider).build() + } + + // MARK: Animation + + private func animatePressBegin() { + pressEndAnimatior?.stopAnimation(true) + pressStartAnimatior?.stopAnimation(true) + let animation = capture { _self in + _self.pressStyle.updateStyle( + for: PressConfiguration( + isPressed: true, + content: _self.contentPressResettable, + overlay: _self.overlayPressResettable + ) + ) + } + if let provider = pressStartAnimationProvider { + pressStartAnimatior = provider.makePropertyAnimator(for: animation) + pressStartAnimatior?.startAnimation() + } else { + animation() + } + } + + private func animatePressEnd() { + if pressStartAnimatior?.isRunning == true { + pressStartAnimatior?.addCompletion { position in + self.forceAnimatePressEnd() + } + return + } + forceAnimatePressEnd() + } + + private func forceAnimatePressEnd() { + pressStartAnimatior?.stopAnimation(false) + let animation = capture { _self in + _self.pressStyle.updateStyle( + for: PressConfiguration( + isPressed: false, + content: _self.contentPressResettable, + overlay: _self.overlayPressResettable + ) + ) + } + if let provider = pressEndAnimationProvider { + pressEndAnimatior = provider.makePropertyAnimator(for: animation) + pressEndAnimatior?.startAnimation() + } else { + animation() + } + } + + // MARK: UIControl Handler + + private class Control: UIControl { + @Handler1 + var onPressBegin + + @Handler1 + var onPressEnd + + @Handler1 + var onAction + + @Handler1 + var onInternalAction + + var haptic: HapticFeedback? + + convenience init( + action: @escaping () -> Void, + onPressBegin: @escaping () -> Void, + onPressEnd: @escaping () -> Void + ) { + self.init() + self.onAction(perform: action) + self.onPressBegin(perform: onPressBegin) + self.onPressEnd(perform: onPressEnd) + self.configure() + } + + override init(frame: CGRect) { + super.init(frame: frame) + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configure() + } + + private func configure() { + addTarget(self, action: #selector(pressBegin), for: [.touchDown, .touchDragEnter]) + addTarget( + self, + action: #selector(pressEnd), + for: [.touchUpInside, .touchDragExit, .touchCancel] + ) + addTarget(self, action: #selector(runAction), for: [.touchUpInside]) + } + + @objc private func pressBegin() { + _onPressBegin() + } + + @objc private func pressEnd() { + _onPressEnd() + } + + @objc private func runAction() { + _onInternalAction() + _onAction() + haptic?.trigger() + } + } +} + +// MARK: - CustomButton + +extension CustomButton where Content == UILabel { + public convenience init(_ title: String, action: @escaping () -> Void = {}) { + self.init(action: action) { + UILabel { $0 + .numberOfLines(0) + .text(title) + .textAlignment(.center) + .isUserInteractionEnabled(true) + } + } + } +} + +extension UIEdgeInsets { + public init(all inset: CGFloat) { + self.init( + top: inset, + left: inset, + bottom: inset, + right: inset + ) + } + + public init( + horizontal: CGFloat, + vertical: CGFloat + ) { + self.init( + top: vertical, + left: horizontal, + bottom: vertical, + right: horizontal + ) + } +} + +#endif diff --git a/Example/Sources/AppUI/HapticEngineClient/HapticEngineClient.swift b/Example/Sources/AppUI/HapticEngineClient/HapticEngineClient.swift new file mode 100644 index 0000000..d06a692 --- /dev/null +++ b/Example/Sources/AppUI/HapticEngineClient/HapticEngineClient.swift @@ -0,0 +1,7 @@ +public struct HapticEngineClient { + public init(generator: Operations.CreateFeedback) { + self.generator = generator + } + + public var generator: Operations.CreateFeedback +} diff --git a/Example/Sources/AppUI/HapticEngineClient/HapticEngineClientLive.swift b/Example/Sources/AppUI/HapticEngineClient/HapticEngineClientLive.swift new file mode 100644 index 0000000..0c4631d --- /dev/null +++ b/Example/Sources/AppUI/HapticEngineClient/HapticEngineClientLive.swift @@ -0,0 +1,46 @@ +#if os(iOS) +import UIKit + +extension HapticEngineClient { + public static let live: HapticEngineClient = .init(generator: .live) +} + +extension HapticEngineClient.Operations.CreateFeedback { + public static var live: Self { + return .init { input in + switch input { + case .success : return .success + case .warning : return .warning + case .error : return .error + case .selection : return .selection + + case let .light(intensity): + guard let intensity = intensity + else { return .light } + return .light(intensity: CGFloat(intensity)) + + case let .medium(intensity): + guard let intensity = intensity + else { return .medium } + return .medium(intensity: CGFloat(intensity)) + + case let .heavy(intensity): + guard let intensity = intensity + else { return .heavy } + return .heavy(intensity: CGFloat(intensity)) + + case let .soft(intensity): + guard let intensity = intensity + else { return .soft } + return .soft(intensity: CGFloat(intensity)) + + case let .rigid(intensity): + guard let intensity = intensity + else { return .rigid } + return .rigid(intensity: CGFloat(intensity)) + + } + } + } +} +#endif diff --git a/Example/Sources/AppUI/HapticEngineClient/HapticEngineClientOperations.swift b/Example/Sources/AppUI/HapticEngineClient/HapticEngineClientOperations.swift new file mode 100644 index 0000000..49a962d --- /dev/null +++ b/Example/Sources/AppUI/HapticEngineClient/HapticEngineClientOperations.swift @@ -0,0 +1,34 @@ +extension HapticEngineClient { + public enum Operations {} +} + +extension HapticEngineClient.Operations { + public struct CreateFeedback { + public enum Input: Equatable { + case success + case warning + case error + case selection + + case light(intensity: Double? = nil) + case medium(intensity: Double? = nil) + case heavy(intensity: Double? = nil) + case soft(intensity: Double? = nil) + case rigid(intensity: Double? = nil) + } + + public typealias Output = HapticFeedback + + public typealias Signature = (Input) -> Output + + public init(_ call: @escaping Signature) { + self.call = call + } + + public var call: Signature + + public func callAsFunction(for input: Input) -> Output { + return call(input) + } + } +} diff --git a/Example/Sources/AppUI/HapticEngineClient/HapticFeedback+Factory.swift b/Example/Sources/AppUI/HapticEngineClient/HapticFeedback+Factory.swift new file mode 100644 index 0000000..b26dcc5 --- /dev/null +++ b/Example/Sources/AppUI/HapticEngineClient/HapticFeedback+Factory.swift @@ -0,0 +1,101 @@ +#if os(iOS) +import UIKit + +extension HapticFeedback { + public static func custom( + _ generator: Generator, + _ action: @escaping (Generator) -> Void + ) -> HapticFeedback { + HapticFeedback( + prepare: { generator.prepare() }, + action: { action(generator) } + ) + } + + public static var light: HapticFeedback { + .custom(UIImpactFeedbackGenerator(style: .light)) { + $0.impactOccurred() + } + } + + public static var medium: HapticFeedback { + .custom(UIImpactFeedbackGenerator(style: .medium)) { + $0.impactOccurred() + } + } + + public static var heavy: HapticFeedback { + .custom(UIImpactFeedbackGenerator(style: .heavy)) { + $0.impactOccurred() + } + } + + public static var soft: HapticFeedback { + .custom(UIImpactFeedbackGenerator(style: .soft)) { + $0.impactOccurred() + } + } + + public static var rigid: HapticFeedback { + .custom(UIImpactFeedbackGenerator(style: .rigid)) { + $0.impactOccurred() + } + } + + public static func light(intensity: CGFloat) -> HapticFeedback { + .custom(UIImpactFeedbackGenerator(style: .light)) { + $0.impactOccurred(intensity: intensity) + } + } + + public static func medium(intensity: CGFloat) -> HapticFeedback { + .custom(UIImpactFeedbackGenerator(style: .medium)) { + $0.impactOccurred(intensity: intensity) + } + } + + public static func heavy(intensity: CGFloat) -> HapticFeedback { + .custom(UIImpactFeedbackGenerator(style: .heavy)) { + $0.impactOccurred(intensity: intensity) + } + } + + public static func soft(intensity: CGFloat) -> HapticFeedback { + .custom(UIImpactFeedbackGenerator(style: .soft)) { + $0.impactOccurred(intensity: intensity) + } + } + + public static func rigid(intensity: CGFloat) -> HapticFeedback { + .custom(UIImpactFeedbackGenerator(style: .rigid)) { + $0.impactOccurred(intensity: intensity) + } + } + + public static var success: HapticFeedback { + .custom(UINotificationFeedbackGenerator()) { + $0.notificationOccurred(.success) + } + } + + public static var warning: HapticFeedback { + .custom(UINotificationFeedbackGenerator()) { + $0.notificationOccurred(.success) + } + } + + public static var error: HapticFeedback { + .custom(UINotificationFeedbackGenerator()) { + $0.notificationOccurred(.success) + } + } + + public static var selection: HapticFeedback { + .custom(UISelectionFeedbackGenerator()) { + $0.selectionChanged() + } + } +} +#endif + + diff --git a/Example/Sources/AppUI/HapticEngineClient/HapticFeedback.swift b/Example/Sources/AppUI/HapticEngineClient/HapticFeedback.swift new file mode 100644 index 0000000..5090615 --- /dev/null +++ b/Example/Sources/AppUI/HapticEngineClient/HapticFeedback.swift @@ -0,0 +1,14 @@ +public struct HapticFeedback { + public init( + prepare: @escaping () -> Void, + action: @escaping () -> Void + ) { + self._prepare = prepare + self._action = action + } + + private let _prepare: () -> Void + private let _action: () -> Void + public func prepare() { _prepare() } + public func trigger() { _action() } +} diff --git a/Example/Sources/FeedTabFeature/FeedTabController.swift b/Example/Sources/FeedTabFeature/FeedTabController.swift index 09ce30a..ba82046 100644 --- a/Example/Sources/FeedTabFeature/FeedTabController.swift +++ b/Example/Sources/FeedTabFeature/FeedTabController.swift @@ -4,11 +4,25 @@ import CombineExtensions import CombineNavigation import UserProfileFeature import TweetsFeedFeature +import AppUI +import CocoaAliases +import TweetPostFeature +import TweetReplyFeature @RoutingController public final class FeedTabController: ComposableViewControllerOf { let contentController: TweetsFeedController = .init() + var presentationCancellables: [AnyHashable: Cancellable] = [:] + var contentView: ContentView! { view as? ContentView } + + public override func loadView() { + self.view = ContentView() + } + + @ComposableViewPresentationDestination + var postTweetController + @ComposableStackDestination var feedControllers @@ -20,9 +34,8 @@ public final class FeedTabController: ComposableViewControllerOf // For direct children this method is used instead of addChild self.addRoutedChild(contentController) - self.view.addSubview(contentController.view) - contentController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - contentController.view.frame = view.bounds + self.contentView?.contentView.addSubview(contentController.view) + contentController.view.pinToSuperview() contentController.didMove(toParent: self) } @@ -32,6 +45,11 @@ public final class FeedTabController: ComposableViewControllerOf action: \.feed )) + _postTweetController.setStore(store?.scope( + state: \.postTweet, + action: \.postTweet.presented + )) + _feedControllers.setStore { id in store?.scope( state: \.path[id: id]?.feed, @@ -51,6 +69,18 @@ public final class FeedTabController: ComposableViewControllerOf _ publisher: StorePublisher, into cancellables: inout Set ) { + contentView?.tweetButton.onAction(perform: capture { _self in + _self.store?.send(.tweet) + }) + + #warning("Should introduce an API to wrap controller in Navigation") + presentationDestination( + isPresented: \.$postTweet.wrappedValue.isNotNil, + destination: $postTweetController, + dismissAction: .postTweet(.dismiss) + ) + .store(in: &cancellables) + navigationStack( state: \.path, action: \.path, @@ -66,3 +96,36 @@ public final class FeedTabController: ComposableViewControllerOf .store(in: &cancellables) } } + +extension FeedTabController { + final class ContentView: CustomCocoaView { + let contentView: CocoaView = .init { $0 + .translatesAutoresizingMaskIntoConstraints(false) + } + + let tweetButton = CustomButton { $0 + .translatesAutoresizingMaskIntoConstraints(false) + .content.scope { $0 + .image(.init(systemName: "plus")) + .contentMode(.center) + .backgroundColor(.systemBlue) + .tintColor(.white) + } + }.modifier(.rounded(radius: 24)) + + override func _init() { + super._init() + + addSubview(contentView) + contentView.pinToSuperview() + + addSubview(tweetButton) + NSLayoutConstraint.activate([ + tweetButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -24), + tweetButton.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -24), + tweetButton.widthAnchor.constraint(equalToConstant: 48), + tweetButton.heightAnchor.constraint(equalToConstant: 48) + ]) + } + } +} diff --git a/Example/Sources/FeedTabFeature/FeedTabFeature.swift b/Example/Sources/FeedTabFeature/FeedTabFeature.swift index 598ee85..962d1ec 100644 --- a/Example/Sources/FeedTabFeature/FeedTabFeature.swift +++ b/Example/Sources/FeedTabFeature/FeedTabFeature.swift @@ -2,65 +2,32 @@ import _ComposableArchitecture import UserProfileFeature import TweetsFeedFeature import LocalExtensions +import ProfileAndFeedPivot +import TweetPostFeature +import TweetReplyFeature @Reducer public struct FeedTabFeature { public init() {} - @Reducer - public struct Path { - public enum State: Equatable { - case feed(TweetsFeedFeature.State) - case profile(UserProfileFeature.State) - } - - @CasePathable - public enum Action: Equatable { - case feed(TweetsFeedFeature.Action) - case profile(UserProfileFeature.Action) - case delegate(Delegate) - - @CasePathable - public enum Delegate: Equatable { - case openProfile(USID) - } - } - - public var body: some ReducerOf { - Reduce { state, action in - switch action { - case - let .feed(.delegate(.openProfile(id))), - let .profile(.delegate(.openProfile(id))): - return .send(.delegate(.openProfile(id))) - - default: - return .none - } - } - Scope( - state: /State.feed, - action: /Action.feed, - child: TweetsFeedFeature.init - ) - Scope( - state: /State.profile, - action: /Action.profile, - child: UserProfileFeature.init - ) - } - } + public typealias Path = ProfileAndFeedPivot @ObservableState public struct State: Equatable { public var feed: TweetsFeedFeature.State + + @Presents + public var postTweet: TweetPostFeature.State? + public var path: StackState public init( feed: TweetsFeedFeature.State = .init(), + postTweet: TweetPostFeature.State? = nil, path: StackState = .init() ) { self.feed = feed + self.postTweet = postTweet self.path = path } } @@ -68,25 +35,37 @@ public struct FeedTabFeature { @CasePathable public enum Action: Equatable { case feed(TweetsFeedFeature.Action) + case postTweet(PresentationAction) case path(StackAction) + case tweet } public var body: some ReducerOf { CombineReducers { + Pullback(\.tweet) { state in + state.postTweet = .init() + return .none + } + + Pullback(\.postTweet.event.didPostTweet.success) { state, _ in + return .concatenate( + .send(.postTweet(.dismiss)), + .send(.feed(.fetchMoreTweets(reset: true))) + ) + } + Scope( state: \.feed, action: \.feed, child: TweetsFeedFeature.init ) + Reduce { state, action in switch action { - case + case let .feed(.delegate(.openProfile(id))), - let .path(.element(_, action: .delegate(.openProfile(id)))): - state.path.append(.profile(.external(.init(model: .init( - id: id, - username: "\(id)" - ))))) + let .path(.element(_, .delegate(.openProfile(id)))): + state.path.append(.profile(.loading(id))) return .none default: @@ -98,6 +77,11 @@ public struct FeedTabFeature { action: \.path, destination: Path.init ) + .ifLet( + \.$postTweet, + action: \.postTweet, + destination: TweetPostFeature.init + ) } } } diff --git a/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift b/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift index 38057bb..bee6c41 100644 --- a/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift +++ b/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift @@ -1,6 +1,7 @@ import _ComposableArchitecture import TweetsFeedFeature import UserProfileFeature +import LocalExtensions @Reducer public struct ProfileAndFeedPivot { @@ -14,11 +15,28 @@ public struct ProfileAndFeedPivot { public enum Action: Equatable { case feed(TweetsFeedFeature.Action) case profile(UserProfileFeature.Action) + case delegate(Delegate) + + @CasePathable + public enum Delegate: Equatable { + case openProfile(USID) + } } public init() {} public var body: some ReducerOf { + Reduce { state, action in + switch action { + case + let .feed(.delegate(.openProfile(id))), + let .profile(.delegate(.openProfile(id))): + return .send(.delegate(.openProfile(id))) + + default: + return .none + } + } Scope( state: /State.feed, action: /Action.feed, diff --git a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift index c7020e3..97cec8b 100644 --- a/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift +++ b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift @@ -104,20 +104,12 @@ public struct ProfileTabFeature { } Reduce { state, action in switch action { - case let .path(.element(_, action: .feed(.delegate(.openProfile(id))))): - state.path.append(.profile(.external(.init(model: .init( - id: id, - username: "\(id)" - ))))) + case + let .root(.profile(.delegate(.openProfile(id)))), + let .path(.element(_, .delegate(.openProfile(id)))): + state.path.append(.profile(.loading(id))) return .none - // case let .path(.element(stackID, .profile(.user(.tweetsList(.tweets(.element(_, .tap))))))): - // guard case let .profile(.external(profile)) = state.path[id: stackID] - // else { return .none } - // - // state.path.append(.feed(.init(list: profile.tweetsList))) - // return .none - default: return .none } diff --git a/Example/Sources/TweetDetailFeature/TweetDetailController.swift b/Example/Sources/TweetDetailFeature/TweetDetailController.swift index d9fe14e..987cabb 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailController.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailController.swift @@ -14,7 +14,7 @@ public final class TweetDetailController: ComposableViewControllerOf + @ComposableViewPresentationDestination var tweetReplyController public override func viewDidLoad() { @@ -34,15 +34,14 @@ public final class TweetDetailController: ComposableViewControllerOf ) { + presentationDestination( + isPresented: \.$tweetReply.wrappedValue.isNotNil, + destination: $tweetReplyController, + dismissAction: .tweetReply(.dismiss) + ) + .store(in: &cancellables) + navigationDestination( - state: \State.$destination, - switch: { destinations, route in - switch route { - case .tweetReply: - destinations.$tweetReplyController - case .detail: - destinations.$detailController - } - }, - popAction: .destination(.dismiss) + isPresented: \.$detail.wrappedValue.isNotNil, + destination: _detailController, + popAction: .detail(.dismiss) ) .store(in: &cancellables) } diff --git a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift index 8c19a8a..472c279 100644 --- a/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift +++ b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift @@ -20,16 +20,10 @@ public struct TweetDetailFeature { @CasePathable public enum Action: Equatable { - case detail(TweetDetailFeature.Action) case tweetReply(TweetReplyFeature.Action) } public var body: some ReducerOf { - Scope( - state: \.detail, - action: \.detail, - child: TweetDetailFeature.init - ) Scope( state: \.tweetReply, action: \.tweetReply, @@ -44,7 +38,10 @@ public struct TweetDetailFeature { public var replies: TweetsListFeature.State @Presents - public var destination: Destination.State? + public var detail: TweetDetailFeature.State? + + @Presents + public var tweetReply: TweetReplyFeature.State? @Presents public var alert: AlertState? @@ -52,12 +49,14 @@ public struct TweetDetailFeature { public init( source: TweetFeature.State, replies: TweetsListFeature.State = .init(), - destination: Destination.State? = nil, + detail: TweetDetailFeature.State? = nil, + tweetReply: TweetReplyFeature.State? = nil, alert: AlertState? = nil ) { self.source = source self.replies = replies - self.destination = destination + self.detail = detail + self.tweetReply = tweetReply self.alert = alert } } @@ -66,8 +65,9 @@ public struct TweetDetailFeature { public enum Action: Equatable { case source(TweetFeature.Action) case replies(TweetsListFeature.Action) - case destination(PresentationAction) - case fetchMoreReplies + case detail(PresentationAction) + case tweetReply(PresentationAction) + case fetchMoreReplies(reset: Bool = true) case alert(Alert) case delegate(Delegate) @@ -80,14 +80,27 @@ public struct TweetDetailFeature { @CasePathable public enum Alert: Equatable { - case close + case didTapAccept case didTapRecoveryOption(String) } @CasePathable public enum Event: Equatable { case didAppear - case didFetchReplies(Result<[TweetModel], APIClient.Error>) + case didFetchReplies(Result) + + public struct FetchedTweets: Equatable { + public var tweets: [TweetModel] + public var reset: Bool + + public init( + tweets: [TweetModel], + reset: Bool + ) { + self.tweets = tweets + self.reset = reset + } + } } } @@ -99,11 +112,13 @@ public struct TweetDetailFeature { Reduce { state, action in switch action { case .source(.tapOnAuthor): - return .send(.delegate(.openProfile(state.source.id))) + return .send(.delegate(.openProfile( + state.source.author.id + ))) case let .replies(.delegate(.openProfile(id))), - let .destination(.presented(.detail(.delegate(.openProfile(id))))): + let .detail(.presented(.delegate(.openProfile(id)))): return .send(.delegate(.openProfile(id))) default: @@ -111,54 +126,50 @@ public struct TweetDetailFeature { } } - Reduce { state, action in - switch action { - case .source(.reply): - state.destination = .tweetReply(.init(source: state.source, replyText: "")) - return .none - - case let .replies(.tweets(.element(id, .reply))): - guard let tweet = state.replies.tweets[id: id] else { return .none } - state.destination = .tweetReply(.init(source: tweet, replyText: "")) - return .none - - default: - return .none - } - } - Pullback(\.replies.delegate.openDetail) { state, id in guard let tweet = state.replies.tweets[id: id] else { return .none } - state.destination = .detail(.init(source: tweet)) + state.detail = .init(source: tweet) return .none } Pullback(\.event.didAppear) { state in - return .send(.fetchMoreReplies) + return .send(.fetchMoreReplies()) } Reduce { state, action in switch action { - case .fetchMoreReplies: + case let .fetchMoreReplies(reset): let id = state.source.id - let repliesCount = state.replies.tweets.count + let repliesCount = reset ? 0 : state.replies.tweets.count return .run { send in await send(.event(.didFetchReplies( apiClient.tweet.fetchReplies( for: id, page: repliesCount / 10, limit: 10 - ) + ).map { tweets in + Action.Event.FetchedTweets( + tweets: tweets, + reset: reset + ) + } ))) } case let .event(.didFetchReplies(replies)): switch replies { case let .success(replies): - let tweets = replies.map { $0.convert(to: .tweetFeature) } - state.replies.tweets.append(contentsOf: tweets) + let tweets = replies.tweets.map { $0.convert(to: .tweetFeature) } + if replies.reset { + state.replies.tweets = .init(uniqueElements: tweets) + } else { + state.replies.tweets.append(contentsOf: tweets) + } + if state.replies.tweets.count > state.source.repliesCount { + state.source.repliesCount = state.replies.tweets.count + } state.replies.placeholder = .text() return .none @@ -174,7 +185,7 @@ public struct TweetDetailFeature { Reduce { state, action in switch action { - case .alert(.close): + case .alert(.didTapAccept): state.alert = nil return .none @@ -187,10 +198,21 @@ public struct TweetDetailFeature { } } - Pullback(\.destination.presented.tweetReply.tweet) { state in - #warning("Observe posting result instead of action and update child tweets") - return .send(.destination(.dismiss)) - } + injectTweetReply( + tweetState: \.source, + tweetAction: \.source, + state: \.$tweetReply, + action: \.tweetReply, + child: TweetReplyFeature.init + ) + + injectTweetReply( + tweetsState: \.replies.tweets, + tweetsAction: \.replies.tweets, + state: \.$tweetReply, + action: \.tweetReply, + child: TweetReplyFeature.init + ) Scope( state: \State.source, @@ -204,12 +226,24 @@ public struct TweetDetailFeature { child: TweetsListFeature.init ) } + .syncTweetDetailSource( + state: \.$detail, + with: \.source + ) + .syncTweetDetailSource( + state: \.$detail, + with: \.replies + ) + .ifLet( + \State.$detail, + action: \.detail, + destination: TweetDetailFeature.init + ) .ifLet( - \State.$destination, - action: \.destination, - destination: Destination.init + \.$tweetReply, + action: \.tweetReply, + destination: TweetReplyFeature.init ) - .syncTweetDetailSource(\.$destination.detail, with: \.replies) } func makeAlert(for error: APIClient.Error) -> AlertState { @@ -218,8 +252,8 @@ public struct TweetDetailFeature { TextState(error.message) }, actions: { - ButtonState(role: .cancel, action: .send(.close)) { - TextState("") + ButtonState(role: .cancel, action: .send(.didTapAccept)) { + TextState("OK") } } ) @@ -228,7 +262,7 @@ public struct TweetDetailFeature { extension Reducer { public func syncTweetDetailSource( - _ toTweetDetail: @escaping (State) -> PresentationState, + state toTweetDetail: @escaping (State) -> PresentationState, with toTweetsListState: WritableKeyPath ) -> some ReducerOf { onChange(of: { toTweetDetail($0).wrappedValue?.source }) { state, old, new in @@ -237,4 +271,15 @@ extension Reducer { return .none } } + + public func syncTweetDetailSource( + state toTweetDetail: @escaping (State) -> PresentationState, + with toTweetState: WritableKeyPath + ) -> some ReducerOf { + onChange(of: { toTweetDetail($0).wrappedValue?.source }) { state, old, new in + guard let tweet = new else { return .none } + state[keyPath: toTweetState] = tweet + return .none + } + } } diff --git a/Example/Sources/TweetPostFeature/TweetPostFeature.swift b/Example/Sources/TweetPostFeature/TweetPostFeature.swift new file mode 100644 index 0000000..90c2ae2 --- /dev/null +++ b/Example/Sources/TweetPostFeature/TweetPostFeature.swift @@ -0,0 +1,114 @@ +import _ComposableArchitecture +import LocalExtensions +import APIClient + +@Reducer +public struct TweetPostFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var avatarURL: URL? + public var text: String + + @Presents + public var alert: AlertState? + + public init( + avatarURL: URL? = nil, + text: String = "", + alert: AlertState? = nil + ) { + self.avatarURL = avatarURL + self.text = text + self.alert = alert + } + } + + @CasePathable + public enum Action: Equatable, BindableAction { + case binding(BindingAction) + case tweet + case alert(Alert) + case event(Event) + + @CasePathable + public enum Alert: Equatable { + case didTapAccept + case didTapRecoveryOption(String) + } + + @CasePathable + public enum Event: Equatable { + case didPostTweet(Result) + } + } + + @Dependency(\.apiClient) + var apiClient + + @Dependency(\.currentUser) + var currentUser + + public var body: some ReducerOf { + Pullback(\.tweet) { state in + guard state.text.isNotEmpty else { return .none } + let content = state.text + return .run { send in + let result = await apiClient.tweet.post(content) + await send(.event(.didPostTweet(result.map(Unit.init)))) + } + } + + Reduce { state, action in + switch action { + case let .event(.didPostTweet(.failure(error))): + state.alert = makeAlert(for: error) + return .none + case .alert(.didTapAccept): + state.alert = nil + return .none + + case let .alert(.didTapRecoveryOption(deeplink)): + #warning("TODO: Handle deeplink") + return .none + + default: + return .none + } + } + + BindingReducer() + } + + func makeAlert(for error: APIClient.Error) -> AlertState { + .init( + title: { + TextState(error.message) + }, + actions: { + ButtonState(role: .cancel, action: .send(.didTapAccept)) { + TextState("OK") + } + } + ) + } +} + +public func injectTweetPost( + on toPostAction: CaseKeyPath, + state toPresentationState: WritableKeyPath>, + action toPresentationAction: CaseKeyPath>, + child: () -> TweetPostFeature +) -> some Reducer { + CombineReducers { + Pullback(toPostAction) { state in + #warning("Use currentUser avatarURL") + state[keyPath: toPresentationState].wrappedValue = .init() + return .none + } + Pullback(toPresentationAction.appending(path: \.presented.event.didPostTweet.success)) { state, _ in + return .send(toPresentationAction(.dismiss)) + } + } +} diff --git a/Example/Sources/TweetPostFeature/TweetPostView.swift b/Example/Sources/TweetPostFeature/TweetPostView.swift new file mode 100644 index 0000000..96da409 --- /dev/null +++ b/Example/Sources/TweetPostFeature/TweetPostView.swift @@ -0,0 +1,68 @@ +import _ComposableArchitecture +import SwiftUI +import AppUI + +public struct TweetPostView: ComposableView { + private let store: StoreOf + + @Environment(\.colorTheme) + private var color + + @FocusState + private var focused: Bool + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + _body + .padding(.top) + } + + private var _body: some View { + ScrollView(.vertical) { + VStack(spacing: 0) { + makeTweetInputField() + Spacer(minLength: 8) + } + .padding(.horizontal) + } + .toolbar { + Button("Tweet") { + store.send(.tweet) + } + .disabled(store.text.isEmpty) + } + .toolbarRole(.navigationStack) + .onAppear { focused = true } + } + + @ViewBuilder + private func makeTweetInputField() -> some View { + HStack(alignment: .top) { + makeAvatar(nil) + TextEditor(text: Binding( + get: { store.text }, + set: { store.send(.binding(.set(\.text, $0))) }) + ) + .focused($focused) + .textEditorStyle(PlainTextEditorStyle()) + .scrollDisabled(true) + Button(action: { store.send(.tweet) }) { + Image(systemName: "paperplane.fill") + } + .frame(width: 32, height: 32) + } + } + + @ViewBuilder + private func makeAvatar( + _ avatarURL: URL? + ) -> some View { + Circle() + .stroke(color(\.label.secondary).opacity(0.3)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } +} diff --git a/Example/Sources/TweetReplyFeature/TweetReplyFeature.swift b/Example/Sources/TweetReplyFeature/TweetReplyFeature.swift index 82a172a..5c6be42 100644 --- a/Example/Sources/TweetReplyFeature/TweetReplyFeature.swift +++ b/Example/Sources/TweetReplyFeature/TweetReplyFeature.swift @@ -1,6 +1,7 @@ import _ComposableArchitecture import TweetFeature import LocalExtensions +import APIClient @Reducer public struct TweetReplyFeature { @@ -11,15 +12,20 @@ public struct TweetReplyFeature { public var source: TweetFeature.State public var avatarURL: URL? public var replyText: String + + @Presents + public var alert: AlertState? public init( source: TweetFeature.State, avatarURL: URL? = nil, - replyText: String + replyText: String = "", + alert: AlertState? = nil ) { self.source = source self.avatarURL = avatarURL self.replyText = replyText + self.alert = alert } } @@ -27,6 +33,19 @@ public struct TweetReplyFeature { public enum Action: Equatable, BindableAction { case binding(BindingAction) case tweet + case alert(Alert) + case event(Event) + + @CasePathable + public enum Alert: Equatable { + case didTapAccept + case didTapRecoveryOption(String) + } + + @CasePathable + public enum Event: Equatable { + case didPostTweet(Result) + } } @Dependency(\.apiClient) @@ -40,13 +59,102 @@ public struct TweetReplyFeature { guard state.replyText.isNotEmpty else { return .none } let state = state return .run { send in - #warning("Handle error") - _ = await apiClient.tweet.reply( + let result = await apiClient.tweet.reply( to: state.source.id, with: state.replyText ) + + await send(.event(.didPostTweet(result.map(Unit.init)))) + } + } + + Reduce { state, action in + switch action { + case let .event(.didPostTweet(.failure(error))): + state.alert = makeAlert(for: error) + return .none + case .alert(.didTapAccept): + state.alert = nil + return .none + + case let .alert(.didTapRecoveryOption(deeplink)): + #warning("TODO: Handle deeplink") + return .none + + default: + return .none } } + BindingReducer() } + + func makeAlert(for error: APIClient.Error) -> AlertState { + .init( + title: { + TextState(error.message) + }, + actions: { + ButtonState(role: .cancel, action: .send(.didTapAccept)) { + TextState("OK") + } + } + ) + } +} + +public func injectTweetReply( + tweetsState toTweetsState: @escaping (State) -> IdentifiedArrayOf, + tweetsAction toTweetsAction: CaseKeyPath>, + state toPresentationState: WritableKeyPath>, + action toPresentationAction: CaseKeyPath>, + child: () -> TweetReplyFeature +) -> some Reducer { + CombineReducers { + Pullback(toTweetsAction, action: \.reply) { state, id in + guard let tweet = toTweetsState(state)[id: id] + else { return .none } + + #warning("Use currentUser avatarURL") + state[keyPath: toPresentationState].wrappedValue = .init( + source: tweet, + avatarURL: nil, + replyText: "", + alert: nil + ) + + return .none + } + Pullback(toPresentationAction.appending( + path: \.presented.event.didPostTweet.success + )) { state, _ in + return .send(toPresentationAction(.dismiss)) + } + } +} + +public func injectTweetReply( + tweetState toTweetState: @escaping (State) -> TweetFeature.State, + tweetAction toTweetAction: CaseKeyPath, + state toPresentationState: WritableKeyPath>, + action toPresentationAction: CaseKeyPath>, + child: () -> TweetReplyFeature +) -> some Reducer { + CombineReducers { + Pullback(toTweetAction.appending(path: \.reply)) { state in + #warning("Use currentUser avatarURL") + state[keyPath: toPresentationState].wrappedValue = .init( + source: toTweetState(state), + avatarURL: nil, + replyText: "", + alert: nil + ) + return .none + } + Pullback(toPresentationAction.appending( + path: \.presented.event.didPostTweet.success + )) { state, _ in + return .send(toPresentationAction(.dismiss)) + } + } } diff --git a/Example/Sources/TweetReplyFeature/TweetReplyView.swift b/Example/Sources/TweetReplyFeature/TweetReplyView.swift index b4db09a..f0d8be6 100644 --- a/Example/Sources/TweetReplyFeature/TweetReplyView.swift +++ b/Example/Sources/TweetReplyFeature/TweetReplyView.swift @@ -57,6 +57,10 @@ public struct TweetReplyView: ComposableView { .focused($focused) .textEditorStyle(PlainTextEditorStyle()) .scrollDisabled(true) + Button(action: { store.send(.tweet) }) { + Image(systemName: "paperplane.fill") + } + .frame(width: 32, height: 32) } } diff --git a/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift index ba37844..81314ba 100644 --- a/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift @@ -4,11 +4,15 @@ import CombineExtensions import CombineNavigation import TweetsListFeature import TweetDetailFeature +import TweetReplyFeature @RoutingController public final class TweetsFeedController: ComposableViewControllerOf { let host = ComposableHostingController() + @ComposableViewPresentationDestination + var tweetReplyController + @ComposableTreeDestination var detailController: TweetDetailController? @@ -32,6 +36,11 @@ public final class TweetsFeedController: ComposableViewControllerOf ) { + #warning("Add presentation to CombineNavigation") + + presentationDestination( + isPresented: \.$tweetReply.wrappedValue.isNotNil, + destination: $tweetReplyController, + dismissAction: .tweetReply(.dismiss) + ) + .store(in: &cancellables) + navigationDestination( isPresented: \.$detail.wrappedValue.isNotNil, destination: $detailController, diff --git a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift index 7387ba6..c131835 100644 --- a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift @@ -2,6 +2,7 @@ import _ComposableArchitecture import LocalExtensions import APIClient import TweetsListFeature +import TweetReplyFeature import TweetDetailFeature import AppModels @@ -16,16 +17,22 @@ public struct TweetsFeedFeature { @Presents public var detail: TweetDetailFeature.State? + @Presents + public var tweetReply: TweetReplyFeature.State? + @Presents public var alert: AlertState? public init( list: TweetsListFeature.State = .init(), detail: TweetDetailFeature.State? = nil, + tweetReply: TweetReplyFeature.State? = nil, alert: AlertState? = nil ) { self.list = list self.detail = detail + self.tweetReply = tweetReply + self.alert = alert } } @@ -33,21 +40,35 @@ public struct TweetsFeedFeature { public enum Action: Equatable { case list(TweetsListFeature.Action) case detail(PresentationAction) - case fetchMoreTweets + case tweetReply(PresentationAction) + case fetchMoreTweets(reset: Bool = false) case alert(Alert) case event(Event) case delegate(Delegate) @CasePathable public enum Alert: Equatable { - case close + case didTapAccept case didTapRecoveryOption(String) } @CasePathable public enum Event: Equatable { case didAppear - case didFetchTweets(Result<[TweetModel], APIClient.Error>) + case didFetchTweets(Result) + + public struct FetchedTweets: Equatable { + public var tweets: [TweetModel] + public var reset: Bool + + public init( + tweets: [TweetModel], + reset: Bool + ) { + self.tweets = tweets + self.reset = reset + } + } } @CasePathable @@ -69,7 +90,7 @@ public struct TweetsFeedFeature { return .send(.delegate(.openProfile(id))) case let .list(.delegate(.openDetail(id))): - guard let tweet = state.list.tweets[id: id] + guard let tweet = state.list.tweets[id: id] else { return .none } state.detail = .init(source: tweet) @@ -82,31 +103,40 @@ public struct TweetsFeedFeature { Pullback(\.event.didAppear) { state in return .run { send in - try await Task.sleep(nanoseconds: 2_500_000_000) - await send(.fetchMoreTweets) + try await Task.sleep(nanoseconds: 1_000_000_000) + await send(.fetchMoreTweets()) } } Reduce { state, action in switch action { - case .fetchMoreTweets: - let tweetsCount = state.list.tweets.count + case let .fetchMoreTweets(reset): + let tweetsCount = reset ? 0 : state.list.tweets.count return .run { send in await send(.event(.didFetchTweets( apiClient.feed.fetchTweets( page: tweetsCount / 10, limit: 10 - ) + ).map { tweets in + Action.Event.FetchedTweets( + tweets: tweets, + reset: reset + ) + } ))) } case let .event(.didFetchTweets(tweets)): switch tweets { - case let .success(tweets): - let tweets = tweets.map { $0.convert(to: .tweetFeature) } - state.list.tweets.append(contentsOf: tweets) + case let .success(fetched): + let tweets = fetched.tweets.map { $0.convert(to: .tweetFeature) } + if fetched.reset { + state.list.tweets = .init(uniqueElements: tweets) + } else { + state.list.tweets.append(contentsOf: tweets) + } state.list.placeholder = .text() return .none - + case let .failure(error): state.alert = makeAlert(for: error) return .none @@ -119,7 +149,7 @@ public struct TweetsFeedFeature { Reduce { state, action in switch action { - case .alert(.close): + case .alert(.didTapAccept): state.alert = nil return .none @@ -128,21 +158,37 @@ public struct TweetsFeedFeature { return .none default: - return .none + return .none } } + + Scope( + state: \State.list, + action: \.list, + child: TweetsListFeature.init + ) + + injectTweetReply( + tweetsState: \.list.tweets, + tweetsAction: \.list.tweets, + state: \.$tweetReply, + action: \.tweetReply, + child: TweetReplyFeature.init + ) } + .ifLet( + \.$tweetReply, + action: \.tweetReply, + destination: TweetReplyFeature.init + ) .ifLet( \State.$detail, - action: \.detail, - destination: TweetDetailFeature.init + action: \.detail, + destination: TweetDetailFeature.init ) - .syncTweetDetailSource(\.$detail, with: \.list) - - Scope( - state: \State.list, - action: \.list, - child: TweetsListFeature.init + .syncTweetDetailSource( + state: \.$detail, + with: \.list ) } @@ -152,8 +198,8 @@ public struct TweetsFeedFeature { TextState(error.message) }, actions: { - ButtonState(role: .cancel, action: .send(.close)) { - TextState("") + ButtonState(role: .cancel, action: .send(.didTapAccept)) { + TextState("OK") } } ) diff --git a/Example/Sources/TweetsListFeature/TweetsListFeature.swift b/Example/Sources/TweetsListFeature/TweetsListFeature.swift index d08414f..9acb32c 100644 --- a/Example/Sources/TweetsListFeature/TweetsListFeature.swift +++ b/Example/Sources/TweetsListFeature/TweetsListFeature.swift @@ -45,7 +45,9 @@ public struct TweetsListFeature { return .send(.delegate(.openDetail(id))) case let .tweets(.element(id, .tapOnAuthor)): - return .send(.delegate(.openProfile(id))) + guard let authorID = state.tweets[id: id]?.author.id + else { return .none } + return .send(.delegate(.openProfile(authorID))) default: return .none diff --git a/Example/Sources/UserProfileFeature/UserProfileFeature.swift b/Example/Sources/UserProfileFeature/UserProfileFeature.swift index ad4d47e..e5cba27 100644 --- a/Example/Sources/UserProfileFeature/UserProfileFeature.swift +++ b/Example/Sources/UserProfileFeature/UserProfileFeature.swift @@ -2,29 +2,46 @@ import _ComposableArchitecture import ExternalUserProfileFeature import CurrentUserProfileFeature import LocalExtensions +import AppModels +import APIClient @Reducer public struct UserProfileFeature { public init() {} @ObservableState + @CasePathable public enum State: Equatable { case external(ExternalUserProfileFeature.State) case current(CurrentUserProfileFeature.State) + case loading(USID) } @CasePathable public enum Action: Equatable { case external(ExternalUserProfileFeature.Action) case current(CurrentUserProfileFeature.Action) + case event(Event) case delegate(Delegate) + @CasePathable + public enum Event: Equatable { + case didAppear + case didLoadProfile(Result) + } + @CasePathable public enum Delegate: Equatable { case openProfile(USID) } } - + + @Dependency(\.apiClient) + var apiClient + + @Dependency(\.currentUser) + var currentUser + public var body: some ReducerOf { Reduce { state, action in switch action { @@ -37,15 +54,46 @@ public struct UserProfileFeature { return .none } } - .ifCaseLet( - \.external, - action: \.external, - then: ExternalUserProfileFeature.init + + Reduce { state, action in + guard case let .loading(id) = state + else { return .none } + + switch action { + case .event(.didAppear): + return .run { send in + await send(.event(.didLoadProfile( + apiClient.user.fetch(id: id) + ))) + } + + case let .event(.didLoadProfile(result)): + switch result { + case let .success(profile): + state = profile.id == currentUser.id + ? .current(.init(model: profile)) + : .external(.init(model: profile)) + return .none + case .failure: + #warning("Handle error") + return .none + } + + default: + return .none + } + } + + Scope( + state: \.current, + action: \.current, + child: CurrentUserProfileFeature.init ) - .ifCaseLet( - \.current, - action: \.current, - then: CurrentUserProfileFeature.init + + Scope( + state: \.external, + action: \.external, + child: ExternalUserProfileFeature.init ) } } diff --git a/Example/Sources/UserProfileFeature/UserProfileView.swift b/Example/Sources/UserProfileFeature/UserProfileView.swift index 244e0ce..8058ae7 100644 --- a/Example/Sources/UserProfileFeature/UserProfileView.swift +++ b/Example/Sources/UserProfileFeature/UserProfileView.swift @@ -12,6 +12,12 @@ public struct UserProfileView: ComposableView { } public var body: some View { + _body + .onAppear { store.send(.event(.didAppear)) } + } + + @ViewBuilder + private var _body: some View { switch store.state { case .external: store.scope( @@ -25,6 +31,8 @@ public struct UserProfileView: ComposableView { action: \.current ) .map(CurrentUserProfileView.init) + case .loading: + ProgressView() } } } diff --git a/Example/Sources/_Extensions/LocalExtensions/Unit.swift b/Example/Sources/_Extensions/LocalExtensions/Unit.swift new file mode 100644 index 0000000..4478c46 --- /dev/null +++ b/Example/Sources/_Extensions/LocalExtensions/Unit.swift @@ -0,0 +1,33 @@ +public struct Unit: Codable, Equatable, Hashable { + public init() {} +} + +public let unit = Unit() + +extension Unit { + @inlinable + public init(from decoder: Decoder) throws { self.init() } + + @inlinable + public func encode(to encoder: Encoder) throws {} +} + +extension Unit { + @inlinable + public static func == (_: Unit, _: Unit) -> Bool { + return true + } +} + +extension Unit { // Monoid + public static var empty: Unit = unit +} + +extension Unit: Error {} + +extension Unit: ExpressibleByNilLiteral { + @inlinable + public init(nilLiteral: ()) { + self.init() + } +} From b966574a3c8008bb04d540420822a4da367dcec3 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Wed, 17 Jan 2024 01:19:49 +0100 Subject: [PATCH 40/43] feat: PresentationDestination Additional changes: - Improve tests - Update ci.yml - Refactor Destination APIs - Refactor project structure - Update README.md --- .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .github/workflows/Test.yml | 22 -- .github/workflows/ci.yml | 34 +++ .gitignore | 1 + .../combine-cocoa-navigation.xcscheme | 116 ---------- Makefile | 46 +++- README.md | 71 +++++- .../DestinationInitializableController.swift | 16 ++ .../PresentationDestination.swift | 95 +++++--- .../Destinations/StackDestination.swift | 25 +-- .../Destinations/TreeDestination.swift | 18 +- .../Internal/CombineNavigationRouter.swift | 42 +++- .../Internal/Helpers/AnyCancellable+.swift | 2 + .../Helpers/CombineNavigationRouter+.swift | 6 + .../Internal/Helpers/NSObject+.swift | 1 + .../NavigationAnimation.swift | 206 ------------------ ...oaViewController+NavigationAnimation.swift | 52 +++++ .../Combine+NavigationAnimation.swift | 91 ++++++++ .../NavigationAnimation.swift | 5 + .../WithNavigationAnimation.swift | 73 +++++++ ...CocoaViewController+DismissPublisher.swift | 68 +++++- .../RoutingControllerMacroTests.swift | 6 +- .../DismissPublisherTests.swift | 110 ++++++++++ ...aViewController+TestablePresentation.swift | 72 ++++++ ...Controller+TestablePresentationTests.swift | 33 +++ .../RoutingControllerPresentationTests.swift | 32 +-- .../RoutingControllerStackTests.swift | 2 +- .../RoutingControllerTreeTests.swift | 2 +- 29 files changed, 798 insertions(+), 464 deletions(-) create mode 100644 .github/package.xcworkspace/contents.xcworkspacedata create mode 100644 .github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 .github/workflows/Test.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/combine-cocoa-navigation.xcscheme create mode 100644 Sources/CombineNavigation/Internal/Helpers/CombineNavigationRouter+.swift delete mode 100644 Sources/CombineNavigation/NavigationAnimation.swift create mode 100644 Sources/CombineNavigation/NavigationAnimation/CocoaViewController+NavigationAnimation.swift create mode 100644 Sources/CombineNavigation/NavigationAnimation/Combine+NavigationAnimation.swift create mode 100644 Sources/CombineNavigation/NavigationAnimation/NavigationAnimation.swift create mode 100644 Sources/CombineNavigation/NavigationAnimation/WithNavigationAnimation.swift create mode 100644 Tests/CombineNavigationTests/DismissPublisherTests.swift create mode 100644 Tests/CombineNavigationTests/Helpers/CocoaViewController+TestablePresentation.swift create mode 100644 Tests/CombineNavigationTests/Helpers/CocoaViewController+TestablePresentationTests.swift diff --git a/.github/package.xcworkspace/contents.xcworkspacedata b/.github/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..0fd0bc3 --- /dev/null +++ b/.github/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml deleted file mode 100644 index 721de4e..0000000 --- a/.github/workflows/Test.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: test - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test_macos: - if: | - !contains(github.event.head_commit.message, '[ci skip]') && - !contains(github.event.head_commit.message, '[ci skip test]') && - !contains(github.event.head_commit.message, '[ci skip test_macos]') - runs-on: macOS-13 - timeout-minutes: 30 - steps: - - uses: actions/checkout@v3 - - name: Select Xcode 15.0 - run: sudo xcode-select -switch /Applications/Xcode_15.0.app - - name: Run tests - run: make test diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c54d8cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + library-swift-latest: + name: Library + if: | + !contains(github.event.head_commit.message, '[ci skip]') && + !contains(github.event.head_commit.message, '[ci skip test]') && + !contains(github.event.head_commit.message, '[ci skip library-swift-latest]') + runs-on: macos-13 + timeout-minutes: 30 + strategy: + matrix: + config: + - debug + - release + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 15.1 + run: sudo xcode-select -s /Applications/Xcode_15.1.app + - name: Run tests + run: make CONFIG=debug test-library diff --git a/.gitignore b/.gitignore index 59e2947..e356b02 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ xcuserdata/ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata Package.resolved +/.swiftpm diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/combine-cocoa-navigation.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/combine-cocoa-navigation.xcscheme deleted file mode 100644 index 599f25f..0000000 --- a/.swiftpm/xcode/xcshareddata/xcschemes/combine-cocoa-navigation.xcscheme +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Makefile b/Makefile index d01e032..dea42f1 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,42 @@ +CONFIG = debug +PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS 17,iPhone \d\+ Pro [^M]) +PLATFORM_MACOS = macOS +PLATFORM_MAC_CATALYST = macOS,variant=Mac Catalyst +PLATFORM_TVOS = tvOS Simulator,id=$(call udid_for,tvOS 17,TV) +PLATFORM_WATCHOS = watchOS Simulator,id=$(call udid_for,watchOS 10,Watch) + +default: test + test: - xcodebuild \ - -scheme "combine-cocoa-navigation" \ - -destination platform="iOS Simulator,name=iPhone 15 Pro,OS=17.0" \ - test | xcpretty && exit 0 + $(MAKE) CONFIG=debug test-library + $(MAKE) test-docs + +test-library: + for platform in "$(PLATFORM_IOS)" "$(PLATFORM_MACOS)" "$(PLATFORM_MAC_CATALYST)" "$(PLATFORM_TVOS)" "$(PLATFORM_WATCHOS)"; do \ + echo "\nTesting Library on $$platform\n" && \ + (xcodebuild test \ + -skipMacroValidation \ + -configuration $(CONFIG) \ + -workspace .github/package.xcworkspace \ + -scheme CombineNavigationTests \ + -destination platform="$$platform" | xcpretty && exit 0 \ + ) \ + || exit 1; \ + done; + +DOC_WARNINGS = $(shell xcodebuild clean docbuild \ + -scheme CombineNavigation \ + -destination platform="$(PLATFORM_IOS)" \ + -quiet \ + 2>&1 \ + | grep "couldn't be resolved to known documentation" \ + | sed 's|$(PWD)|.|g' \ + | tr '\n' '\1') +test-docs: + @test "$(DOC_WARNINGS)" = "" \ + || (echo "xcodebuild docbuild failed:\n\n$(DOC_WARNINGS)" | tr '\1' '\n' \ + && exit 1) -test-macro: - swift test --filter CombineNavigationMacrosTests +define udid_for +$(shell xcrun simctl list devices available '$(1)' | grep '$(2)' | sort -r | head -1 | awk -F '[()]' '{ print $$(NF-3) }') +endef diff --git a/README.md b/README.md index 46d7d4b..ba8aa16 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,25 @@ >Package compiles for all platforms, but functionality is available if UIKit can be imported and the platform is not watchOS. -> This readme is draft and the branch is still an `beta` version. +> The branch is a `pre-release` version. ## Usage This library was primarely created for [TCA](https://github.com/pointfreeco/swift-composable-architecture) navigation with Cocoa. However it's geneic enough to use with pure combine. But to dive more into general understanding of stack-based and tree based navigation take a look at TCA docs. +- See [`ComposableExtensions`](https://github.com/capturecontext/composable-architecture-extensions) if you use TCA, it provides better APIs for TCA. +- See [`Example`](./Example)_`(wip)`_ for more usage examples (_it uses [`ComposableExtensions`](https://github.com/capturecontext/composable-architecture-extensions), but th API is similar_). +- See [`Docs`](https://swiftpackageindex.com/capturecontext/combine-cocoa-navigation/1.0.0/documentation/combinenavigation) for documentation reference. + ### Setup -It's **extremely important** to call `bootstrap()` function in the beginning of your app's lifecycle to perform required swizzling for enabling `UINavigationController.popPublisher()` +It's **extremely important** to call `bootstrap()` function in the beginning of your app's lifecycle to perform required swizzling for enabling + +- `UINavigationController.popPublisher` +- `UIViewController.dismissPublisher` +- `UIViewController.selfDismissPublisher` + +Maybe someday a bug in the compiler will be fixed and we may introduce automatic bootstrap. ```swift import UIKit @@ -128,12 +138,59 @@ final class MyViewController: UIViewController { } ``` -## Coming soon +### Presentation + +Basically all you need is to call `presentationDestination` method of the viewController, it accepts routing publisher and mapping of the route to the destination controller. Your code may look somewhat like this: + +```swift +enum MyFeatureDestination { + case details +} + +@RoutingController +final class MyViewController: UIViewController { + @PresentationDestination + var detailsController: DetailsViewController? -- Rich example -- Readme update -- Presentation helpers -- There are a few compiler todos to resolve + func bindViewModel() { + presentationDestination( + viewModel.publisher(for: \.state.destination), + switch: { destinations, route in + switch route { + case .details: + destinations.$detailsController + } + }, + onDismiss: capture { _self in + _self.viewModel.send(.dismiss) + } + ).store(in: &cancellables) + } +} +``` + +or + +```swift +enum MyFeatureState { + // ... + var details: DetailsState? +} + +final class MyViewController: UIViewController { + @PresentationDestination + var detailsController: DetailsViewController? + + func bindViewModel() { + presentationDestination( + "my_feature_details" + isPresented: viewModel.publisher(for: \.state.detais.isNotNil), + destination: $detailsController, + onDismiss: capture { $0.viewModel.send(.dismiss) } + ).store(in: &cancellables) + } +} +``` ## Installation diff --git a/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift b/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift index 23b12fa..590d89d 100644 --- a/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift +++ b/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift @@ -5,4 +5,20 @@ import CocoaAliases public protocol DestinationInitializableControllerProtocol: CocoaViewController { static func _init_for_destination() -> CocoaViewController } + +@usableFromInline +func __initializeDestinationController< + Controller: CocoaViewController +>( + ofType type: Controller.Type = Controller.self +) -> Controller { + if + let controllerType = (Controller.self as? DestinationInitializableControllerProtocol.Type), + let controller = controllerType._init_for_destination() as? Controller + { + return controller + } else { + return Controller() + } +} #endif diff --git a/Sources/CombineNavigation/Destinations/PresentationDestination.swift b/Sources/CombineNavigation/Destinations/PresentationDestination.swift index 1e049a1..a9caf8d 100644 --- a/Sources/CombineNavigation/Destinations/PresentationDestination.swift +++ b/Sources/CombineNavigation/Destinations/PresentationDestination.swift @@ -6,18 +6,15 @@ import FoundationExtensions public protocol _PresentationDestinationProtocol: AnyObject { @_spi(Internals) - func _initControllerIfNeeded() -> CocoaViewController + func _initControllerForPresentationIfNeeded() -> CocoaViewController @_spi(Internals) func _invalidate() - - @_spi(Internals) - var _currentController: CocoaViewController? { get } } /// Wrapper for creating and accessing managed navigation destination controller /// -/// > ⚠️ Sublasses or typealiases must contain "TreeDestination" in their name +/// > ⚠️ Sublasses or typealiases must contain "PresentationDestination" in their name /// > to be processed by `@RoutingController` macro @propertyWrapper open class PresentationDestination: @@ -27,8 +24,10 @@ open class PresentationDestination: @_spi(Internals) open var _controller: Controller? - @_spi(Internals) - public var _currentController: CocoaViewController? { _controller } + internal(set) public var container: CocoaViewController? + + @usableFromInline + internal var containerProvider: ((Controller) -> CocoaViewController)? open var wrappedValue: Controller? { _controller } @@ -41,6 +40,13 @@ open class PresentationDestination: @usableFromInline internal var _configuration: ((Controller) -> Void)? + @inlinable + public func setContainerProvider( + _ containerProvider: PresentationDestinationContainerProvider? + ) { + self.containerProvider = containerProvider?._create + } + /// Sets instance-specific override for creating a new controller /// /// This override has the highest priority when creating a new controller @@ -67,60 +73,89 @@ open class PresentationDestination: @_spi(Internals) @inlinable open class func initController() -> Controller { - if - let controllerType = (Controller.self as? DestinationInitializableControllerProtocol.Type), - let controller = controllerType._init_for_destination() as? Controller - { - return controller - } else { - return Controller() - } + return __initializeDestinationController() } @_spi(Internals) @inlinable open func configureController(_ controller: Controller) {} - /// Creates a new instance + @_disfavoredOverload public init() {} - /// Creates a new instance with instance-specific override for creating a new controller + /// Creates a new instance of PresentationDestination + /// + /// `initControllerOverride`* /// - /// This override has the highest priority when creating a new controller, default one is just `Controller()` - /// **which can lead to crashes if controller doesn't have an empty init** /// /// Default implementation is suitable for most controllers, however if you have a controller which /// doesn't have a custom init you'll have to use this method or if you have a base controller that - /// requires custom init it'll be beneficial for you to create a custom subclass of TreeDestination + /// requires custom init it'll be beneficial for you to create a custom subclass of `PresentationDestination` /// and override it's `initController` class method, you can find an example in tests. - @inlinable - public convenience init(_ initControllerOverride: @escaping () -> Controller) { - self.init() - self.overrideInitController(with: initControllerOverride) + /// + /// - Parameters: + /// - container: + /// ContainerProvider that will wrap controller for presentation + /// - initControllerOverride: + /// This override has the highest priority when creating a new controller, default one is just `Controller()` + /// **which can lead to crashes if controller doesn't have an empty init**. + /// *Consider using `DestinationInitializableControllerProtocol` if possible instead of this parameter* + public init( + container: PresentationDestinationContainerProvider? = nil, + _ initControllerOverride: (() -> Controller)? = nil + ) { + self.containerProvider = container?._create + self._initControllerOverride = initControllerOverride } @_spi(Internals) @inlinable - public func _initControllerIfNeeded() -> CocoaViewController { - self.callAsFunction() + public func _initControllerForPresentationIfNeeded() -> CocoaViewController { + return callAsFunction() } @_spi(Internals) - @inlinable open func _invalidate() { self._controller = nil + self.container = nil } - /// Returns wrappedValue if present, intializes and configures a new instance otherwise - public func callAsFunction() -> Controller { + /// Returns `container` if needed, intializes and configures a new instance otherwise + @discardableResult + public func callAsFunction() -> CocoaViewController { let controller = wrappedValue ?? { let controller = _initControllerOverride?() ?? Self.initController() configureController(controller) _configuration?(controller) self._controller = controller + self.container = containerProvider?(controller) return controller }() - return controller + + return container ?? controller + } +} + +public struct PresentationDestinationContainerProvider< + Controller: CocoaViewController +> { + @usableFromInline + internal var _create: (Controller) -> CocoaViewController + + public init(_ create: @escaping (Controller) -> CocoaViewController) { + self._create = create + } + + @inlinable + public func callAsFunction(_ controller: Controller) -> CocoaViewController { + return _create(controller) + } +} + +extension PresentationDestinationContainerProvider { + @inlinable + public static var navigation: Self { + .init(UINavigationController.init(rootViewController:)) } } #endif diff --git a/Sources/CombineNavigation/Destinations/StackDestination.swift b/Sources/CombineNavigation/Destinations/StackDestination.swift index 29fcd5c..85791ad 100644 --- a/Sources/CombineNavigation/Destinations/StackDestination.swift +++ b/Sources/CombineNavigation/Destinations/StackDestination.swift @@ -69,14 +69,7 @@ open class StackDestination< open class func initController( for id: DestinationID ) -> Controller { - if - let controllerType = (Controller.self as? DestinationInitializableControllerProtocol.Type), - let controller = controllerType._init_for_destination() as? Controller - { - return controller - } else { - return Controller() - } + return __initializeDestinationController() } @_spi(Internals) @@ -91,13 +84,16 @@ open class StackDestination< /// Creates a new instance with instance-specific override for creating a new controller /// - /// This override has the highest priority when creating a new controller, default one is just `Controller()` - /// **which can lead to crashes if controller doesn't have an empty init** - /// /// Default implementation is suitable for most controllers, however if you have a controller which /// doesn't have a custom init you'll have to use this method or if you have a base controller that /// requires custom init it'll be beneficial for you to create a custom subclass of StackDestination /// and override it's `initController` class method, you can find an example in tests. + /// + /// - Parameters: + /// - initControllerOverride: + /// This override has the highest priority when creating a new controller, default one is just `Controller()` + /// **which can lead to crashes if controller doesn't have an empty init**. + /// *Consider using `DestinationInitializableControllerProtocol` if possible instead of this parameter* @inlinable public convenience init(_ initControllerOverride: @escaping (DestinationID) -> Controller) { self.init() @@ -132,10 +128,3 @@ open class StackDestination< } } #endif - -/* -- Add erased protocols for Tree/StackDestination with some cleanup function -- Make RoutingController.destinations method return an instance of the protocol -- In CocoaViewController+API.swift add custom navigationStack/Destination methods -- Those methods will inject cleanup function call into onPop handler -*/ diff --git a/Sources/CombineNavigation/Destinations/TreeDestination.swift b/Sources/CombineNavigation/Destinations/TreeDestination.swift index cd8c2e0..02a7d6b 100644 --- a/Sources/CombineNavigation/Destinations/TreeDestination.swift +++ b/Sources/CombineNavigation/Destinations/TreeDestination.swift @@ -61,14 +61,7 @@ open class TreeDestination: @_spi(Internals) @inlinable open class func initController() -> Controller { - if - let controllerType = (Controller.self as? DestinationInitializableControllerProtocol.Type), - let controller = controllerType._init_for_destination() as? Controller - { - return controller - } else { - return Controller() - } + return __initializeDestinationController() } @_spi(Internals) @@ -80,13 +73,16 @@ open class TreeDestination: /// Creates a new instance with instance-specific override for creating a new controller /// - /// This override has the highest priority when creating a new controller, default one is just `Controller()` - /// **which can lead to crashes if controller doesn't have an empty init** - /// /// Default implementation is suitable for most controllers, however if you have a controller which /// doesn't have a custom init you'll have to use this method or if you have a base controller that /// requires custom init it'll be beneficial for you to create a custom subclass of TreeDestination /// and override it's `initController` class method, you can find an example in tests. + /// + /// - Parameters: + /// - initControllerOverride: + /// This override has the highest priority when creating a new controller, default one is just `Controller()` + /// **which can lead to crashes if controller doesn't have an empty init**. + /// *Consider using `DestinationInitializableControllerProtocol` if possible instead of this parameter* @inlinable public convenience init(_ initControllerOverride: @escaping () -> Controller) { self.init() diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift index b6a35c5..cdbb2de 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift @@ -59,10 +59,23 @@ extension CombineNavigationRouter { final class CombineNavigationRouter: Weakifiable { fileprivate weak var parent: CombineNavigationRouter? fileprivate weak var node: CocoaViewController! + fileprivate var directChildren: [Weak] = [] { didSet { directChildren.removeAll(where: \.object.isNil) } } + fileprivate var isDirectChild: Bool { + true == parent?.directChildren + .compactMap(\.object?.objectID) + .contains(objectID) + } + + fileprivate func navigationGroupRoot() -> CombineNavigationRouter { + return isDirectChild + ? parent!.navigationGroupRoot() + : self + } + fileprivate var navigationControllerCancellable: AnyCancellable? fileprivate var windowCancellable: AnyCancellable? @@ -150,30 +163,35 @@ extension CombineNavigationRouter { ) { self.windowCancellable = nil - let oldDestination = self.presentedDestination + let router = self.navigationGroupRoot() + let oldDestination = router.presentedDestination guard oldDestination !== newDestination else { return } - // Dont track dismiss if it was triggered - // manually from this method - #warning("Ensure that unrelated presentations are not affected") - self.destinationDismissCancellable = nil + router.destinationDismissCancellable = nil let __presentNewDestinationIfNeeded: () -> Void = { + oldDestination?._invalidate() + if let destination = newDestination { - let controller = destination._initControllerIfNeeded() - controller.dismissPublisher + let controller = destination._initControllerForPresentationIfNeeded() + + controller.selfDismissPublisher .sink(receiveValue: onDismiss) - .store(in: &self.destinationDismissCancellable) + .store(in: &router.destinationDismissCancellable) + self.node.present(controller) } self.presentedDestination = newDestination - oldDestination?._invalidate() } - if node.presentedViewController != nil { - node.dismiss(completion: __presentNewDestinationIfNeeded) + if router.node.presentedViewController != nil { + // Cancel current dismiss subscription + // to avoid dismiss action called on + // state changes + router.destinationDismissCancellable = nil + router.node.dismiss(completion: __presentNewDestinationIfNeeded) } else { __presentNewDestinationIfNeeded() } @@ -241,4 +259,6 @@ extension CombineNavigationRouter { + routes.compactMap { $0.makeController(routedBy: self) } } } + +// MARK: Helpers #endif diff --git a/Sources/CombineNavigation/Internal/Helpers/AnyCancellable+.swift b/Sources/CombineNavigation/Internal/Helpers/AnyCancellable+.swift index b0d3fa7..0277805 100644 --- a/Sources/CombineNavigation/Internal/Helpers/AnyCancellable+.swift +++ b/Sources/CombineNavigation/Internal/Helpers/AnyCancellable+.swift @@ -1,6 +1,7 @@ import Combine extension AnyCancellable { + @usableFromInline internal func store( for key: Key, in cancellables: inout [Key: AnyCancellable] @@ -8,6 +9,7 @@ extension AnyCancellable { cancellables[key] = self } + @usableFromInline internal func store( in cancellable: inout AnyCancellable? ) { diff --git a/Sources/CombineNavigation/Internal/Helpers/CombineNavigationRouter+.swift b/Sources/CombineNavigation/Internal/Helpers/CombineNavigationRouter+.swift new file mode 100644 index 0000000..d0762e2 --- /dev/null +++ b/Sources/CombineNavigation/Internal/Helpers/CombineNavigationRouter+.swift @@ -0,0 +1,6 @@ +#if canImport(UIKit) && !os(watchOS) +extension CombineNavigationRouter { + @usableFromInline + internal var objectID: ObjectIdentifier { .init(self) } +} +#endif diff --git a/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift b/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift index b8a6a9a..022ff09 100644 --- a/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift +++ b/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift @@ -1,5 +1,6 @@ import Foundation extension NSObject { + @usableFromInline internal var objectID: ObjectIdentifier { .init(self) } } diff --git a/Sources/CombineNavigation/NavigationAnimation.swift b/Sources/CombineNavigation/NavigationAnimation.swift deleted file mode 100644 index 248dfd0..0000000 --- a/Sources/CombineNavigation/NavigationAnimation.swift +++ /dev/null @@ -1,206 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import Combine -import CocoaAliases - -extension CocoaViewController { - public func present( - _ controller: CocoaViewController, - completion: (() -> Void)? = nil - ) { - present( - controller, - animated: NavigationAnimation.$isEnabled.get(), - completion: completion - ) - } - public func dismiss(completion: (() -> Void)? = nil) { - dismiss( - animated: NavigationAnimation.$isEnabled.get(), - completion: completion - ) - } -} - -extension UINavigationController { - @discardableResult - public func popViewController() -> CocoaViewController? { - popViewController(animated: NavigationAnimation.$isEnabled.get()) - } - - @discardableResult - public func popToRootViewController() -> [CocoaViewController]? { - popToRootViewController(animated: NavigationAnimation.$isEnabled.get()) - } - - @discardableResult - public func popToViewController( - _ controller: CocoaViewController - ) -> [CocoaViewController]? { - popToViewController(controller, animated: NavigationAnimation.$isEnabled.get()) - } - - public func setViewControllers( - _ controllers: [CocoaViewController] - ) { - setViewControllers(controllers, animated: NavigationAnimation.$isEnabled.get()) - } - - public func pushViewController(_ controller: CocoaViewController) { - pushViewController(controller, animated: NavigationAnimation.$isEnabled.get()) - } -} - -/// Disables navigation animations for the duration of the synchronous operation. -/// -/// Basically a convenience function for calling ``withNavigationAnimation(_:perform:file:line:)-76iad`` -@discardableResult -public func withoutNavigationAnimation( - perform operation: () throws -> R, - file: String = #fileID, - line: UInt = #line -) rethrows -> R { - try withNavigationAnimation(false, perform: operation) -} - -/// Disables navigation animations for the duration of the asynchronous operation. -/// -/// Basically a convenience function for calling ``withNavigationAnimation(_:perform:file:line:)-76iad`` -@discardableResult -public func withoutNavigationAnimation( - perform operation: () async throws -> R, - file: String = #fileID, - line: UInt = #line -) async rethrows -> R { - try await withNavigationAnimation(false, perform: operation) -} - -/// Binds task-local NavigationAnimation.isEnabled to the specific value for the duration of the synchronous operation. -/// -/// See [TaskLocal.withValue](https://developer.apple.com/documentation/swift/tasklocal/withvalue(_:operation:file:line:)-79atg) -/// for more details -@discardableResult -public func withNavigationAnimation( - _ enabled: Bool = true, - perform operation: () throws -> R, - file: String = #fileID, - line: UInt = #line -) rethrows -> R { - try NavigationAnimation.$isEnabled.withValue( - enabled, - operation: operation, - file: file, - line: line - ) -} - -/// Binds task-local NavigationAnimation.isEnabled to the specific value for the duration of the asynchronous operation. -/// -/// See [TaskLocal.withValue](https://developer.apple.com/documentation/swift/tasklocal/withvalue(_:operation:file:line:)-1xjor) -/// for more details -@discardableResult -public func withNavigationAnimation( - _ enabled: Bool = true, - perform operation: () async throws -> R, - file: String = #fileID, - line: UInt = #line -) async rethrows -> R { - try await NavigationAnimation.$isEnabled.withValue( - enabled, - operation: operation, - file: file, - line: line - ) -} - -internal enum NavigationAnimation { - @TaskLocal static var isEnabled: Bool = true -} - -extension Publisher { - /// Wraps Subscriber.receive calls in ``withNavigationAnimation(_:perform:file:line:)-76iad`` - /// - /// Basically a convenience method for calling ``withNavigationAnimation(_:file:line:)`` - public func withoutNavigationAnimation( - file: String = #fileID, - line: UInt = #line - ) -> some Publisher { - return withNavigationAnimation( - false, - file: file, - line: line - ) - } - - /// Wraps Subscriber.receive calls in ``withNavigationAnimation(_:perform:file:line:)-76iad`` - public func withNavigationAnimation( - _ enabled: Bool = true, - file: String = #fileID, - line: UInt = #line - ) -> some Publisher { - return NavigationAnimationPublisher( - upstream: self, - isNavigationAnimationEnabled: enabled, - file: file, - line: line - ) - } -} - -private struct NavigationAnimationPublisher: Publisher { - typealias Output = Upstream.Output - typealias Failure = Upstream.Failure - - var upstream: Upstream - var isNavigationAnimationEnabled: Bool - var file: String - var line: UInt - - func receive(subscriber: S) - where S.Input == Output, S.Failure == Failure { - let conduit = Subscriber( - downstream: subscriber, - isNavigationAnimationEnabled: isNavigationAnimationEnabled - ) - self.upstream.receive(subscriber: conduit) - } - - private final class Subscriber: Combine.Subscriber { - typealias Input = Downstream.Input - typealias Failure = Downstream.Failure - - let downstream: Downstream - let isNavigationAnimationEnabled: Bool - var file: String - var line: UInt - - init( - downstream: Downstream, - isNavigationAnimationEnabled: Bool, - file: String = #fileID, - line: UInt = #line - ) { - self.downstream = downstream - self.isNavigationAnimationEnabled = isNavigationAnimationEnabled - self.file = file - self.line = line - } - - func receive(subscription: Subscription) { - self.downstream.receive(subscription: subscription) - } - - func receive(_ input: Input) -> Subscribers.Demand { - CombineNavigation.withNavigationAnimation( - isNavigationAnimationEnabled, - perform: { self.downstream.receive(input) }, - file: file, - line: line - ) - } - - func receive(completion: Subscribers.Completion) { - self.downstream.receive(completion: completion) - } - } -} -#endif diff --git a/Sources/CombineNavigation/NavigationAnimation/CocoaViewController+NavigationAnimation.swift b/Sources/CombineNavigation/NavigationAnimation/CocoaViewController+NavigationAnimation.swift new file mode 100644 index 0000000..e2526f5 --- /dev/null +++ b/Sources/CombineNavigation/NavigationAnimation/CocoaViewController+NavigationAnimation.swift @@ -0,0 +1,52 @@ +#if canImport(UIKit) && !os(watchOS) +import CocoaAliases + +extension UINavigationController { + @discardableResult + public func popViewController() -> CocoaViewController? { + popViewController(animated: NavigationAnimation.$isEnabled.get()) + } + + @discardableResult + public func popToRootViewController() -> [CocoaViewController]? { + popToRootViewController(animated: NavigationAnimation.$isEnabled.get()) + } + + @discardableResult + public func popToViewController( + _ controller: CocoaViewController + ) -> [CocoaViewController]? { + popToViewController(controller, animated: NavigationAnimation.$isEnabled.get()) + } + + public func setViewControllers( + _ controllers: [CocoaViewController] + ) { + setViewControllers(controllers, animated: NavigationAnimation.$isEnabled.get()) + } + + public func pushViewController(_ controller: CocoaViewController) { + pushViewController(controller, animated: NavigationAnimation.$isEnabled.get()) + } +} + +extension CocoaViewController { + public func present( + _ controller: CocoaViewController, + completion: (() -> Void)? = nil + ) { + present( + controller, + animated: NavigationAnimation.$isEnabled.get(), + completion: completion + ) + } + + public func dismiss(completion: (() -> Void)? = nil) { + dismiss( + animated: NavigationAnimation.$isEnabled.get(), + completion: completion + ) + } +} +#endif diff --git a/Sources/CombineNavigation/NavigationAnimation/Combine+NavigationAnimation.swift b/Sources/CombineNavigation/NavigationAnimation/Combine+NavigationAnimation.swift new file mode 100644 index 0000000..37180d8 --- /dev/null +++ b/Sources/CombineNavigation/NavigationAnimation/Combine+NavigationAnimation.swift @@ -0,0 +1,91 @@ +#if canImport(UIKit) && !os(watchOS) +import Combine + +extension Publisher { + /// Wraps Subscriber.receive calls in ``withNavigationAnimation(_:perform:file:line:)-76iad`` + /// + /// Basically a convenience method for calling ``withNavigationAnimation(_:file:line:)`` + public func withoutNavigationAnimation( + file: String = #fileID, + line: UInt = #line + ) -> some Publisher { + return withNavigationAnimation( + false, + file: file, + line: line + ) + } + + /// Wraps Subscriber.receive calls in ``withNavigationAnimation(_:perform:file:line:)-76iad`` + public func withNavigationAnimation( + _ enabled: Bool = true, + file: String = #fileID, + line: UInt = #line + ) -> some Publisher { + return NavigationAnimationPublisher( + upstream: self, + isNavigationAnimationEnabled: enabled, + file: file, + line: line + ) + } +} + +private struct NavigationAnimationPublisher: Publisher { + typealias Output = Upstream.Output + typealias Failure = Upstream.Failure + + var upstream: Upstream + var isNavigationAnimationEnabled: Bool + var file: String + var line: UInt + + func receive(subscriber: S) + where S.Input == Output, S.Failure == Failure { + let conduit = Subscriber( + downstream: subscriber, + isNavigationAnimationEnabled: isNavigationAnimationEnabled + ) + self.upstream.receive(subscriber: conduit) + } + + private final class Subscriber: Combine.Subscriber { + typealias Input = Downstream.Input + typealias Failure = Downstream.Failure + + let downstream: Downstream + let isNavigationAnimationEnabled: Bool + var file: String + var line: UInt + + init( + downstream: Downstream, + isNavigationAnimationEnabled: Bool, + file: String = #fileID, + line: UInt = #line + ) { + self.downstream = downstream + self.isNavigationAnimationEnabled = isNavigationAnimationEnabled + self.file = file + self.line = line + } + + func receive(subscription: Subscription) { + self.downstream.receive(subscription: subscription) + } + + func receive(_ input: Input) -> Subscribers.Demand { + CombineNavigation.withNavigationAnimation( + isNavigationAnimationEnabled, + perform: { self.downstream.receive(input) }, + file: file, + line: line + ) + } + + func receive(completion: Subscribers.Completion) { + self.downstream.receive(completion: completion) + } + } +} +#endif diff --git a/Sources/CombineNavigation/NavigationAnimation/NavigationAnimation.swift b/Sources/CombineNavigation/NavigationAnimation/NavigationAnimation.swift new file mode 100644 index 0000000..f39a270 --- /dev/null +++ b/Sources/CombineNavigation/NavigationAnimation/NavigationAnimation.swift @@ -0,0 +1,5 @@ +#if canImport(UIKit) && !os(watchOS) +internal enum NavigationAnimation { + @TaskLocal static var isEnabled: Bool = true +} +#endif diff --git a/Sources/CombineNavigation/NavigationAnimation/WithNavigationAnimation.swift b/Sources/CombineNavigation/NavigationAnimation/WithNavigationAnimation.swift new file mode 100644 index 0000000..9007cf3 --- /dev/null +++ b/Sources/CombineNavigation/NavigationAnimation/WithNavigationAnimation.swift @@ -0,0 +1,73 @@ +#if canImport(UIKit) && !os(watchOS) +/// Disables navigation animations for the duration of the synchronous operation. +/// +/// Basically a convenience function for calling ``withNavigationAnimation(_:perform:file:line:)-76iad`` +@discardableResult +public func withoutNavigationAnimation( + perform operation: () throws -> R, + file: String = #fileID, + line: UInt = #line +) rethrows -> R { + try withNavigationAnimation( + false, + perform: operation, + file: file, + line: line + ) +} + +/// Disables navigation animations for the duration of the asynchronous operation. +/// +/// Basically a convenience function for calling ``withNavigationAnimation(_:perform:file:line:)-76iad`` +@discardableResult +public func withoutNavigationAnimation( + perform operation: () async throws -> R, + file: String = #fileID, + line: UInt = #line +) async rethrows -> R { + try await withNavigationAnimation( + false, + perform: operation, + file: file, + line: line + ) +} + +/// Binds task-local NavigationAnimation.isEnabled to the specific value for the duration of the synchronous operation. +/// +/// See [TaskLocal.withValue](https://developer.apple.com/documentation/swift/tasklocal/withvalue(_:operation:file:line:)-79atg) +/// for more details +@discardableResult +public func withNavigationAnimation( + _ enabled: Bool = true, + perform operation: () throws -> R, + file: String = #fileID, + line: UInt = #line +) rethrows -> R { + try NavigationAnimation.$isEnabled.withValue( + enabled, + operation: operation, + file: file, + line: line + ) +} + +/// Binds task-local NavigationAnimation.isEnabled to the specific value for the duration of the asynchronous operation. +/// +/// See [TaskLocal.withValue](https://developer.apple.com/documentation/swift/tasklocal/withvalue(_:operation:file:line:)-1xjor) +/// for more details +@discardableResult +public func withNavigationAnimation( + _ enabled: Bool = true, + perform operation: () async throws -> R, + file: String = #fileID, + line: UInt = #line +) async rethrows -> R { + try await NavigationAnimation.$isEnabled.withValue( + enabled, + operation: operation, + file: file, + line: line + ) +} +#endif diff --git a/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift b/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift index d5c053d..33f0a86 100644 --- a/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift +++ b/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift @@ -6,7 +6,10 @@ import FoundationExtensions extension CocoaViewController { @AssociatedObject(readonly: true) - fileprivate var dismissSubject: PassthroughSubject = .init() + fileprivate var selfDismissSubject: PassthroughSubject = .init() + + @AssociatedObject(readonly: true) + fileprivate var dismissSubject: PassthroughSubject<[CocoaViewController], Never> = .init() /// Publisher for dismiss /// @@ -14,9 +17,20 @@ extension CocoaViewController { /// /// > It has different behavior from simply observing `dismiss(animated:completion:)` /// > selector, the publisher is always called on the dismissed controller + /// > + /// > If you need to observe `dismiss(animated:completion:)` selector + /// > use `dismissPublisher` /// /// Underlying subject is triggered by swizzled methods in `CombineNavigation` module. - public var dismissPublisher: some Publisher { + public var selfDismissPublisher: some Publisher { + return selfDismissSubject + } + + /// Publisher for dismiss + /// + /// Emits an array of dismissed controllers. + /// If there was no `presentedViewController`s it will emit `[self]` instead + public var dismissPublisher: some Publisher<[CocoaViewController], Never> { return dismissSubject } } @@ -43,10 +57,27 @@ extension CocoaViewController { // animated: Bool, // completion: (() -> Void)? = nil // ) { -// dismiss(animated: animated) { -// self.dismissSubject.send(()) +// let presentationStack = _presentationStack ?? [self] +// +// let _notifySubjects = { +// self.dismissSubject.send(presentationStack) +// +// presentationStack +// .reversed() +// .forEach { $0.selfDismissSubject.send(()) } +// } +// +// let _completion = { +// _notifySubjects() // completion?() // } +// +// #if canImport(XCTest) +// dismiss(animated: animated, completion: nil) +// if !NavigationAnimation.$isEnabled.get() { _completion() } +// #else +// dismiss(animated: animated, completion: _completion) +// #endif // } //} @@ -63,12 +94,31 @@ extension CocoaViewController { animated: Bool, completion: (() -> Void)? ) { - #warning("Handle dismiss for all nested presentations") - let dismissedController: UIViewController = presentedViewController ?? self - __swizzledDismiss(animated: animated, completion: { - dismissedController.dismissSubject.send(()) + let presentationStack = _presentationStack ?? [self] + + let _notifySubjects = { + self.dismissSubject.send(presentationStack) + + presentationStack + .reversed() + .forEach { $0.selfDismissSubject.send(()) } + } + + let _completion = { + _notifySubjects() completion?() - }) + } + + #if canImport(XCTest) + __swizzledDismiss(animated: animated, completion: nil) + if !NavigationAnimation.$isEnabled.get() { _completion() } + #else + __swizzledDismiss(animated: animated, completion: _completion) + #endif + } + + private var _presentationStack: [CocoaViewController]? { + presentedViewController.map { [$0] + ($0._presentationStack ?? []) } } } #endif diff --git a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift index 141f187..4cfedb4 100644 --- a/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift +++ b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift @@ -145,7 +145,7 @@ final class RoutingControllerMacroTests: XCTestCase { """ @RoutingController final class CustomController { - @PresentationDestination + @PresentationDestination(container: .navigation) var firstDetailController: CocoaViewController? @PresentationDestination var secondDetailController: CocoaViewController? @@ -154,7 +154,7 @@ final class RoutingControllerMacroTests: XCTestCase { } expansion: { """ final class CustomController { - @PresentationDestination + @PresentationDestination(container: .navigation) var firstDetailController: CocoaViewController? @PresentationDestination var secondDetailController: CocoaViewController? @@ -168,7 +168,7 @@ final class RoutingControllerMacroTests: XCTestCase { /// Use in `navigationDestination`/`navigationStack` methods to map /// routes to specific destinations using `destinations` method public struct Destinations { - @PresentationDestination + @PresentationDestination(container: .navigation) var firstDetailController: CocoaViewController? @PresentationDestination diff --git a/Tests/CombineNavigationTests/DismissPublisherTests.swift b/Tests/CombineNavigationTests/DismissPublisherTests.swift new file mode 100644 index 0000000..22a1973 --- /dev/null +++ b/Tests/CombineNavigationTests/DismissPublisherTests.swift @@ -0,0 +1,110 @@ +import XCTest +import CocoaAliases +import Capture +import Combine +import FoundationExtensions +@testable import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) + +final class DismissPublisherTests: XCTestCase { + static override func setUp() { + CombineNavigation.bootstrap() + } + + func testMain() { + let expectation1 = self.expectation(description: "1 should track dismissal") + let expectation2 = self.expectation(description: "2 should track dismissal") + let expectation3 = self.expectation(description: "Root should track dimsissal") + expectation1.expectedFulfillmentCount = 2 + expectation2.expectedFulfillmentCount = 2 + + @Box + var dismissCancellables: [ObjectIdentifier: AnyCancellable] = [:] + + let window = UIWindow() + let root = RootController() + window.rootViewController = root + window.makeKeyAndVisible() + + let detail1 = PresentedController1() + let detail2 = PresentedController2() + + withoutNavigationAnimation { + let detail = detail1 + let expectation = expectation1 + + XCTAssertNil(root.presentedViewController) + + dismissCancellables[detail.objectID] = detail + .selfDismissPublisher + .sink { expectation.fulfill() } + + root.present(detail) + XCTAssertEqual(root.presentedViewController, detail) + + root.dismiss() + XCTAssertNil(root.presentedViewController) + } + + withoutNavigationAnimation { + let detail = detail2 + let expectation = expectation2 + + XCTAssertNil(root.presentedViewController) + + dismissCancellables[detail.objectID] = detail + .selfDismissPublisher + .sink { expectation.fulfill() } + + root.present(detail) + XCTAssertEqual(root.presentedViewController, detail) + + root.dismiss() + XCTAssertNil(root.presentedViewController) + } + + withoutNavigationAnimation { // nested dismiss + XCTAssertNil(root.presentedViewController) + + dismissCancellables[root.objectID] = root + .dismissPublisher + .sink { + XCTAssertEqual($0, [detail1, detail2]) + expectation3.fulfill() + } + + dismissCancellables[detail1.objectID] = detail1 + .selfDismissPublisher + .sink { expectation1.fulfill() } + + dismissCancellables[detail2.objectID] = detail2 + .selfDismissPublisher + .sink { expectation2.fulfill() } + + root.present(detail1) + XCTAssertEqual(root.presentedViewController, detail1) + + detail1.present(detail2) + XCTAssertEqual(detail1.presentedViewController, detail2) + + root.dismiss() + XCTAssertNil(root.presentedViewController) + } + + wait( + for: [ + expectation1, + expectation2, + expectation3 + ], + timeout: 1 + ) + } +} + +class RootController: AppleSpaghettiCodeTestableController {} +class PresentedController1: AppleSpaghettiCodeTestableController {} +class PresentedController2: AppleSpaghettiCodeTestableController {} + +#endif diff --git a/Tests/CombineNavigationTests/Helpers/CocoaViewController+TestablePresentation.swift b/Tests/CombineNavigationTests/Helpers/CocoaViewController+TestablePresentation.swift new file mode 100644 index 0000000..a263e6d --- /dev/null +++ b/Tests/CombineNavigationTests/Helpers/CocoaViewController+TestablePresentation.swift @@ -0,0 +1,72 @@ +import CocoaAliases + +#if canImport(UIKit) && !os(watchOS) + +/// Overrides presentation behaviour specifically +/// for being able to test sequential present/dismiss calls +/// +/// Default implementation heavialy relies on lifecycle, requres Window etc. +/// But this one simply assigns presented and presenting controller references +/// +/// > Lifecycle events are not guaranteed to trigger +/// > so this implementation might not be suitable +/// > for testing live applications +/// > but fixes unexpected UIKit behaviors +/// > specifically for the needs of this package +open class AppleSpaghettiCodeTestableController: CocoaViewController { + private weak var _presentingViewController: CocoaViewController? + override open var presentingViewController: CocoaViewController? { + _presentingViewController + } + + private var _presentedViewController: CocoaViewController? + override open var presentedViewController: CocoaViewController? { + _presentedViewController + } + + override open func present( + _ viewControllerToPresent: CocoaViewController, + animated flag: Bool, + completion: (() -> Void)? = nil + ) { + super.present(viewControllerToPresent, animated: flag, completion: completion) + + self._presentedViewController = viewControllerToPresent + (viewControllerToPresent as? AppleSpaghettiCodeTestableController)?._presentingViewController = self + + completion?() + } + + override open func dismiss( + animated flag: Bool, + completion: (() -> Void)? = nil + ) { + if let presentedViewController { + super.dismiss(animated: flag) + self._presentedViewController = nil + (presentedViewController as? AppleSpaghettiCodeTestableController)?._presentingViewController = nil + completion?() + } else if let presentingViewController = presentingViewController as? AppleSpaghettiCodeTestableController { + presentingViewController.dismiss(animated: flag, completion: completion) + } else if let presentingViewController { + presentingViewController.dismiss(animated: flag, completion: { + self._presentingViewController = nil + completion?() + }) + } else { + self._presentingViewController = nil + completion?() + } + } +} + +extension CocoaViewController { + var _topPresentedController: CocoaViewController? { + if let presentedViewController { + return presentedViewController._topPresentedController + } else { + return self + } + } +} +#endif diff --git a/Tests/CombineNavigationTests/Helpers/CocoaViewController+TestablePresentationTests.swift b/Tests/CombineNavigationTests/Helpers/CocoaViewController+TestablePresentationTests.swift new file mode 100644 index 0000000..f5e40d1 --- /dev/null +++ b/Tests/CombineNavigationTests/Helpers/CocoaViewController+TestablePresentationTests.swift @@ -0,0 +1,33 @@ +import XCTest +import CocoaAliases +import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) +final class AppleSpaghettiCodeTestableControllerTests: XCTestCase { + func testAppleSpaghettiCode() { + let window = UIWindow() + let root = AppleSpaghettiCodeTestableController() + window.rootViewController = root + window.makeKeyAndVisible() + + let detail1 = AppleSpaghettiCodeTestableController() + let detail2 = AppleSpaghettiCodeTestableController() + + withoutNavigationAnimation { + XCTAssertNil(root.presentedViewController) + root.present(detail1) + + XCTAssertEqual(root.presentedViewController, detail1) + root.dismiss() + + XCTAssertNil(root.presentedViewController) + root.present(detail2) + + XCTAssertEqual(root.presentedViewController, detail2) + root.dismiss() + + XCTAssertNil(root.presentedViewController) + } + } +} +#endif diff --git a/Tests/CombineNavigationTests/RoutingControllerPresentationTests.swift b/Tests/CombineNavigationTests/RoutingControllerPresentationTests.swift index d59340c..8888dac 100644 --- a/Tests/CombineNavigationTests/RoutingControllerPresentationTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerPresentationTests.swift @@ -6,7 +6,7 @@ import Combine #if canImport(UIKit) && !os(watchOS) -// TODO: Test deinitialization +// TODO: Test destinations deinitialization // Note: Manual check succeed ✅ final class RoutingControllePresentationTests: XCTestCase { @@ -25,35 +25,30 @@ final class RoutingControllePresentationTests: XCTestCase { // Disable navigation animation for tests withoutNavigationAnimation { - XCTAssert(controller._topPresentedController === controller) + XCTAssertEqual(controller._topPresentedController, controller) viewModel.state.value.destination = .feedback() - XCTAssert(controller._topPresentedController === controller.feedbackController) + XCTAssertEqual(controller._topPresentedController, controller.feedbackController) viewModel.state.value.destination = .orderDetail() - XCTAssert(controller._topPresentedController === controller.orderDetailController) + XCTAssertEqual(controller._topPresentedController, controller.orderDetailController) controller.dismiss() XCTAssertEqual(viewModel.state.value.destination, .none) viewModel.state.value.destination = .feedback() - XCTAssert(controller._topPresentedController === controller.feedbackController) + XCTAssertEqual(controller._topPresentedController, controller.feedbackController) viewModel.state.value.destination = .none - XCTAssert(controller._topPresentedController === controller) + XCTAssertEqual(controller._topPresentedController, controller) } } } fileprivate let testDestinationID = UUID() -fileprivate class OrderDetailsController: CocoaViewController {} -fileprivate class FeedbackController: CocoaViewController { - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - print("Disappear") - } -} +fileprivate class OrderDetailsController: AppleSpaghettiCodeTestableController {} +fileprivate class FeedbackController: AppleSpaghettiCodeTestableController {} fileprivate class PresentationViewModel { struct State { @@ -80,7 +75,7 @@ fileprivate class PresentationViewModel { } @RoutingController -fileprivate class PresentationViewController: CocoaViewController { +fileprivate class PresentationViewController: AppleSpaghettiCodeTestableController { private var cancellables: Set = [] var viewModel: PresentationViewModel! { @@ -111,14 +106,5 @@ fileprivate class PresentationViewController: CocoaViewController { .store(in: &cancellables) } } -extension CocoaViewController { - var _topPresentedController: CocoaViewController? { - if let presentedViewController { - return presentedViewController._topPresentedController - } else { - return self - } - } -} #endif diff --git a/Tests/CombineNavigationTests/RoutingControllerStackTests.swift b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift index 9edfc85..563b3f4 100644 --- a/Tests/CombineNavigationTests/RoutingControllerStackTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift @@ -6,7 +6,7 @@ import Combine #if canImport(UIKit) && !os(watchOS) -// TODO: Test deinitialization +// TODO: Test destinations deinitialization // Note: Manual check succeed ✅ final class RoutingControllerStackTests: XCTestCase { diff --git a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift index 42ad12a..9c6aac3 100644 --- a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift +++ b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift @@ -6,7 +6,7 @@ import Combine #if canImport(UIKit) && !os(watchOS) -// TODO: Test deinitialization +// TODO: Test destinations deinitialization // Note: Manual check succeed ✅ final class RoutingControllerTreeTests: XCTestCase { From 7130c2be4fc84bc5bb99806650c1e7dd0cd97021 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Wed, 17 Jan 2024 02:04:13 +0100 Subject: [PATCH 41/43] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ba8aa16..ef14a07 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,9 @@ enum MyFeatureState { } final class MyViewController: UIViewController { - @PresentationDestination + // You can also wrap presented controller in a container controller + // by passing `PresentationDestinationContainerProvider` + @PresentationDestination(container: .navigation) var detailsController: DetailsViewController? func bindViewModel() { From a4c3b87c78d051560a22c9f553cdb413f4cba5fa Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Wed, 17 Jan 2024 02:04:47 +0100 Subject: [PATCH 42/43] cleanup: Refactor duplicate routes removal --- .../Internal/CombineNavigationRouter+API.swift | 6 +----- Sources/CombineNavigation/Internal/Helpers/EnumTag.swift | 6 +++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift index 9bdfa34..6d1a2e1 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift @@ -195,11 +195,7 @@ extension CombineNavigationRouter { P.Failure == Never { publisher - .removeDuplicates(by: { - let wrapped: Bool = enumTag($0) == enumTag($1) - let unwrapped: Bool = $0.flatMap(enumTag) == $1.flatMap(enumTag) - return wrapped && unwrapped - }) + .removeDuplicates(by: Optional.compareTagsEqual) .map { [weak self] (route) -> NavigationRoute? in guard let self, let route else { return nil } let destination = destination(route) diff --git a/Sources/CombineNavigation/Internal/Helpers/EnumTag.swift b/Sources/CombineNavigation/Internal/Helpers/EnumTag.swift index c578594..c4cb9e1 100644 --- a/Sources/CombineNavigation/Internal/Helpers/EnumTag.swift +++ b/Sources/CombineNavigation/Internal/Helpers/EnumTag.swift @@ -13,8 +13,8 @@ extension Optional { lhs: Self, rhs: Self ) -> Bool { - let wrapped: Bool = enumTag(lhs) == enumTag(rhs) - let unwrapped: Bool = lhs.flatMap(enumTag) == rhs.flatMap(enumTag) - return wrapped && unwrapped + let wrappedCompare: Bool = enumTag(lhs) == enumTag(rhs) + let unwrappedCompare: Bool = lhs.flatMap(enumTag) == rhs.flatMap(enumTag) + return wrappedCompare && unwrappedCompare } } From 9f697d8602744d7860443928a2bcb09ed1a602e5 Mon Sep 17 00:00:00 2001 From: maximkrouk Date: Wed, 17 Jan 2024 02:05:46 +0100 Subject: [PATCH 43/43] fix: Presentation Always present controllers on navigationGroupRoot --- .../CombineNavigation/Internal/CombineNavigationRouter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift index cdbb2de..cd8f7ab 100644 --- a/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift @@ -180,7 +180,7 @@ extension CombineNavigationRouter { .sink(receiveValue: onDismiss) .store(in: &router.destinationDismissCancellable) - self.node.present(controller) + router.node.present(controller) } self.presentedDestination = newDestination