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/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/.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/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigation.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigation.xcscheme new file mode 100644 index 0000000..eada169 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/CombineNavigation.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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/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/AppUI.xcscheme b/Example/.swiftpm/xcode/xcshareddata/xcschemes/AppUI.xcscheme new file mode 100644 index 0000000..4443fe8 --- /dev/null +++ b/Example/.swiftpm/xcode/xcshareddata/xcschemes/AppUI.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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/.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/.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/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/.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/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/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/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/.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/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj new file mode 100644 index 0000000..edda605 --- /dev/null +++ b/Example/Example.xcodeproj/project.pbxproj @@ -0,0 +1,376 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + E11363F22B32984100915F38 /* AppFeature in Frameworks */ = {isa = PBXBuildFile; productRef = E11363F12B32984100915F38 /* AppFeature */; }; + 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; }; + 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 = ( + E11363F22B32984100915F38 /* AppFeature 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 = ( + E1A59CE22AFFF5D600E08FF8 /* Assets.xcassets */, + E1A59CE42AFFF5D600E08FF8 /* LaunchScreen.storyboard */, + E1A59CE72AFFF5D600E08FF8 /* Info.plist */, + E1A4489E2B367B89008C6875 /* main.swift */, + ); + 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 = ( + E11363F12B32984100915F38 /* AppFeature */, + ); + 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; + LastSwiftMigration = 1500; + }; + }; + }; + 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 = ( + E1A4489F2B367B89008C6875 /* main.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; + 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; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + 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_OBJC_BRIDGING_HEADER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_PRECOMPILE_BRIDGING_HEADER = 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; + 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; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen.storyboard; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + 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_OBJC_BRIDGING_HEADER = ""; + SWIFT_PRECOMPILE_BRIDGING_HEADER = 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 */ + E11363F12B32984100915F38 /* AppFeature */ = { + isa = XCSwiftPackageProductDependency; + productName = AppFeature; + }; +/* 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.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/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..5a14045 --- /dev/null +++ b/Example/Example/Info.plist @@ -0,0 +1,43 @@ + + + + + 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 + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + AppFeature.SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + + diff --git a/Example/Example/main.swift b/Example/Example/main.swift new file mode 100644 index 0000000..673d052 --- /dev/null +++ b/Example/Example/main.swift @@ -0,0 +1,8 @@ +import UIKit + +_ = UIApplicationMain( + CommandLine.argc, + CommandLine.unsafeArgv, + nil, + "AppFeature.AppDelegate" +) diff --git a/Example/Package.swift b/Example/Package.swift new file mode 100644 index 0000000..97e607d --- /dev/null +++ b/Example/Package.swift @@ -0,0 +1,553 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "CombineNavigationExample", + platforms: [ + .iOS(.v17) + ], + dependencies: [ + .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") + ), + .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 + // 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: "LocalExtensions", + product: .library(.static), + dependencies: [ + .product( + name: "CombineExtensions", + package: "combine-extensions" + ), + .product( + name: "FoundationExtensions", + package: "swift-foundation-extensions" + ), + .product( + name: "IdentifiedCollections", + package: "swift-identified-collections" + ) + ], + 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 + // - Must not import targets from `Modules` section + + .target( + name: "_ComposableArchitecture", + product: .library(.static), + dependencies: [ + .localExtensions, + .product( + name: "ComposableExtensions", + package: "composable-architecture-extensions" + ) + ], + 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 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, + ] + ), + + .target( + name: "AppModels", + product: .library(.static), + dependencies: [ + .dependency("_Dependencies"), + .localExtensions + ] + ), + + .target( + name: "AppUI", + product: .library(.static), + dependencies: [ + .localExtensions, + ] + ), + + .target( + name: "AuthFeature", + product: .library(.static), + dependencies: [ + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "CurrentUserProfileFeature", + product: .library(.static), + dependencies: [ + .target("AppModels"), + .target("TweetsListFeature"), + .target("UserSettingsFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "DatabaseSchema", + product: .library(.static), + dependencies: [ + .dependency("_Dependencies"), + .localExtensions + ] + ), + + .target( + name: "ExternalUserProfileFeature", + product: .library(.static), + dependencies: [ + .target("AppModels"), + .target("TweetsListFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "FeedTabFeature", + product: .library(.static), + dependencies: [ + .target("AppUI"), + .target("UserProfileFeature"), + .target("TweetsFeedFeature"), + .target("TweetPostFeature"), + .target("ProfileAndFeedPivot"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "MainFeature", + product: .library(.static), + dependencies: [ + .target("FeedTabFeature"), + .target("ProfileTabFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "OnboardingFeature", + product: .library(.static), + dependencies: [ + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "ProfileAndFeedPivot", + product: .library(.static), + dependencies: [ + .target("TweetsFeedFeature"), + .target("UserProfileFeature"), + .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"), + .target("ProfileAndFeedPivot"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "TweetDetailFeature", + product: .library(.static), + dependencies: [ + .target("APIClient"), + .target("TweetFeature"), + .target("TweetsListFeature"), + .target("TweetReplyFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "TweetFeature", + product: .library(.static), + dependencies: [ + .target("AppUI"), + .target("AppModels"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .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), + 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("CurrentUserProfileFeature"), + .target("ExternalUserProfileFeature"), + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + + .target( + name: "UserSettingsFeature", + product: .library(.static), + dependencies: [ + .dependency("_ComposableArchitecture"), + .localExtensions, + ] + ), + ] +) + +// 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/Project.fig b/Example/Project.fig new file mode 100644 index 0000000..c4300b2 Binary files /dev/null and b/Example/Project.fig differ 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/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/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 new file mode 100644 index 0000000..16f77c7 --- /dev/null +++ b/Example/Sources/APIClient/APIClient+Live.swift @@ -0,0 +1,435 @@ +import Dependencies +import SwiftData +import LocalExtensions +import DatabaseSchema +import AppModels + +extension APIClient: DependencyKey { + public static var liveValue: APIClient { + + return .init( + auth: .backendLike(), + feed: .backendLike(), + tweet: .backendLike(), + user: .backendLike() + ) + } +} + +extension ModelContext { + fileprivate var currentUser: DatabaseSchema.UserModel? { + @Dependency(\.currentUser) + var userIDContainer + + guard let currentUserID = userIDContainer.id?.rawValue + else { return nil } + + 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 + ), + 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() -> Self { + .init( + signIn: .init { input in + return await Result { + @Dependency(\.database) + var database + + @Dependency(\.currentUser) + var currentUser + + try await database.withContext { context in + let pwHash = try Data.sha256(input.password).unwrap().get() + + 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.id = user.id.usid() + } + }.mapError(APIClient.Error.init) + }, + signUp: .init { input in + return await Result { + @Dependency(\.database) + var database + + @Dependency(\.currentUser) + var currentUser + + 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 user = DatabaseSchema.UserModel( + id: USID(), + username: input.username, + password: pwHash + ) + + context.insert(user) + try context.save() + currentUser.id = user.id.usid() + } + }.mapError(APIClient.Error.init) + }, + logout: .init { _ in + @Dependency(\.currentUser) + var currentUser + + currentUser.id = nil + } + ) + } +} + +extension APIClient.Feed { + static func backendLike() -> APIClient.Feed { + return .init( + fetchTweets: .init { input in + return await Result { + @Dependency(\.database) + var database + + 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) + } + ) + } +} + +extension APIClient.Tweet { + static func backendLike() -> Self { + .init( + fetch: .init { input in + return await Result { + @Dependency(\.database) + var database + + 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 + return await Result { + @Dependency(\.database) + var database + + try await database.withContext { context in + guard let user = context.currentUser + else { throw .unauthenticatedRequest("like a tweet") } + + let shouldLike = input.value + let tweetID = input.id.rawValue + let isLiked = user.likedTweets.contains(where: { $0.id == tweetID }) + + guard shouldLike != isLiked else { return } + + 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 == tweetID } + } + + try context.save() + } + }.mapError(APIClient.Error.init) + }, + post: .init { input in + return await Result { + @Dependency(\.database) + var database + + try await database.withContext { context in + guard let user = context.currentUser + else { throw .unauthenticatedRequest("post a tweet") } + + DatabaseSchema.TweetModel( + id: USID(), + createdAt: .now, + content: input + ) + .insert(to: context) + .update(\.author, with: { $0 = user }) + + try context.save() + } + }.mapError(APIClient.Error.init) + }, + repost: .init { input in + return await Result { + @Dependency(\.database) + var database + + 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 context.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw .tweetNotFound } + + DatabaseSchema.TweetModel( + id: USID(), + createdAt: .now, + content: input.content + ) + .insert(to: context) + .update(\.author, with: { $0 = user }) + .update(\.repostSource, with: { $0 = originalTweet }) + + try context.save() + } + }.mapError(APIClient.Error.init) + }, + reply: .init { input in + return await Result { + @Dependency(\.database) + var database + + 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 context.fetch( + DatabaseSchema.TweetModel.self, + #Predicate { $0.id == tweetID } + ).first + else { throw .tweetNotFound } + + DatabaseSchema.TweetModel( + id: USID(), + createdAt: .now, + content: input.content + ) + .insert(to: context) + .update(\.author, with: { $0 = user }) + .update(\.replySource, with: { $0 = originalTweet }) + + try context.save() + } + }.mapError(APIClient.Error.init) + }, + delete: .init { input in + return await Result { + @Dependency(\.database) + var database + + try await database.withContext { context in + guard let user = context.currentUser + else { throw .unauthenticatedRequest("delete tweets") } + + 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.id == tweetToDelete.id } + + try context.save() + } + }.mapError(APIClient.Error.init) + }, + report: .init { input in + // Pretend we did collect the report + return .success(()) + }, + fetchReplies: .init { input in + return await Result { + @Dependency(\.database) + var database + + 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 { $0.toAPIModel() } + } + }.mapError(APIClient.Error.init) + } + ) + } +} + +extension APIClient.User { + static func backendLike() -> Self { + .init( + fetch: .init { input in + return await Result { + @Dependency(\.database) + var database + + 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 + return await Result { + @Dependency(\.database) + var database + + try await database.withContext { context in + guard let user = context.currentUser + else { throw .unauthenticatedRequest("follow or unfollow profiles") } + + let userID = input.id.rawValue + let shouldFollow = input.value + let isFollowing = user.follows.contains(where: { $0.id == userID }) + + guard shouldFollow != isFollowing else { return } + + 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 == userID } + } + + try context.save() + } + + }.mapError(APIClient.Error.init) + }, + report: .init { input in + // Pretend we did collect the report + return .success(()) + }, + fetchTweets: .init { input in + return await Result { + @Dependency(\.database) + var database + + 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/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..8c5d4d1 --- /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..948f7e3 --- /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], APIClient.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/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 new file mode 100644 index 0000000..5fb2229 --- /dev/null +++ b/Example/Sources/APIClient/Tweet/APIClient+Tweet.swift @@ -0,0 +1,219 @@ +import LocalExtensions +import AppModels + +extension APIClient { + public struct Tweet { + public init( + fetch: Operations.Fetch, + like: Operations.Like, + post: Operations.Post, + repost: Operations.Repost, + reply: Operations.Reply, + delete: Operations.Delete, + report: Operations.Report, + fetchReplies: Operations.FetchReplies + ) { + self.fetch = fetch + self.like = like + self.post = post + self.repost = repost + self.reply = reply + self.delete = delete + self.report = report + self.fetchReplies = fetchReplies + } + + public var fetch: Operations.Fetch + 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 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, + 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], APIClient.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..dbdaca8 --- /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], APIClient.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/AppDelegate.swift b/Example/Sources/AppFeature/Bootstrap/AppDelegate.swift new file mode 100644 index 0000000..6ec980f --- /dev/null +++ b/Example/Sources/AppFeature/Bootstrap/AppDelegate.swift @@ -0,0 +1,37 @@ +// +// AppDelegate.swift +// Example +// +// Created by Maxim Krouk on 11.11.2023. +// + +import UIKit +import CombineNavigation + +public class AppDelegate: UIResponder, UIApplicationDelegate { + public func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + CombineNavigation.bootstrap() + return true + } + + // MARK: UISceneSession Lifecycle + + public func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + return UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) + } + + public func application( + _ application: UIApplication, + didDiscardSceneSessions sceneSessions: Set + ) {} +} diff --git a/Example/Sources/AppFeature/Bootstrap/PrefillDatabase.swift b/Example/Sources/AppFeature/Bootstrap/PrefillDatabase.swift new file mode 100644 index 0000000..480e64b --- /dev/null +++ b/Example/Sources/AppFeature/Bootstrap/PrefillDatabase.swift @@ -0,0 +1,113 @@ +import _Dependencies +import DatabaseSchema +import LocalExtensions + +let defaultUsername = "capturecontext" +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]) }) + + 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/AppFeature/Bootstrap/SceneDelegate.swift b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift new file mode 100644 index 0000000..61f1806 --- /dev/null +++ b/Example/Sources/AppFeature/Bootstrap/SceneDelegate.swift @@ -0,0 +1,52 @@ +// +// SceneDelegate.swift +// Example +// +// Created by Maxim Krouk on 11.11.2023. +// + +import _ComposableArchitecture +import AppUI +import AppModels +import MainFeature +import DatabaseSchema +import LocalExtensions + +public class SceneDelegate: UIResponder, UIWindowSceneDelegate { + public var window: UIWindow? + + public func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + guard let scene = scene as? UIWindowScene else { return } + + let controller = MainViewController() + + let window = UIWindow(windowScene: scene) + self.window = window + + window.rootViewController = controller + + window.makeKeyAndVisible() + + Task { @MainActor in + try await prefillDatabaseIfNeeded(autoSignIn: true) + + controller.setStore(Store( + initialState: .init(), + reducer: { + MainFeature()._printChanges() + } + )) + } + } + + 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/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..7000fbc --- /dev/null +++ b/Example/Sources/AppModels/Exports.swift @@ -0,0 +1,32 @@ +import _Dependencies +import LocalExtensions +import Combine + +extension DependencyValues { + public var currentUser: CurrentUserIDContainer { + get { self[CurrentUserIDContainer.self] } + set { self[CurrentUserIDContainer.self] = newValue } + } +} + +public struct CurrentUserIDContainer { + 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._idSubject = .init(id) + } +} + +extension CurrentUserIDContainer: DependencyKey { + public static var liveValue: CurrentUserIDContainer { .init() } + public static var previewValue: CurrentUserIDContainer { .init() } +} diff --git a/Example/Sources/AppModels/TweetModel.swift b/Example/Sources/AppModels/TweetModel.swift new file mode 100644 index 0000000..bd37255 --- /dev/null +++ b/Example/Sources/AppModels/TweetModel.swift @@ -0,0 +1,57 @@ +import LocalExtensions + +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 + public var likesCount: Int + public var isReposted: Bool + public var repostsCount: Int + public var text: String + + public init( + id: USID, + author: AuthorModel, + createdAt: Date = .now, + replyTo: USID? = nil, + repliesCount: Int = 0, + isLiked: Bool = false, + likesCount: Int = 0, + isReposted: Bool = false, + repostsCount: Int = 0, + text: String + ) { + self.id = id + self.author = author + self.createdAt = createdAt + self.replyTo = replyTo + self.repliesCount = repliesCount + self.isLiked = isLiked + self.likesCount = likesCount + self.isReposted = isReposted + self.repostsCount = repostsCount + self.text = text + } +} diff --git a/Example/Sources/AppModels/UserInfoModel.swift b/Example/Sources/AppModels/UserInfoModel.swift new file mode 100644 index 0000000..dc55801 --- /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/_Mock/MockThreads.swift b/Example/Sources/AppModels/_Mock/MockThreads.swift new file mode 100644 index 0000000..53fc49d --- /dev/null +++ b/Example/Sources/AppModels/_Mock/MockThreads.swift @@ -0,0 +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( +// 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/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/Colors/ColorTheme.swift b/Example/Sources/AppUI/Colors/ColorTheme.swift new file mode 100644 index 0000000..cb3502a --- /dev/null +++ b/Example/Sources/AppUI/Colors/ColorTheme.swift @@ -0,0 +1,144 @@ +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 { + #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 + } + + 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/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/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/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/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+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..4b5b13b --- /dev/null +++ b/Example/Sources/AuthFeature/AuthFeature+SignUp.swift @@ -0,0 +1,22 @@ +import _ComposableArchitecture + +extension AuthFeature { + @Reducer + public struct SignUp { + @ObservableState + public struct State: Equatable { + public init() {} + } + + @CasePathable + 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..d0bdfaa --- /dev/null +++ b/Example/Sources/AuthFeature/AuthFeature.swift @@ -0,0 +1,33 @@ +import _ComposableArchitecture + +@Reducer +public struct AuthFeature { + @ObservableState + public enum State: Equatable { + case signIn(SignIn.State = .init()) + case signUp(SignUp.State = .init()) + } + + @CasePathable + 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/CurrentUserProfileFeature/CurrentUserProfileFeature.swift b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift new file mode 100644 index 0000000..2af23c4 --- /dev/null +++ b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileFeature.swift @@ -0,0 +1,100 @@ +import _ComposableArchitecture +import LocalExtensions +import AppModels +import TweetsListFeature +import UserSettingsFeature + +@Reducer +public struct CurrentUserProfileFeature { + public init() {} + + @Reducer + public struct Destination { + @ObservableState + public enum State: Equatable { + case avatarPreivew(URL) + case userSettings(UserSettingsFeature.State) + } + + @CasePathable + 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 + ) + } + } + + @ObservableState + public struct State: Equatable { + public var model: UserInfoModel + public var tweetsList: TweetsListFeature.State + + @Presents + public var destination: Destination.State? + + public init( + model: UserInfoModel, + tweetsList: TweetsListFeature.State = .init(), + destination: Destination.State? = nil + ) { + self.model = model + self.tweetsList = tweetsList + self.destination = destination + } + } + + @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 { + 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 + ) + + Pullback(\.tweetsList.delegate.openProfile) { state, id in + return .send(.delegate(.openProfile(id))) + } + + Scope( + state: \State.tweetsList, + action: \.tweetsList, + child: TweetsListFeature.init + ) + } +} diff --git a/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileView.swift b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileView.swift new file mode 100644 index 0000000..49cb75c --- /dev/null +++ b/Example/Sources/CurrentUserProfileFeature/CurrentUserProfileView.swift @@ -0,0 +1,97 @@ +import _ComposableArchitecture +import SwiftUI +import AppModels +import TweetsListFeature +import UserSettingsFeature + +public struct CurrentUserProfileView: 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) + tweetsView + } + } + + @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.username.lowercased()) + .monospaced() + .bold() + } + } + + @ViewBuilder + var tweetsView: some View { + LazyVStack(spacing: 32) { + ForEachStore( + store.scope( + state: \.tweetsList.tweets, + action: \.tweetsList.tweets + ), + content: { store in + Text(store.text) + .padding(.horizontal) + .contentShape(Rectangle()) + .onTapGesture { + store.send(.tap) + } + } + ) + } + } +} + +#Preview { + NavigationStack { + CurrentUserProfileView(Store( + initialState: .init( + 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: [ + .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..006d600 --- /dev/null +++ b/Example/Sources/DatabaseSchema/Database.swift @@ -0,0 +1,43 @@ +import _Dependencies + +public actor Database: Sendable { + private let container: ModelContainer + + 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)) + } + } +} + +// 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(container: DatabaseSchema.createModelContainer(.file())) + } + + public static var previewValue: Database { + try! .init(container: DatabaseSchema.createModelContainer(.inMemory)) + } +} + +extension DependencyValues { + public var database: Database { + get { self[Database.self] } + set { self[Database.self] = newValue } + } +} 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..c78a6cf --- /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 createModelContainer( + _ persistance: ModelPersistance + ) throws -> ModelContainer { + + 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 container + } +} diff --git a/Example/Sources/DatabaseSchema/DatabaseSchema.swift b/Example/Sources/DatabaseSchema/DatabaseSchema.swift new file mode 100644 index 0000000..402afff --- /dev/null +++ b/Example/Sources/DatabaseSchema/DatabaseSchema.swift @@ -0,0 +1,5 @@ +import _Dependencies + +public enum DatabaseSchema { + public typealias Current = V1 +} diff --git a/Example/Sources/DatabaseSchema/Exports.swift b/Example/Sources/DatabaseSchema/Exports.swift new file mode 100644 index 0000000..d573933 --- /dev/null +++ b/Example/Sources/DatabaseSchema/Exports.swift @@ -0,0 +1,42 @@ +@_exported import SwiftData +import Foundation +import _Dependencies + +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 + + @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 { + 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..92f2f28 --- /dev/null +++ b/Example/Sources/DatabaseSchema/Versions/V1/V1+TweetModel.swift @@ -0,0 +1,43 @@ +import SwiftData +import LocalExtensions + +extension DatabaseSchema.V1 { + @Model + public final class TweetModel: Equatable, Identifiable, @unchecked Sendable { + @Attribute(.unique) + public let id: String + public var createdAt: Date + + 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, + content: String + ) { + self.id = id.rawValue + self.createdAt = createdAt + 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 new file mode 100644 index 0000000..5f8263f --- /dev/null +++ b/Example/Sources/DatabaseSchema/Versions/V1/V1+UserModel.swift @@ -0,0 +1,52 @@ +import SwiftData +import LocalExtensions + +extension DatabaseSchema.V1 { + @Model + public final class UserModel: Equatable, Identifiable, @unchecked Sendable { + @Attribute(.unique) + public let 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, inverse: \TweetModel.author) + 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 + ) { + self.id = id.rawValue + self.username = username + self.password = password + self.displayName = displayName + self.bio = bio + self.avatarURL = avatarURL + self.tweets = [] + self.likedTweets = [] + self.follows = [] + self.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..1035c30 --- /dev/null +++ b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileFeature.swift @@ -0,0 +1,83 @@ +import _ComposableArchitecture +import LocalExtensions +import AppModels +import TweetsListFeature + +@Reducer +public struct ExternalUserProfileFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var model: UserInfoModel + public var tweetsList: TweetsListFeature.State + + @Presents + public var avatarPreview: URL? + + public init( + model: UserInfoModel, + tweetsList: TweetsListFeature.State = .init() + ) { + self.model = model + self.tweetsList = tweetsList + } + } + + @CasePathable + public enum Action: Equatable { + case avatarPreview(PresentationAction) + case tweetsList(TweetsListFeature.Action) + case tapOnAvatar + case tapFollow + case delegate(Delegate) + + @CasePathable + public enum Delegate: Equatable { + case openDetail(USID) + case openProfile(USID) + } + } + + public var body: some ReducerOf { + CombineReducers { + Reduce { state, action in + switch action { + case .tapOnAvatar: + state.avatarPreview = state.model.avatarURL + return .none + + case .tapFollow: + state.model.isFollowedByYou.toggle() + return .none + + default: + return .none + } + } + Reduce { state, action in + switch action { + case let .tweetsList(.delegate(.openDetail(id))): + return .send(.delegate(.openDetail(id))) + + case let .tweetsList(.delegate(.openProfile(id))): + guard id != state.model.id else { return .none } + return .send(.delegate(.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..cd426a9 --- /dev/null +++ b/Example/Sources/ExternalUserProfileFeature/ExternalUserProfileView.swift @@ -0,0 +1,82 @@ +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.username.lowercased()) + .monospaced() + .bold() + Button(action: { store.send(.tapFollow) }) { + Text(store.model.isFollowedByYou ? "Unfollow" : "Follow") + } + } + } +} + +#Preview { + NavigationStack { + ExternalUserProfileView(Store( + initialState: .init( + 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: [ + .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/FeedTabController.swift b/Example/Sources/FeedTabFeature/FeedTabController.swift new file mode 100644 index 0000000..ba82046 --- /dev/null +++ b/Example/Sources/FeedTabFeature/FeedTabController.swift @@ -0,0 +1,131 @@ +import _ComposableArchitecture +import CocoaExtensions +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 + + @ComposableViewStackDestination + var profileControllers + + public override func viewDidLoad() { + super.viewDidLoad() + + // For direct children this method is used instead of addChild + self.addRoutedChild(contentController) + self.contentView?.contentView.addSubview(contentController.view) + contentController.view.pinToSuperview() + contentController.didMove(toParent: self) + } + + public override func scope(_ store: Store?) { + contentController.setStore(store?.scope( + state: \.feed, + action: \.feed + )) + + _postTweetController.setStore(store?.scope( + state: \.postTweet, + action: \.postTweet.presented + )) + + _feedControllers.setStore { id in + store?.scope( + state: \.path[id: id]?.feed, + action: \.path[id: id].feed + ) + } + + _profileControllers.setStore { id in + store?.scope( + state: \.path[id: id]?.profile, + action: \.path[id: id].profile + ) + } + } + + public override func bind( + _ 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, + switch: { destinations, route in + switch route { + case .feed: + destinations.$feedControllers + case .profile: + destinations.$profileControllers + } + } + ) + .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 new file mode 100644 index 0000000..962d1ec --- /dev/null +++ b/Example/Sources/FeedTabFeature/FeedTabFeature.swift @@ -0,0 +1,87 @@ +import _ComposableArchitecture +import UserProfileFeature +import TweetsFeedFeature +import LocalExtensions +import ProfileAndFeedPivot +import TweetPostFeature +import TweetReplyFeature + +@Reducer +public struct FeedTabFeature { + public 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 + } + } + + @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 + let .feed(.delegate(.openProfile(id))), + let .path(.element(_, .delegate(.openProfile(id)))): + state.path.append(.profile(.loading(id))) + return .none + + default: + return .none + } + } + .forEach( + \State.path, + action: \.path, + destination: Path.init + ) + .ifLet( + \.$postTweet, + action: \.postTweet, + destination: TweetPostFeature.init + ) + } + } +} diff --git a/Example/Sources/MainFeature/MainFeature.swift b/Example/Sources/MainFeature/MainFeature.swift new file mode 100644 index 0000000..4e34500 --- /dev/null +++ b/Example/Sources/MainFeature/MainFeature.swift @@ -0,0 +1,58 @@ +import _ComposableArchitecture +import FeedTabFeature +import ProfileTabFeature + +@Reducer +public struct MainFeature { + @ObservableState + public struct State: Equatable { + public init( + feed: FeedTabFeature.State = .init(), + profile: ProfileTabFeature.State = .init(), + 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 + } + } + + @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() {} + + 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..236e328 --- /dev/null +++ b/Example/Sources/MainFeature/MainViewController.swift @@ -0,0 +1,116 @@ +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: ProfileTabController = .init() + + 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( + [ + feedNavigation, + profileNavigation + ], + animated: false + ) + } + + 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( + _ 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/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..bee6c41 --- /dev/null +++ b/Example/Sources/ProfileAndFeedPivot/ProfileAndFeedPivot.swift @@ -0,0 +1,51 @@ +import _ComposableArchitecture +import TweetsFeedFeature +import UserProfileFeature +import LocalExtensions + +@Reducer +public struct ProfileAndFeedPivot { + @ObservableState + public enum State: Equatable { + case feed(TweetsFeedFeature.State = .init()) + 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 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, + child: TweetsFeedFeature.init + ) + Scope( + state: /State.profile, + action: /Action.profile, + child: UserProfileFeature.init + ) + } +} diff --git a/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift b/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift new file mode 100644 index 0000000..58d9a4d --- /dev/null +++ b/Example/Sources/ProfileFeedFeature/ProfileFeedFeature.swift @@ -0,0 +1,43 @@ +import _ComposableArchitecture +import LocalExtensions +import AppModels +import TweetFeature + +@Reducer +public struct ProfileFeedFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var tweets: IdentifiedArrayOf + + public init( + tweets: IdentifiedArrayOf = [] + ) { + self.tweets = tweets + } + } + + @CasePathable + public enum Action: Equatable { + case tweets(IdentifiedActionOf) + case openProfile(USID) + } + + public var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .tweets(.element(id, action: .tapOnAuthor)): + return .send(.openProfile(id)) + + default: + return .none + } + } + .forEach( + \State.tweets, + action: \.tweets, + element: TweetFeature.init + ) + } +} diff --git a/Example/Sources/ProfileFeedFeature/ProfileFeedView.swift b/Example/Sources/ProfileFeedFeature/ProfileFeedView.swift new file mode 100644 index 0000000..41b84a3 --- /dev/null +++ b/Example/Sources/ProfileFeedFeature/ProfileFeedView.swift @@ -0,0 +1,26 @@ +import _ComposableArchitecture +import SwiftUI +import AppModels +import TweetFeature + +public struct ProfileFeedView: ComposableView { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + ScrollView(.vertical) { + LazyVStack(spacing: 24) { + ForEachStore( + store.scope( + state: \.tweets, + action: \.tweets + ), + content: TweetView.init + ) + } + } + } +} 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 new file mode 100644 index 0000000..97cec8b --- /dev/null +++ b/Example/Sources/ProfileTabFeature/ProfileTabFeature.swift @@ -0,0 +1,128 @@ +import _ComposableArchitecture +import AuthFeature +import ProfileAndFeedPivot +import UserProfileFeature +import TweetsFeedFeature +import CurrentUserProfileFeature +import LocalExtensions + +@Reducer +public struct ProfileTabFeature { + public init() {} + + public typealias Path = ProfileAndFeedPivot + + @Reducer + public struct Root { + @ObservableState + public enum State: Equatable { + case auth(AuthFeature.State = .signIn()) + case profile(CurrentUserProfileFeature.State) + } + + @CasePathable + 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, + child: AuthFeature.init + ) + Scope( + state: /State.profile, + action: /Action.profile, + child: CurrentUserProfileFeature.init + ) + } + } + + @ObservableState + public struct State: Equatable { + public var root: Root.State + public var path: StackState + + public init( + root: Root.State = .auth(), + path: StackState = .init() + ) { + self.root = root + self.path = path + } + } + + @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 .root(.profile(.delegate(.openProfile(id)))), + let .path(.element(_, .delegate(.openProfile(id)))): + state.path.append(.profile(.loading(id))) + return .none + + default: + return .none + } + } + .forEach( + \State.path, + action: \.path, + destination: Path.init + ) + Scope( + state: \.root, + action: \.root, + child: Root.init + ) + } +} diff --git a/Example/Sources/TweetDetailFeature/TweetDetailController.swift b/Example/Sources/TweetDetailFeature/TweetDetailController.swift new file mode 100644 index 0000000..987cabb --- /dev/null +++ b/Example/Sources/TweetDetailFeature/TweetDetailController.swift @@ -0,0 +1,66 @@ +import _ComposableArchitecture +import UIKit +import SwiftUI +import Combine +import CombineExtensions +import Capture +import CombineNavigation +import TweetReplyFeature + +@RoutingController +public final class TweetDetailController: ComposableViewControllerOf { + let host = ComposableHostingController() + + @ComposableTreeDestination + var detailController: TweetDetailController? + + @ComposableViewPresentationDestination + var tweetReplyController + + 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 viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.store?.send(.event(.didAppear)) + } + + public override func scope(_ store: Store?) { + host.setStore(store) + + _tweetReplyController.setStore(store?.scope( + state: \.tweetReply, + action: \..tweetReply.presented + )) + + _detailController.setStore(store?.scope( + state: \.detail, + action: \.detail.presented + )) + } + + public override func bind( + _ publisher: StorePublisher, + into cancellables: inout Set + ) { + presentationDestination( + isPresented: \.$tweetReply.wrappedValue.isNotNil, + destination: $tweetReplyController, + dismissAction: .tweetReply(.dismiss) + ) + .store(in: &cancellables) + + navigationDestination( + 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 new file mode 100644 index 0000000..472c279 --- /dev/null +++ b/Example/Sources/TweetDetailFeature/TweetDetailFeature.swift @@ -0,0 +1,285 @@ +import _ComposableArchitecture +import LocalExtensions +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 tweetReply(TweetReplyFeature.Action) + } + + public var body: some ReducerOf { + 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? + + @Presents + public var tweetReply: TweetReplyFeature.State? + + @Presents + public var alert: AlertState? + + public init( + source: TweetFeature.State, + replies: TweetsListFeature.State = .init(), + detail: TweetDetailFeature.State? = nil, + tweetReply: TweetReplyFeature.State? = nil, + alert: AlertState? = nil + ) { + self.source = source + self.replies = replies + self.detail = detail + self.tweetReply = tweetReply + self.alert = alert + } + } + + @CasePathable + public enum Action: Equatable { + case source(TweetFeature.Action) + case replies(TweetsListFeature.Action) + case detail(PresentationAction) + case tweetReply(PresentationAction) + case fetchMoreReplies(reset: Bool = true) + + case alert(Alert) + case delegate(Delegate) + case event(Event) + + @CasePathable + public enum Delegate: Equatable { + case openProfile(USID) + } + + @CasePathable + public enum Alert: Equatable { + case didTapAccept + case didTapRecoveryOption(String) + } + + @CasePathable + public enum Event: Equatable { + case didAppear + 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 + } + } + } + } + + @Dependency(\.apiClient) + var apiClient + + public var body: some ReducerOf { + CombineReducers { + Reduce { state, action in + switch action { + case .source(.tapOnAuthor): + return .send(.delegate(.openProfile( + state.source.author.id + ))) + + case + let .replies(.delegate(.openProfile(id))), + let .detail(.presented(.delegate(.openProfile(id)))): + return .send(.delegate(.openProfile(id))) + + 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 let .fetchMoreReplies(reset): + let id = state.source.id + 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.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 + + case let .failure(error): + state.alert = makeAlert(for: error) + return .none + } + + default: + return .none + } + } + + Reduce { state, action in + switch action { + case .alert(.didTapAccept): + state.alert = nil + return .none + + case let .alert(.didTapRecoveryOption(deeplink)): + #warning("TODO: Handle deeplink") + return .none + + default: + return .none + } + } + + 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, + action: \.source, + child: TweetFeature.init + ) + + Scope( + state: \State.replies, + action: \.replies, + child: TweetsListFeature.init + ) + } + .syncTweetDetailSource( + state: \.$detail, + with: \.source + ) + .syncTweetDetailSource( + state: \.$detail, + with: \.replies + ) + .ifLet( + \State.$detail, + action: \.detail, + destination: TweetDetailFeature.init + ) + .ifLet( + \.$tweetReply, + action: \.tweetReply, + destination: TweetReplyFeature.init + ) + } + + func makeAlert(for error: APIClient.Error) -> AlertState { + .init( + title: { + TextState(error.message) + }, + actions: { + ButtonState(role: .cancel, action: .send(.didTapAccept)) { + TextState("OK") + } + } + ) + } +} + +extension Reducer { + public func syncTweetDetailSource( + state 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 + } + } + + 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/TweetDetailFeature/TweetDetailView.swift b/Example/Sources/TweetDetailFeature/TweetDetailView.swift new file mode 100644 index 0000000..226c16a --- /dev/null +++ b/Example/Sources/TweetDetailFeature/TweetDetailView.swift @@ -0,0 +1,92 @@ +import _ComposableArchitecture +import SwiftUI +import AppModels +import TweetFeature +import TweetsListFeature + +public struct TweetDetailView: ComposableView { + 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 + )) + HStack(spacing: 0) { + 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 + )) + .padding(.top) + } + .padding(.leading) + } + } + } +} + +#Preview { + TweetDetailView(Store( + initialState: .init( + 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: [ + 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 new file mode 100644 index 0000000..fe7cdcc --- /dev/null +++ b/Example/Sources/TweetFeature/TweetFeature.swift @@ -0,0 +1,140 @@ +import _ComposableArchitecture +import LocalExtensions +import AppModels +import APIClient + +@Reducer +public struct TweetFeature { + public init() {} + + @ObservableState + public struct State: Equatable, Identifiable { + @ObservableState + 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 + public var likesCount: Int + public var isReposted: Bool + public var repostsCount: Int + public var author: AuthorState + public var text: String + + public init( + id: USID, + createdAt: Date = .now, + replyTo: USID? = nil, + repliesCount: Int = 0, + isLiked: Bool = false, + likesCount: Int = 0, + isReposted: Bool = false, + repostsCount: Int = 0, + author: AuthorState, + text: String + ) { + self.id = id + self.createdAt = createdAt + self.replyTo = replyTo + self.repliesCount = repliesCount + self.isLiked = isLiked + self.likesCount = likesCount + self.isReposted = isReposted + self.repostsCount = repostsCount + self.author = author + self.text = text + } + } + + @CasePathable + public enum Action: Equatable, BindableAction { + case tap + case tapOnAuthor + case reply, toggleLike, repost, share + case binding(BindingAction) + } + + @Dependency(\.apiClient) + var apiClient + + 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 + + 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() + } +} + +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, + 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, + displayName: $0.displayName, + username: $0.username + )} + } +} diff --git a/Example/Sources/TweetFeature/TweetView.swift b/Example/Sources/TweetFeature/TweetView.swift new file mode 100644 index 0000000..d955f73 --- /dev/null +++ b/Example/Sources/TweetFeature/TweetView.swift @@ -0,0 +1,168 @@ +import _ComposableArchitecture +import SwiftUI +import AppUI + +public struct TweetView: ComposableView { + 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 { + _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) + } + } + + @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)) + } + + @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) + } +} + +#Preview { + TweetView(Store( + 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/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/SimpleTweetPreviewView.swift b/Example/Sources/TweetReplyFeature/SimpleTweetPreviewView.swift new file mode 100644 index 0000000..66259fc --- /dev/null +++ b/Example/Sources/TweetReplyFeature/SimpleTweetPreviewView.swift @@ -0,0 +1,98 @@ +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 + .frame(maxWidth: .infinity, alignment: .leading) + .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/TweetReplyFeature/TweetReplyFeature.swift b/Example/Sources/TweetReplyFeature/TweetReplyFeature.swift new file mode 100644 index 0000000..5c6be42 --- /dev/null +++ b/Example/Sources/TweetReplyFeature/TweetReplyFeature.swift @@ -0,0 +1,160 @@ +import _ComposableArchitecture +import TweetFeature +import LocalExtensions +import APIClient + +@Reducer +public struct TweetReplyFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + 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 = "", + alert: AlertState? = nil + ) { + self.source = source + self.avatarURL = avatarURL + self.replyText = replyText + 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.replyText.isNotEmpty else { return .none } + let state = state + return .run { send in + 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 new file mode 100644 index 0000000..f0d8be6 --- /dev/null +++ b/Example/Sources/TweetReplyFeature/TweetReplyView.swift @@ -0,0 +1,76 @@ +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") { + store.send(.tweet) + } + .disabled(store.replyText.isEmpty) + } + .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) + 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/TweetsFeedFeature/TweetsFeedController.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift new file mode 100644 index 0000000..81314ba --- /dev/null +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedController.swift @@ -0,0 +1,70 @@ +import _ComposableArchitecture +import CocoaExtensions +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? + + 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 viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.store?.send(.event(.didAppear)) + } + + public override func scope(_ store: Store?) { + host.setStore(store?.scope( + state: \.list, + action: \.list + )) + + _tweetReplyController.setStore(store?.scope( + state: \.tweetReply, + action: \.tweetReply.presented + )) + + _detailController.setStore(store?.scope( + state: \.detail, + action: \.detail.presented + )) + } + + public override func bind( + _ publisher: StorePublisher, + into cancellables: inout Set + ) { + #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, + popAction: .detail(.dismiss) + ) + .store(in: &cancellables) + } +} diff --git a/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift new file mode 100644 index 0000000..c131835 --- /dev/null +++ b/Example/Sources/TweetsFeedFeature/TweetsFeedFeature.swift @@ -0,0 +1,207 @@ +import _ComposableArchitecture +import LocalExtensions +import APIClient +import TweetsListFeature +import TweetReplyFeature +import TweetDetailFeature +import AppModels + +@Reducer +public struct TweetsFeedFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + public var list: TweetsListFeature.State + + @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 + } + } + + @CasePathable + public enum Action: Equatable { + case list(TweetsListFeature.Action) + case detail(PresentationAction) + case tweetReply(PresentationAction) + case fetchMoreTweets(reset: Bool = false) + case alert(Alert) + case event(Event) + case delegate(Delegate) + + @CasePathable + public enum Alert: Equatable { + case didTapAccept + case didTapRecoveryOption(String) + } + + @CasePathable + public enum Event: Equatable { + case didAppear + 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 + public enum Delegate: Equatable { + case openProfile(USID) + } + } + + @Dependency(\.apiClient) + var apiClient + + public var body: some ReducerOf { + 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 .run { send in + try await Task.sleep(nanoseconds: 1_000_000_000) + await send(.fetchMoreTweets()) + } + } + + Reduce { state, action in + switch action { + 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(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 + } + + default: + return .none + } + } + + Reduce { state, action in + switch action { + case .alert(.didTapAccept): + state.alert = nil + return .none + + case let .alert(.didTapRecoveryOption(deeplink)): + #warning("TODO: Handle deeplink") + return .none + + default: + 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 + ) + .syncTweetDetailSource( + state: \.$detail, + with: \.list + ) + } + + func makeAlert(for error: APIClient.Error) -> AlertState { + .init( + title: { + TextState(error.message) + }, + actions: { + ButtonState(role: .cancel, action: .send(.didTapAccept)) { + TextState("OK") + } + } + ) + } +} diff --git a/Example/Sources/TweetsListFeature/TweetsListFeature.swift b/Example/Sources/TweetsListFeature/TweetsListFeature.swift new file mode 100644 index 0000000..9acb32c --- /dev/null +++ b/Example/Sources/TweetsListFeature/TweetsListFeature.swift @@ -0,0 +1,62 @@ +import _ComposableArchitecture +import LocalExtensions +import TweetFeature + +@Reducer +public struct TweetsListFeature { + public init() {} + + @ObservableState + public struct State: Equatable { + @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 + public enum Action: Equatable { + case tweets(IdentifiedActionOf) + 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(id, .tap)): + return .send(.delegate(.openDetail(id))) + + case let .tweets(.element(id, .tapOnAuthor)): + guard let authorID = state.tweets[id: id]?.author.id + else { return .none } + return .send(.delegate(.openProfile(authorID))) + + default: + return .none + } + } + .forEach( + \State.tweets, + action: \.tweets, + element: TweetFeature.init + ) + } +} diff --git a/Example/Sources/TweetsListFeature/TweetsListView.swift b/Example/Sources/TweetsListFeature/TweetsListView.swift new file mode 100644 index 0000000..3ba2e20 --- /dev/null +++ b/Example/Sources/TweetsListFeature/TweetsListView.swift @@ -0,0 +1,78 @@ +import _ComposableArchitecture +import SwiftUI +import TweetFeature + +public struct TweetsListView: ComposableView { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + 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) + } + } +} + +#Preview { + NavigationStack { + TweetsListView(Store( + initialState: .init(tweets: [ + 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 + )) + .navigationTitle("Preview") + } +} diff --git a/Example/Sources/UserProfileFeature/UserProfileFeature.swift b/Example/Sources/UserProfileFeature/UserProfileFeature.swift new file mode 100644 index 0000000..e5cba27 --- /dev/null +++ b/Example/Sources/UserProfileFeature/UserProfileFeature.swift @@ -0,0 +1,99 @@ +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 { + case + let .current(.delegate(.openProfile(id))), + let .external(.delegate(.openProfile(id))): + return .send(.delegate(.openProfile(id))) + + default: + return .none + } + } + + 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 + ) + + Scope( + state: \.external, + action: \.external, + child: ExternalUserProfileFeature.init + ) + } +} diff --git a/Example/Sources/UserProfileFeature/UserProfileView.swift b/Example/Sources/UserProfileFeature/UserProfileView.swift new file mode 100644 index 0000000..8058ae7 --- /dev/null +++ b/Example/Sources/UserProfileFeature/UserProfileView.swift @@ -0,0 +1,77 @@ +import _ComposableArchitecture +import SwiftUI +import AppModels +import ExternalUserProfileFeature +import CurrentUserProfileFeature + +public struct UserProfileView: ComposableView { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + _body + .onAppear { store.send(.event(.didAppear)) } + } + + @ViewBuilder + private var _body: some View { + 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) + case .loading: + ProgressView() + } + } +} + +#Preview { + NavigationStack { + UserProfileView(Store( + initialState: .external(.init( + 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: [ + .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 + )) + } +} diff --git a/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift b/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift new file mode 100644 index 0000000..0f8c1bc --- /dev/null +++ b/Example/Sources/UserSettingsFeature/UserSettingsFeature.swift @@ -0,0 +1,25 @@ +import _ComposableArchitecture +import LocalExtensions + +@Reducer +public struct UserSettingsFeature { + public init() {} + + @ObservableState + public struct State: Equatable, Identifiable { + public var id: USID + + public init( + id: USID + ) { + self.id = id + } + } + + @CasePathable + public enum Action: Equatable {} + + public var body: some ReducerOf { + EmptyReducer() + } +} diff --git a/Example/Sources/UserSettingsFeature/UserSettingsView.swift b/Example/Sources/UserSettingsFeature/UserSettingsView.swift new file mode 100644 index 0000000..94b69fc --- /dev/null +++ b/Example/Sources/UserSettingsFeature/UserSettingsView.swift @@ -0,0 +1,21 @@ +import _ComposableArchitecture +import SwiftUI + +public struct UserSettingsView: ComposableView { + let store: StoreOf + + public init(_ store: StoreOf) { + self.store = store + } + + public var body: some View { + Text(store.id.usidString) + } +} + +#Preview { + UserSettingsView(Store( + initialState: .init(id: .init()), + reducer: UserSettingsFeature.init + )) +} diff --git a/Example/Sources/_Dependencies/_ComposableArchitecture/Exports.swift b/Example/Sources/_Dependencies/_ComposableArchitecture/Exports.swift new file mode 100644 index 0000000..290a4fd --- /dev/null +++ b/Example/Sources/_Dependencies/_ComposableArchitecture/Exports.swift @@ -0,0 +1 @@ +@_exported import ComposableExtensions 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/ArrayBuilder.swift b/Example/Sources/_Extensions/LocalExtensions/ArrayBuilder.swift new file mode 100644 index 0000000..d86a0c9 --- /dev/null +++ b/Example/Sources/_Extensions/LocalExtensions/ArrayBuilder.swift @@ -0,0 +1,65 @@ +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/_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/Exports.swift b/Example/Sources/_Extensions/LocalExtensions/Exports.swift new file mode 100644 index 0000000..433b363 --- /dev/null +++ b/Example/Sources/_Extensions/LocalExtensions/Exports.swift @@ -0,0 +1,7 @@ +@_exported import FoundationExtensions +@_exported import IdentifiedCollections +@_exported import CombineExtensions + +extension USID { + public static func uuid() -> Self { .init(UUID()) } +} 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() + } +} 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 + } +} diff --git a/Example/project-structure.pdf b/Example/project-structure.pdf new file mode 100644 index 0000000..09a8677 Binary files /dev/null and b/Example/project-structure.pdf differ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..dea42f1 --- /dev/null +++ b/Makefile @@ -0,0 +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: + $(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) + +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/Package.swift b/Package.swift index 192c316..e30b9d5 100644 --- a/Package.swift +++ b/Package.swift @@ -1,52 +1,97 @@ -// swift-tools-version:5.8 +// swift-tools-version: 5.9 import PackageDescription +import CompilerPluginSupport 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/apple/swift-docc-plugin.git", + .upToNextMajor(from: "1.3.0") + ), + .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/swift-foundation-extensions.git", + .upToNextMinor(from: "0.4.0") + ), + .package( + 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/stackotter/swift-macro-toolkit.git", + .upToNextMinor(from: "0.3.0") + ), + ], + targets: [ + .target( + name: "CombineNavigation", + dependencies: [ + .target(name: "CombineNavigationMacros"), + .product( + name: "Capture", + package: "swift-capture" + ), + .product( + name: "CasePaths", + package: "swift-case-paths" + ), + .product( + name: "CocoaAliases", + package: "cocoa-aliases" + ), + .product( + name: "FoundationExtensions", + 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/README.md b/README.md index 1219767..ef14a07 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,199 @@ # 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) + +>Package compiles for all platforms, but functionality is available if UIKit can be imported and the platform is not watchOS. + +> The branch is a `pre-release` 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-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` +- `UIViewController.dismissPublisher` +- `UIViewController.selfDismissPublisher` + +Maybe someday a bug in the compiler will be fixed and we may introduce automatic bootstrap. ```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: + +```swift +enum MyFeatureRoute { + case details +} + +@RoutingController final class MyViewController: UIViewController { + @TreeDestination + var detailsController: DetailsViewController? + + func bindViewModel() { + navigationDestination( + viewModel.publisher(for: \.state.route), + switch: { destinations, route in + switch route { + case .details: + destinations.$detailsController + } + }, + onPop: capture { _self in + _self.viewModel.send(.dismiss) + } + ).store(in: &cancellables) + } +} +``` + +or + +```swift +enum MyFeatureState { // ... + var details: DetailsState? +} + +final class MyViewController: UIViewController { + @TreeDestination + var detailsController: DetailsViewController? func bindViewModel() { - configureRoutes( - for viewModel.publisher(for: \.state.route), - routes: [ - // Provide mapping from route to controller - .associate(makeDetailsController, with: .details) - ], - onDismiss: { - // Update state on dismiss - viewModel.send(.dismiss) + navigationDestination( + "my_feature_details" + isPresented: viewModel.publisher(for: \.state.detais.isNotNil), + destination: $detailsController, + onPop: 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 path: [DestinationState] +} + +final class MyViewController: UIViewController { + @StackDestination + var featureAControllers: [Int: FeatureAController] + + @StackDestination + var featureBControllers: [Int: FeatureBController] + + func bindViewModel() { + navigationStack( + viewModel.publisher(for: \.state.path), + switch: { destinations, route in + switch route { + case .featureA: + destinations.$featureAControllers + case .featureB: + destinations.$featureBControllers + } + }, + 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)) } ).store(in: &cancellables) } } ``` +### 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? + + 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 { + // You can also wrap presented controller in a container controller + // by passing `PresentationDestinationContainerProvider` + @PresentationDestination(container: .navigation) + 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 ### Basic @@ -43,7 +211,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 +222,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/Bootstrap.swift b/Sources/CombineNavigation/Bootstrap.swift new file mode 100644 index 0000000..0fc1d6c --- /dev/null +++ b/Sources/CombineNavigation/Bootstrap.swift @@ -0,0 +1,8 @@ +#if canImport(UIKit) && !os(watchOS) +import CocoaAliases + +public func bootstrap() { + CocoaViewController.bootstrapDismissPublisher + UINavigationController.bootstrapPopPublisher +} +#endif diff --git a/Sources/CombineNavigation/CocoaViewController+.swift b/Sources/CombineNavigation/CocoaViewController+.swift deleted file mode 100644 index 07eda7b..0000000 --- a/Sources/CombineNavigation/CocoaViewController+.swift +++ /dev/null @@ -1,165 +0,0 @@ -#if canImport(UIKit) && !os(watchOS) -import CocoaAliases -import Combine -import CombineExtensions -import FoundationExtensions - -fileprivate extension Cancellable { - func store(in cancellable: inout Cancellable?) { - cancellable = self - } -} - -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 - } -} -#endif diff --git a/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift b/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift new file mode 100644 index 0000000..590d89d --- /dev/null +++ b/Sources/CombineNavigation/Destinations/DestinationInitializableController.swift @@ -0,0 +1,24 @@ +#if canImport(UIKit) && !os(watchOS) && canImport(SwiftUI) +import SwiftUI +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 new file mode 100644 index 0000000..a9caf8d --- /dev/null +++ b/Sources/CombineNavigation/Destinations/PresentationDestination.swift @@ -0,0 +1,161 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +public protocol _PresentationDestinationProtocol: AnyObject { + @_spi(Internals) + func _initControllerForPresentationIfNeeded() -> CocoaViewController + + @_spi(Internals) + func _invalidate() +} + +/// Wrapper for creating and accessing managed navigation destination controller +/// +/// > ⚠️ Sublasses or typealiases must contain "PresentationDestination" in their name +/// > to be processed by `@RoutingController` macro +@propertyWrapper +open class PresentationDestination: + Weakifiable, + _PresentationDestinationProtocol +{ + @_spi(Internals) + open var _controller: Controller? + + internal(set) public var container: CocoaViewController? + + @usableFromInline + internal var containerProvider: ((Controller) -> CocoaViewController)? + + open var wrappedValue: Controller? { _controller } + + @inlinable + open var projectedValue: PresentationDestination { self } + + @usableFromInline + internal var _initControllerOverride: (() -> Controller)? + + @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 + /// + /// 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 { + return __initializeDestinationController() + } + + @_spi(Internals) + @inlinable + open func configureController(_ controller: Controller) {} + + @_disfavoredOverload + public init() {} + + /// Creates a new instance of PresentationDestination + /// + /// `initControllerOverride`* + /// + /// + /// 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 `PresentationDestination` + /// and override it's `initController` class method, you can find an example in tests. + /// + /// - 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 _initControllerForPresentationIfNeeded() -> CocoaViewController { + return callAsFunction() + } + + @_spi(Internals) + open func _invalidate() { + self._controller = nil + self.container = nil + } + + /// 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 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 new file mode 100644 index 0000000..85791ad --- /dev/null +++ b/Sources/CombineNavigation/Destinations/StackDestination.swift @@ -0,0 +1,130 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +public protocol _StackDestinationProtocol: AnyObject { + associatedtype DestinationID: Hashable + + @_spi(Internals) + func _initControllerIfNeeded(for id: DestinationID) -> CocoaViewController + + @_spi(Internals) + func _invalidate(_ id: DestinationID) +} + +/// 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, + Controller: CocoaViewController +>: Weakifiable, _StackDestinationProtocol { + @_spi(Internals) + open var _controllers: [DestinationID: Controller] = [:] + + open var wrappedValue: [DestinationID: Controller] { + _controllers + } + + @inlinable + open var projectedValue: StackDestination { self } + + @usableFromInline + internal var _initControllerOverride: ((DestinationID) -> Controller)? + + @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: ((DestinationID) -> Controller)? + ) { + _initControllerOverride = closure + } + + /// Sets instance-specific configuration for controllers + @inlinable + public func setConfiguration( + _ closure: ((Controller, DestinationID) -> Void)? + ) { + _configuration = closure + closure.map { configure in + wrappedValue.forEach { id, controller in + configure(controller, id) + } + } + } + + @_spi(Internals) + @inlinable + open class func initController( + for id: DestinationID + ) -> Controller { + return __initializeDestinationController() + } + + @_spi(Internals) + @inlinable + open func configureController( + _ controller: Controller, + for id: DestinationID + ) {} + + /// Creates a new instance + public init() {} + + /// Creates a new instance with instance-specific override for creating a new controller + /// + /// 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() + self.overrideInitController(with: initControllerOverride) + } + + @_spi(Internals) + @inlinable + public func _initControllerIfNeeded( + for id: DestinationID + ) -> CocoaViewController { + return self[id] + } + + @_spi(Internals) + @inlinable + open func _invalidate(_ id: DestinationID) { + self._controllers.removeValue(forKey: id) + } + + /// Returns `wrappedValue[id]` if present, intializes and configures a new instance otherwise + public subscript(_ id: DestinationID) -> Controller { + let controller = wrappedValue[id] ?? { + let controller = _initControllerOverride?(id) ?? Self.initController(for: id) + _controllers[id] = 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 new file mode 100644 index 0000000..02a7d6b --- /dev/null +++ b/Sources/CombineNavigation/Destinations/TreeDestination.swift @@ -0,0 +1,116 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +public protocol _TreeDestinationProtocol: AnyObject { + @_spi(Internals) + func _initControllerIfNeeded() -> CocoaViewController + + @_spi(Internals) + func _invalidate() +} + +/// 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, + _TreeDestinationProtocol +{ + @_spi(Internals) + open var _controller: Controller? + + open var wrappedValue: Controller? { _controller } + + @inlinable + open var projectedValue: TreeDestination { 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 { + return __initializeDestinationController() + } + + @_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 + /// + /// 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() + 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/Internal/CombineNavigationRouter+API.swift b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift new file mode 100644 index 0000000..6d1a2e1 --- /dev/null +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter+API.swift @@ -0,0 +1,264 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +// MARK: - navigationStack + +extension CombineNavigationRouter { + /// Subscribes on publisher of navigation stack state + @usableFromInline + func navigationStack< + P: Publisher, + C: Collection, + Route + >( + _ publisher: P, + switch destination: @escaping (Route) -> any _StackDestinationProtocol, + 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, + Route + >( + _ 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 + @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 _StackDestinationProtocol, + onPop: @escaping ([IDs.Element]) -> Void + ) -> Cancellable where + P.Output == Stack, + P.Failure == Never, + IDs.Element: Hashable + { + _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 + let destination = destination(route) + return _self.makeNavigationRoute( + for: id, + controller: { destination._initControllerIfNeeded(for: id) }, + invalidationHandler: { destination._invalidate(id) } + ) + } + } + }, + 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 controller: @escaping (Route, IDs.Element) -> CocoaViewController, + onPop: @escaping ([IDs.Element]) -> Void + ) -> Cancellable where + P.Output == Stack, + P.Failure == Never, + IDs.Element: Hashable + { + _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 + _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 + .sink(receiveValue: capture { _self, stack in + let managedRoutes = routes(stack) + + _self.setRoutes( + managedRoutes, + onPop: managedRoutes.isNotEmpty + ? { poppedRoutes 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 + ) + }) + } +} + +// MARK: - navigationDestination + +extension CombineNavigationRouter { + /// Subscribes on publisher of navigation destination state + @usableFromInline + func navigationDestination( + _ id: AnyHashable, + isPresented publisher: P, + destination: _TreeDestinationProtocol, + 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( + _ publisher: P, + switch destination: @escaping (Route) -> _TreeDestinationProtocol, + onPop: @escaping () -> Void + ) -> AnyCancellable where + P.Output == Optional, + P.Failure == Never + { + publisher + .removeDuplicates(by: Optional.compareTagsEqual) + .map { [weak self] (route) -> NavigationRoute? in + guard let self, let route else { return nil } + let destination = destination(route) + return self.makeNavigationRoute( + for: enumTag(route), + controller: destination._initControllerIfNeeded, + invalidationHandler: destination._invalidate + ) + } + .sink(receiveValue: 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 }) + poppedRoutes.forEach { $0.invalidationHandler?() } + if shouldTriggerPopHandler { onPop() } + } + } + ) + }) + } +} + +// 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 new file mode 100644 index 0000000..cd8f7ab --- /dev/null +++ b/Sources/CombineNavigation/Internal/CombineNavigationRouter.swift @@ -0,0 +1,264 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +extension CocoaViewController { + @usableFromInline + internal var combineNavigationRouter: CombineNavigationRouter { + getAssociatedObject(forKey: #function) ?? { + let router = CombineNavigationRouter(self) + setAssociatedObject(router, forKey: #function) + return router + }() + } + + @inlinable + public func addRoutedChild(_ controller: CocoaViewController) { + combineNavigationRouter.addChild(controller.combineNavigationRouter) + addChild(controller) + } +} + +extension CombineNavigationRouter { + @usableFromInline + class NavigationRoute: Identifiable { + @usableFromInline + let id: AnyHashable + + let routingControllerID: ObjectIdentifier + private(set) var routedControllerID: ObjectIdentifier? + private let controller: () -> CocoaViewController? + let invalidationHandler: (() -> Void)? + + init( + id: AnyHashable, + routingControllerID: ObjectIdentifier, + controller: @escaping () -> CocoaViewController?, + invalidationHandler: (() -> Void)? + ) { + self.id = id + self.routingControllerID = routingControllerID + self.controller = controller + self.invalidationHandler = invalidationHandler + } + + func makeController( + routedBy parentRouter: CombineNavigationRouter + ) -> CocoaViewController? { + let controller = self.controller() + controller?.combineNavigationRouter.parent = parentRouter + routedControllerID = controller?.objectID + return controller + } + } +} + +@usableFromInline +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? + + 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 + } + + @usableFromInline + internal 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 present( + _ presentationDestination: _PresentationDestinationProtocol?, + onDismiss: @escaping () -> Void + ) { + self.requestSetPresentationDestination( + presentationDestination, + onDismiss: onDismiss + ) + } + + func makeNavigationRoute( + for id: ID, + controller: @escaping () -> CocoaViewController?, + invalidationHandler: (() -> Void)? = nil + ) -> NavigationRoute { + NavigationRoute( + id: id, + routingControllerID: node.objectID, + controller: controller, + invalidationHandler: invalidationHandler + ) + } +} + +// 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 router = self.navigationGroupRoot() + let oldDestination = router.presentedDestination + + guard oldDestination !== newDestination else { return } + + router.destinationDismissCancellable = nil + + let __presentNewDestinationIfNeeded: () -> Void = { + oldDestination?._invalidate() + + if let destination = newDestination { + let controller = destination._initControllerForPresentationIfNeeded() + + controller.selfDismissPublisher + .sink(receiveValue: onDismiss) + .store(in: &router.destinationDismissCancellable) + + router.node.present(controller) + } + + self.presentedDestination = newDestination + } + + 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() + } + } + + fileprivate func requestNavigationStackSync() { + guard let node else { return } + + if let navigation = node.navigationController { + syncNavigationStack(using: navigation) + } else { + node.publisher(for: \.navigationController) + .compactMap { $0 } + .sink(receiveValue: capture { $0.syncNavigationStack(using: $1) }) + .store(in: &navigationControllerCancellable) + } + } + + private func syncNavigationStack(using navigation: UINavigationController) { + navigationControllerCancellable = nil + + navigation.popPublisher + .sink(receiveValue: 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: &destinationPopCancellable) + + 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) } + } +} + +// MARK: Helpers +#endif diff --git a/Sources/CombineNavigation/Internal/Helpers/AnyCancellable+.swift b/Sources/CombineNavigation/Internal/Helpers/AnyCancellable+.swift new file mode 100644 index 0000000..0277805 --- /dev/null +++ b/Sources/CombineNavigation/Internal/Helpers/AnyCancellable+.swift @@ -0,0 +1,18 @@ +import Combine + +extension AnyCancellable { + @usableFromInline + internal func store( + for key: Key, + in cancellables: inout [Key: AnyCancellable] + ) { + cancellables[key] = self + } + + @usableFromInline + internal func store( + in cancellable: inout AnyCancellable? + ) { + cancellable = self + } +} 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/EnumTag.swift b/Sources/CombineNavigation/Internal/Helpers/EnumTag.swift new file mode 100644 index 0000000..c4cb9e1 --- /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 wrappedCompare: Bool = enumTag(lhs) == enumTag(rhs) + let unwrappedCompare: Bool = lhs.flatMap(enumTag) == rhs.flatMap(enumTag) + return wrappedCompare && unwrappedCompare + } +} diff --git a/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift b/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift new file mode 100644 index 0000000..022ff09 --- /dev/null +++ b/Sources/CombineNavigation/Internal/Helpers/NSObject+.swift @@ -0,0 +1,6 @@ +import Foundation + +extension NSObject { + @usableFromInline + internal var objectID: ObjectIdentifier { .init(self) } +} 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/Plugin.swift b/Sources/CombineNavigation/Plugin.swift new file mode 100644 index 0000000..8c1a50c --- /dev/null +++ b/Sources/CombineNavigation/Plugin.swift @@ -0,0 +1,13 @@ +#if canImport(UIKit) && !os(watchOS) +import CocoaAliases + +@attached( + extension, + conformances: RoutingController, + names: named(Destinations), named(_makeDestinations()) +) +public macro RoutingController() = #externalMacro( + module: "CombineNavigationMacros", + type: "RoutingControllerMacro" +) +#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/RoutingController+API.swift b/Sources/CombineNavigation/RoutingController+API.swift new file mode 100644 index 0000000..d4ff95e --- /dev/null +++ b/Sources/CombineNavigation/RoutingController+API.swift @@ -0,0 +1,175 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +// MARK: - Public API + +// MARK: navigationStack + +extension RoutingController { + /// Subscribes on publisher of navigation stack state + @inlinable + public func navigationStack< + P: Publisher, + C: Collection & Equatable, + Route + >( + _ publisher: P, + switch destination: @escaping (Destinations, Route) -> any _StackDestinationProtocol, + 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: destinations(destination), + onPop: onPop + ) + } + + /// Subscribes on publisher of navigation stack state + @inlinable + 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 (Destinations, Route) -> any _StackDestinationProtocol, + onPop: @escaping ([IDs.Element]) -> Void + ) -> Cancellable where + P.Output == Stack, + P.Failure == Never, + IDs.Element: Hashable + { + combineNavigationRouter.navigationStack( + publisher, + ids: ids, + route: route, + switch: destinations(destination), + onPop: onPop + ) + } +} + +// MARK: navigationDestination + +extension RoutingController { + /// Subscribes on publisher of navigation destination state + @inlinable + public func navigationDestination( + _ id: AnyHashable, + isPresented publisher: P, + destination: _TreeDestinationProtocol, + 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 + @inlinable + public func navigationDestination( + _ publisher: P, + switch destination: @escaping (Destinations, Route) -> _TreeDestinationProtocol, + onPop: @escaping () -> Void + ) -> AnyCancellable where + P.Output == Route?, + P.Failure == Never + { + combineNavigationRouter.navigationDestination( + publisher, + switch: destinations(destination), + onPop: onPop + ) + } +} + +// 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) -> _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) + } + } + + @usableFromInline + internal func destinations( + _ mapping: @escaping (Destinations, Route) -> any _StackDestinationProtocol + ) -> (Route) -> any _StackDestinationProtocol { + let destinations = _makeDestinations() + return { route in + mapping(destinations, route) + } + } +} +#endif diff --git a/Sources/CombineNavigation/RoutingController.swift b/Sources/CombineNavigation/RoutingController.swift new file mode 100644 index 0000000..0505eed --- /dev/null +++ b/Sources/CombineNavigation/RoutingController.swift @@ -0,0 +1,8 @@ +#if canImport(UIKit) && !os(watchOS) +import CocoaAliases + +public protocol RoutingController: CocoaViewController { + associatedtype Destinations + func _makeDestinations() -> Destinations +} +#endif diff --git a/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift b/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift new file mode 100644 index 0000000..33f0a86 --- /dev/null +++ b/Sources/CombineNavigation/Swizzling/CocoaViewController+DismissPublisher.swift @@ -0,0 +1,124 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +extension CocoaViewController { + @AssociatedObject(readonly: true) + fileprivate var selfDismissSubject: PassthroughSubject = .init() + + @AssociatedObject(readonly: true) + fileprivate var dismissSubject: PassthroughSubject<[CocoaViewController], Never> = .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 + /// > + /// > If you need to observe `dismiss(animated:completion:)` selector + /// > use `dismissPublisher` + /// + /// Underlying subject is triggered by swizzled methods in `CombineNavigation` module. + 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 + } +} + +// 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 +// ) { +// 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 +// } +//} + +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 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/Sources/CombineNavigation/Swizzling/UINavigationController+PopPublisher.swift b/Sources/CombineNavigation/Swizzling/UINavigationController+PopPublisher.swift new file mode 100644 index 0000000..b33d08a --- /dev/null +++ b/Sources/CombineNavigation/Swizzling/UINavigationController+PopPublisher.swift @@ -0,0 +1,164 @@ +#if canImport(UIKit) && !os(watchOS) +import Capture +import CocoaAliases +import Combine +import FoundationExtensions + +extension UINavigationController { + @AssociatedObject(readonly: true) + private var popSubject: PassthroughSubject<[CocoaViewController], Never> = .init() + + /// 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 + internal static let bootstrapPopPublisher: Void = { + objc_exchangeImplementations( + #selector(popViewController(animated:)), + #selector(__swizzledPopViewController) + ) + + objc_exchangeImplementations( + #selector(popToViewController(_:animated:)), + #selector(__swizzledPopToViewController) + ) + + objc_exchangeImplementations( + #selector(popToRootViewController(animated:)), + #selector(__swizzledPopToRootViewController) + ) + + objc_exchangeImplementations( + #selector(setViewControllers(_:animated:)), + #selector(__swizzledSetViewControllers) + ) + }() + + @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/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/RoutingControllerMacro/RoutingControllerMacro.swift b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift new file mode 100644 index 0000000..f8b153d --- /dev/null +++ b/Sources/CombineNavigationMacros/RoutingControllerMacro/RoutingControllerMacro.swift @@ -0,0 +1,245 @@ +import MacroToolkit +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +public struct RoutingControllerMacro { + static let moduleName = "CombineNavigation" + static let conformanceName = "RoutingController" + 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) + }() + + 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] + } + + /// 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.contains("TreeDestination") || + attribute.name.name.contains("StackDestination") || + attribute.name.name.contains("PresentationDestination"): + return true + default: + return false + } + } + + return isNavigationChild ? variable : nil + } + + let destinationStructDocComment: String = """ + /// 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 + """ + + if navigationDestinations.isEmpty { + var destinationsStructDecl: DeclSyntax = """ + \(raw: destinationStructDocComment) + public struct Destinations { + public subscript(_ id: some Hashable) -> UIViewController? { + 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 + } + + let stackDestinations: [String] = navigationDestinations.compactMap { decl in + guard + let attribute = decl.attributes.first?.attribute, + attribute.name.name.contains("StackDestination"), + let identifier = decl.bindings.first?.identifier + else { return .none } + + return identifier + } + + if !stackDestinations.isEmpty { + let erasedIDType = "some Hashable" + + let castedIDDecl: DeclSyntax = """ + func controller( + for s: StackDestination + ) -> UIViewController? { + return (id as? ID).flatMap { + s.wrappedValue[$0] + } + } + """ + + var stackDestinationsCoalecing: String { + return stackDestinations + .map { "controller(for: _\($0))" } + .joined(separator: "\n\t?? ") + } + + let destinationsStructSubscriptDecl: DeclSyntax = + """ + \npublic subscript(_ id: \(raw: erasedIDType)) -> UIViewController? { + \(castedIDDecl) + + return \(raw: stackDestinationsCoalecing) + } + """ + + destinationsStructMembers.append(MemberBlockItemSyntax( + decl: destinationsStructSubscriptDecl + )) + } else { + let destinationsStructSubscriptDecl: DeclSyntax = """ + \npublic subscript(_ id: some Hashable) -> UIViewController? { + return nil + } + """ + + destinationsStructMembers.append(MemberBlockItemSyntax( + decl: destinationsStructSubscriptDecl + )) + } + + 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( + destinationsStructMembers + ) + ), + trailingTrivia: .newlines(2) + ).as(DeclSyntax.self) + + let destinationsInitParams = navigationDestinations.compactMap( + \.bindings.first?.identifier + ).map { identifier in + "\(identifier): $\(identifier)" + }.joined(separator: ",\n") + + let makeDestinationsFuncDecl: DeclSyntax = """ + public func _makeDestinations() -> Destinations { + return 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) + }() + } + + 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 new file mode 100644 index 0000000..4cfedb4 --- /dev/null +++ b/Tests/CombineNavigationMacrosTests/RoutingControllerMacroTests.swift @@ -0,0 +1,466 @@ +import XCTest +import MacroTesting +#if canImport(CombineNavigationMacros) +import CombineNavigationMacros + +final class RoutingControllerMacroTests: 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 {} + """ + } 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) -> UIViewController? { + return nil + } + } + + public func _makeDestinations() -> Destinations { + return Destinations() + } + } + """ + } + } + + func testAttachmentToClass_TreeDestinations() { + assertMacro { + """ + @RoutingController + final class CustomController { + @TreeDestination + var firstDetailController: CocoaViewController? + @TreeDestination + var secondDetailController: CocoaViewController? + } + """ + } expansion: { + """ + final class CustomController { + @TreeDestination + var firstDetailController: CocoaViewController? + @TreeDestination + 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 { + @TreeDestination + var firstDetailController: CocoaViewController? + + @TreeDestination + var secondDetailController: CocoaViewController? + + public subscript(_ id: some Hashable) -> UIViewController? { + return nil + } + } + + public func _makeDestinations() -> Destinations { + return Destinations( + firstDetailController: $firstDetailController, + secondDetailController: $secondDetailController + ) + } + } + """ + } + } + func testAttachmentToClass_PresentationDestinations() { + assertMacro { + """ + @RoutingController + final class CustomController { + @PresentationDestination(container: .navigation) + var firstDetailController: CocoaViewController? + @PresentationDestination + var secondDetailController: CocoaViewController? + } + """ + } expansion: { + """ + final class CustomController { + @PresentationDestination(container: .navigation) + 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(container: .navigation) + 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 { + """ + @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: some Hashable) -> UIViewController? { + func controller( + for s: StackDestination + ) -> UIViewController? { + return (id as? ID).flatMap { + s.wrappedValue[$0] + } + } + + return controller(for: _firstDetailController) + ?? controller(for: _secondDetailController) + } + } + + 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 + } + """ + } expansion: { + """ + final class CustomController { + @StackDestination + 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 + ) + } + } + """ + } + } + + func testAttachmentToClass_CustomTreeDestinations() { + assertMacro { + """ + @RoutingController + final class CustomController { + @CustomTreeDestination + var firstDetailController: CocoaViewController? + @CustomTreeDestination + var secondDetailController: CocoaViewController? + } + """ + } expansion: { + """ + final class CustomController { + @CustomTreeDestination + var firstDetailController: CocoaViewController? + @CustomTreeDestination + 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 { + @CustomTreeDestination + var firstDetailController: CocoaViewController? + + @CustomTreeDestination + var secondDetailController: CocoaViewController? + + public subscript(_ id: some Hashable) -> UIViewController? { + return nil + } + } + + public func _makeDestinations() -> Destinations { + return Destinations( + firstDetailController: $firstDetailController, + secondDetailController: $secondDetailController + ) + } + } + """ + } + } + + 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 { + """ + @RoutingController + final class CustomController { + @CustomStackDestination + var firstDetailController: [Int: CocoaViewController] + + @CustomStackDestinationOf + var secondDetailController + } + """ + } expansion: { + """ + final class CustomController { + @CustomStackDestination + var firstDetailController: [Int: CocoaViewController] + + @CustomStackDestinationOf + 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] + + + @CustomStackDestinationOf + var secondDetailController + + 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) + } + } + + public func _makeDestinations() -> Destinations { + return Destinations( + firstDetailController: $firstDetailController, + secondDetailController: $secondDetailController + ) + } + } + """ + } + } +} +#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/Destinations/StackDestinationTests.swift b/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift new file mode 100644 index 0000000..cc8c995 --- /dev/null +++ b/Tests/CombineNavigationTests/Destinations/StackDestinationTests.swift @@ -0,0 +1,145 @@ +import XCTest +import CocoaAliases +@_spi(Internals) import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) +final class StackDestinationTests: XCTestCase { + func testMain() { + @StackDestination + var sut: [AnyHashable: CustomViewController] + + @StackDestination({ _ in .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({ _ in .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(Internals) import` + /// to override this declaration + override func configureController( + _ controller: Controller, + for id: StackElementID + ) { + 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( + for id: StackElementID + ) -> Controller { + .init(value: 1) + } +} +#endif diff --git a/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift b/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift new file mode 100644 index 0000000..51e7d85 --- /dev/null +++ b/Tests/CombineNavigationTests/Destinations/TreeDestinationTests.swift @@ -0,0 +1,85 @@ +import XCTest +import CocoaAliases +@_spi(Internals) import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) +final class TreeDestinationTests: 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() { + @CustomTreeDestination + var sut: CustomViewController? + + @CustomTreeDestination({ .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 CustomTreeDestination: TreeDestination { + override var wrappedValue: Controller? { super.wrappedValue } + override var projectedValue: CustomTreeDestination { 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/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/NavigationAnimationTests.swift b/Tests/CombineNavigationTests/NavigationAnimationTests.swift new file mode 100644 index 0000000..50e5493 --- /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, 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/RoutingControllerPresentationTests.swift b/Tests/CombineNavigationTests/RoutingControllerPresentationTests.swift new file mode 100644 index 0000000..8888dac --- /dev/null +++ b/Tests/CombineNavigationTests/RoutingControllerPresentationTests.swift @@ -0,0 +1,110 @@ +import XCTest +import CocoaAliases +import Capture +import Combine +@testable import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) + +// TODO: Test destinations 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 { + XCTAssertEqual(controller._topPresentedController, controller) + + viewModel.state.value.destination = .feedback() + XCTAssertEqual(controller._topPresentedController, controller.feedbackController) + + viewModel.state.value.destination = .orderDetail() + XCTAssertEqual(controller._topPresentedController, controller.orderDetailController) + + controller.dismiss() + XCTAssertEqual(viewModel.state.value.destination, .none) + + viewModel.state.value.destination = .feedback() + XCTAssertEqual(controller._topPresentedController, controller.feedbackController) + + viewModel.state.value.destination = .none + XCTAssertEqual(controller._topPresentedController, controller) + } + } +} + +fileprivate let testDestinationID = UUID() + +fileprivate class OrderDetailsController: AppleSpaghettiCodeTestableController {} +fileprivate class FeedbackController: AppleSpaghettiCodeTestableController {} + +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: AppleSpaghettiCodeTestableController { + 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) + } +} + +#endif diff --git a/Tests/CombineNavigationTests/RoutingControllerStackTests.swift b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift new file mode 100644 index 0000000..563b3f4 --- /dev/null +++ b/Tests/CombineNavigationTests/RoutingControllerStackTests.swift @@ -0,0 +1,207 @@ +import XCTest +import CocoaAliases +import Capture +import Combine +@testable import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) + +// TODO: Test destinations deinitialization +// Note: Manual check succeed ✅ + +final class RoutingControllerStackTests: XCTestCase { + static override func setUp() { + CombineNavigation.bootstrap() + } + + 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, route in + switch route { + case .orderDetail: + destinations.$orderDetailControllers + case .feedback: + destinations.$feedbackControllers + } + }, + 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 new file mode 100644 index 0000000..d4a8a5c --- /dev/null +++ b/Tests/CombineNavigationTests/RoutingControllerTests.swift @@ -0,0 +1,324 @@ +import XCTest +import CocoaAliases +import Capture +import Combine +@testable import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) +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()) + let navigation = UINavigationController(rootViewController: root) + navigation.loadViewIfNeeded() + root.loadViewIfNeeded() + root.viewModel = viewModel + + withoutNavigationAnimation { + 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 class TreeViewModel { + struct State { + enum Destination { + /// UUID represents some state + case tree(TreeViewModel) + case stack(StackViewModel) + + enum Tag: Hashable { + 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 .tree: return .tree + case .stack: return .stack + } + } + } + + var id: UUID = .init() + var destination: Destination? + } + + 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 +fileprivate class TreeViewController: CocoaViewController { + private var cancellables: Set = [] + + var viewModel: TreeViewModel! { + didSet { + cancellables = [] + guard let viewModel else { return } + bind(viewModel.publisher) + } + } + + @TreeDestination + var treeController: TreeViewController? + + @TreeDestination + 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) + }.sink(receiveValue: capture { _self, destination in + self.scope(_self.viewModel) + }) + .store(in: &cancellables) + + navigationDestination( + publisher.map(\.destination?.tag).removeDuplicates(), + switch: { destinations, route in + switch route { + case .tree: + destinations.$treeController + case .stack: + destinations.$stackController + } + }, + onPop: capture { _self in + _self.viewModel.state.destination = .none + } + ) + .store(in: &cancellables) + } +} + +// MARK: - Stack + +fileprivate class StackViewModel { + struct State { + enum Destination { + /// UUID represents some state + case tree(TreeViewModel) + case stack(StackViewModel) + + enum Tag: Hashable { + 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 .tree: return .tree + case .stack: return .stack + } + } + } + + var root: TreeViewModel = .init(initialState: .init()) + var path: [Destination] = [] + } + + 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 +fileprivate class StackViewController: CocoaViewController { + private var cancellables: Set = [] + + var viewModel: StackViewModel! { + didSet { + cancellables = [] + guard let viewModel else { return } + bind(viewModel.publisher) + } + } + + var contentController: TreeViewController = .init() + + @StackDestination + var treeControllers: [Int: TreeViewController] + + @StackDestination + 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) + }.sink(receiveValue: capture { _self, destination in + self.scope(_self.viewModel) + }) + .store(in: &cancellables) + + navigationStack( + publisher.map(\.path).map { $0.map(\.tag) }.removeDuplicates(), + switch: { destinations, route in + switch route { + case .tree: + destinations.$treeControllers + case .stack: + destinations.$stackControllers + } + }, + onPop: capture { _self, indices in + _self.viewModel.state.path.remove(atOffsets: IndexSet(indices)) + } + ) + .store(in: &cancellables) + } + +} +#endif diff --git a/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift new file mode 100644 index 0000000..9c6aac3 --- /dev/null +++ b/Tests/CombineNavigationTests/RoutingControllerTreeTests.swift @@ -0,0 +1,123 @@ +import XCTest +import CocoaAliases +import Capture +import Combine +@testable import CombineNavigation + +#if canImport(UIKit) && !os(watchOS) + +// TODO: Test destinations deinitialization +// Note: Manual check succeed ✅ + +final class RoutingControllerTreeTests: XCTestCase { + static override func setUp() { + CombineNavigation.bootstrap() + } + + 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), + switch: { 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