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
-[](https://swift.org/download/) ! [](https://twitter.com/capture_context)
+[](https://github.com/CaptureContext/swift-declarative-configuration/actions/workflows/Test.yml)  [](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