diff --git a/.gitignore b/.gitignore index 95c4320..fd9669c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,18 @@ -.DS_Store /.build /Packages /*.xcodeproj xcuserdata/ +.build/ +.swiftpm/ +.hg +.svn +CVS + +.DS_Store +Desktop.ini +Thumbs.db +ehthumbs.db +*~ +.#* +.*.swp +.*.swo diff --git a/Package.swift b/Package.swift index 9f9bc1f..461fd04 100644 --- a/Package.swift +++ b/Package.swift @@ -5,22 +5,25 @@ import PackageDescription let package = Package( name: "Incremental", + platforms: [.iOS(.v12)], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( name: "Incremental", targets: ["Incremental"]), + .library(name: "Resin", + targets: ["Resin"]), ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - ], + dependencies: [], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages which this package depends on. .target( name: "Incremental", dependencies: []), + .target( + name: "Resin", + dependencies: ["Incremental"]), .testTarget( name: "IncrementalTests", dependencies: ["Incremental"]), diff --git a/SampleApp/SampleApp.xcodeproj/project.pbxproj b/SampleApp/SampleApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8229dde --- /dev/null +++ b/SampleApp/SampleApp.xcodeproj/project.pbxproj @@ -0,0 +1,455 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + F9B67C50243390F90060F96F /* SetTabAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C46243390F90060F96F /* SetTabAction.swift */; }; + F9B67C52243390F90060F96F /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C48243390F90060F96F /* AppState.swift */; }; + F9B67C53243390F90060F96F /* SFSafariViewController+PresentationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C49243390F90060F96F /* SFSafariViewController+PresentationItem.swift */; }; + F9B67C54243390F90060F96F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F9B67C4A243390F90060F96F /* Assets.xcassets */; }; + F9B67C55243390F90060F96F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C4B243390F90060F96F /* AppDelegate.swift */; }; + F9B67C56243390F90060F96F /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C4C243390F90060F96F /* main.swift */; }; + F9B67C57243390F90060F96F /* ForegroundDaemon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C4D243390F90060F96F /* ForegroundDaemon.swift */; }; + F9B67C58243390F90060F96F /* TabController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C4E243390F90060F96F /* TabController.swift */; }; + F9B67C59243390F90060F96F /* AppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B67C4F243390F90060F96F /* AppCoordinator.swift */; }; + F9B67C5C243391030060F96F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F9B67C5A243391030060F96F /* LaunchScreen.storyboard */; }; + F9B68C87243391820060F96F /* UndergroundState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C7B243391820060F96F /* UndergroundState.swift */; }; + F9B68C88243391820060F96F /* StationsState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C7C243391820060F96F /* StationsState.swift */; }; + F9B68C89243391820060F96F /* Station.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C7D243391820060F96F /* Station.swift */; }; + F9B68C8A243391820060F96F /* StationListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C7F243391820060F96F /* StationListViewController.swift */; }; + F9B68C8B243391820060F96F /* StationsDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C80243391820060F96F /* StationsDetailsViewController.swift */; }; + F9B68C8C243391820060F96F /* LineListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C81243391820060F96F /* LineListViewController.swift */; }; + F9B68C8D243391820060F96F /* SyncFavoritesActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C83243391820060F96F /* SyncFavoritesActions.swift */; }; + F9B68C8E243391820060F96F /* ToggleFavoriteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C84243391820060F96F /* ToggleFavoriteAction.swift */; }; + F9B68C8F243391820060F96F /* LoadLinesInfoAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C85243391820060F96F /* LoadLinesInfoAction.swift */; }; + F9B68C90243391820060F96F /* UndergroundParcel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B68C86243391820060F96F /* UndergroundParcel.swift */; }; + F9B68C9D243393890060F96F /* Incremental in Frameworks */ = {isa = PBXBuildFile; productRef = F9B68C9C243393890060F96F /* Incremental */; }; + F9B68C9F243393890060F96F /* Resin in Frameworks */ = {isa = PBXBuildFile; productRef = F9B68C9E243393890060F96F /* Resin */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + F9B67C2F243390B80060F96F /* SampleApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SampleApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F9B67C46243390F90060F96F /* SetTabAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetTabAction.swift; sourceTree = ""; }; + F9B67C47243390F90060F96F /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F9B67C48243390F90060F96F /* AppState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + F9B67C49243390F90060F96F /* SFSafariViewController+PresentationItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SFSafariViewController+PresentationItem.swift"; sourceTree = ""; }; + F9B67C4A243390F90060F96F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + F9B67C4B243390F90060F96F /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + F9B67C4C243390F90060F96F /* main.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + F9B67C4D243390F90060F96F /* ForegroundDaemon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForegroundDaemon.swift; sourceTree = ""; }; + F9B67C4E243390F90060F96F /* TabController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabController.swift; sourceTree = ""; }; + F9B67C4F243390F90060F96F /* AppCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppCoordinator.swift; sourceTree = ""; }; + F9B67C5B243391030060F96F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + F9B68C7B243391820060F96F /* UndergroundState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UndergroundState.swift; sourceTree = ""; }; + F9B68C7C243391820060F96F /* StationsState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StationsState.swift; sourceTree = ""; }; + F9B68C7D243391820060F96F /* Station.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Station.swift; sourceTree = ""; }; + F9B68C7F243391820060F96F /* StationListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StationListViewController.swift; sourceTree = ""; }; + F9B68C80243391820060F96F /* StationsDetailsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StationsDetailsViewController.swift; sourceTree = ""; }; + F9B68C81243391820060F96F /* LineListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LineListViewController.swift; sourceTree = ""; }; + F9B68C83243391820060F96F /* SyncFavoritesActions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SyncFavoritesActions.swift; sourceTree = ""; }; + F9B68C84243391820060F96F /* ToggleFavoriteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToggleFavoriteAction.swift; sourceTree = ""; }; + F9B68C85243391820060F96F /* LoadLinesInfoAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadLinesInfoAction.swift; sourceTree = ""; }; + F9B68C86243391820060F96F /* UndergroundParcel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UndergroundParcel.swift; sourceTree = ""; }; + F9B68C9A243393720060F96F /* incremental-simplified */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "incremental-simplified"; path = ..; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + F9B67C2C243390B80060F96F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F9B68C9F243393890060F96F /* Resin in Frameworks */, + F9B68C9D243393890060F96F /* Incremental in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + F9B67C26243390B80060F96F = { + isa = PBXGroup; + children = ( + F9B68C9A243393720060F96F /* incremental-simplified */, + F9B67C31243390B80060F96F /* SampleApp */, + F9B67C30243390B80060F96F /* Products */, + F9B68C9B243393890060F96F /* Frameworks */, + ); + sourceTree = ""; + }; + F9B67C30243390B80060F96F /* Products */ = { + isa = PBXGroup; + children = ( + F9B67C2F243390B80060F96F /* SampleApp.app */, + ); + name = Products; + sourceTree = ""; + }; + F9B67C31243390B80060F96F /* SampleApp */ = { + isa = PBXGroup; + children = ( + F9B68C7A243391820060F96F /* Underground */, + F9B68C79243391690060F96F /* MainApp */, + F9B67C5A243391030060F96F /* LaunchScreen.storyboard */, + F9B67C4A243390F90060F96F /* Assets.xcassets */, + F9B67C47243390F90060F96F /* Info.plist */, + F9B67C4C243390F90060F96F /* main.swift */, + ); + path = SampleApp; + sourceTree = ""; + }; + F9B68C79243391690060F96F /* MainApp */ = { + isa = PBXGroup; + children = ( + F9B67C4F243390F90060F96F /* AppCoordinator.swift */, + F9B67C4B243390F90060F96F /* AppDelegate.swift */, + F9B67C48243390F90060F96F /* AppState.swift */, + F9B67C4D243390F90060F96F /* ForegroundDaemon.swift */, + F9B67C46243390F90060F96F /* SetTabAction.swift */, + F9B67C49243390F90060F96F /* SFSafariViewController+PresentationItem.swift */, + F9B67C4E243390F90060F96F /* TabController.swift */, + ); + path = MainApp; + sourceTree = ""; + }; + F9B68C7A243391820060F96F /* Underground */ = { + isa = PBXGroup; + children = ( + F9B68C7B243391820060F96F /* UndergroundState.swift */, + F9B68C7C243391820060F96F /* StationsState.swift */, + F9B68C7D243391820060F96F /* Station.swift */, + F9B68C7E243391820060F96F /* ViewControllers */, + F9B68C82243391820060F96F /* Actions */, + F9B68C86243391820060F96F /* UndergroundParcel.swift */, + ); + path = Underground; + sourceTree = ""; + }; + F9B68C7E243391820060F96F /* ViewControllers */ = { + isa = PBXGroup; + children = ( + F9B68C7F243391820060F96F /* StationListViewController.swift */, + F9B68C80243391820060F96F /* StationsDetailsViewController.swift */, + F9B68C81243391820060F96F /* LineListViewController.swift */, + ); + path = ViewControllers; + sourceTree = ""; + }; + F9B68C82243391820060F96F /* Actions */ = { + isa = PBXGroup; + children = ( + F9B68C83243391820060F96F /* SyncFavoritesActions.swift */, + F9B68C84243391820060F96F /* ToggleFavoriteAction.swift */, + F9B68C85243391820060F96F /* LoadLinesInfoAction.swift */, + ); + path = Actions; + sourceTree = ""; + }; + F9B68C9B243393890060F96F /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F9B67C2E243390B80060F96F /* SampleApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = F9B67C43243390BB0060F96F /* Build configuration list for PBXNativeTarget "SampleApp" */; + buildPhases = ( + F9B67C2B243390B80060F96F /* Sources */, + F9B67C2C243390B80060F96F /* Frameworks */, + F9B67C2D243390B80060F96F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SampleApp; + packageProductDependencies = ( + F9B68C9C243393890060F96F /* Incremental */, + F9B68C9E243393890060F96F /* Resin */, + ); + productName = SampleApp; + productReference = F9B67C2F243390B80060F96F /* SampleApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F9B67C27243390B80060F96F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1130; + LastUpgradeCheck = 1130; + TargetAttributes = { + F9B67C2E243390B80060F96F = { + CreatedOnToolsVersion = 11.3.1; + LastSwiftMigration = 1130; + }; + }; + }; + buildConfigurationList = F9B67C2A243390B80060F96F /* Build configuration list for PBXProject "SampleApp" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = F9B67C26243390B80060F96F; + packageReferences = ( + ); + productRefGroup = F9B67C30243390B80060F96F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F9B67C2E243390B80060F96F /* SampleApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + F9B67C2D243390B80060F96F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F9B67C5C243391030060F96F /* LaunchScreen.storyboard in Resources */, + F9B67C54243390F90060F96F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + F9B67C2B243390B80060F96F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F9B67C58243390F90060F96F /* TabController.swift in Sources */, + F9B68C8B243391820060F96F /* StationsDetailsViewController.swift in Sources */, + F9B68C90243391820060F96F /* UndergroundParcel.swift in Sources */, + F9B68C8C243391820060F96F /* LineListViewController.swift in Sources */, + F9B67C55243390F90060F96F /* AppDelegate.swift in Sources */, + F9B67C56243390F90060F96F /* main.swift in Sources */, + F9B68C8E243391820060F96F /* ToggleFavoriteAction.swift in Sources */, + F9B67C50243390F90060F96F /* SetTabAction.swift in Sources */, + F9B68C88243391820060F96F /* StationsState.swift in Sources */, + F9B67C59243390F90060F96F /* AppCoordinator.swift in Sources */, + F9B67C53243390F90060F96F /* SFSafariViewController+PresentationItem.swift in Sources */, + F9B68C87243391820060F96F /* UndergroundState.swift in Sources */, + F9B68C89243391820060F96F /* Station.swift in Sources */, + F9B67C57243390F90060F96F /* ForegroundDaemon.swift in Sources */, + F9B67C52243390F90060F96F /* AppState.swift in Sources */, + F9B68C8F243391820060F96F /* LoadLinesInfoAction.swift in Sources */, + F9B68C8A243391820060F96F /* StationListViewController.swift in Sources */, + F9B68C8D243391820060F96F /* SyncFavoritesActions.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + F9B67C5A243391030060F96F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + F9B67C5B243391030060F96F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + F9B67C41243390BB0060F96F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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_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; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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 = 13.2; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + F9B67C42243390BB0060F96F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + 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_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; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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 = 13.2; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + F9B67C44243390BB0060F96F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = SampleApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.resin.SampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + F9B67C45243390BB0060F96F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = SampleApp/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.resin.SampleApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + F9B67C2A243390B80060F96F /* Build configuration list for PBXProject "SampleApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F9B67C41243390BB0060F96F /* Debug */, + F9B67C42243390BB0060F96F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F9B67C43243390BB0060F96F /* Build configuration list for PBXNativeTarget "SampleApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F9B67C44243390BB0060F96F /* Debug */, + F9B67C45243390BB0060F96F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + F9B68C9C243393890060F96F /* Incremental */ = { + isa = XCSwiftPackageProductDependency; + productName = Incremental; + }; + F9B68C9E243393890060F96F /* Resin */ = { + isa = XCSwiftPackageProductDependency; + productName = Resin; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = F9B67C27243390B80060F96F /* Project object */; +} diff --git a/SampleApp/SampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/SampleApp/SampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/SampleApp/SampleApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/SampleApp/SampleApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/SampleApp/SampleApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/SampleApp/SampleApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/SampleApp/SampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/SampleApp/SampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d8db8d6 --- /dev/null +++ b/SampleApp/SampleApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SampleApp/SampleApp/Assets.xcassets/Contents.json b/SampleApp/SampleApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/SampleApp/SampleApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/SampleApp/SampleApp/Base.lproj/LaunchScreen.storyboard b/SampleApp/SampleApp/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/SampleApp/SampleApp/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SampleApp/SampleApp/Info.plist b/SampleApp/SampleApp/Info.plist new file mode 100644 index 0000000..5a63475 --- /dev/null +++ b/SampleApp/SampleApp/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/SampleApp/SampleApp/MainApp/AppCoordinator.swift b/SampleApp/SampleApp/MainApp/AppCoordinator.swift new file mode 100644 index 0000000..091e41b --- /dev/null +++ b/SampleApp/SampleApp/MainApp/AppCoordinator.swift @@ -0,0 +1,67 @@ +import Resin +import SafariServices +import UIKit + +typealias AppEnvironment = ( + userDefaults: UserDefaults, + loadStations: (@escaping (Result) -> Void) -> Void +) + +public final class AppCoordinator { + let presentationRouter: PresentationRouter + + let tabBarController: TabBarController + + let store: Store + + public init() { + let store = RootStore(state: AppState()) + + let environment: AppEnvironment = ( + userDefaults: .standard, + loadStations: dummyLoadStations + ) + + self.presentationRouter = PresentationRouter(store: store) + + let parcels: [Parcel] = [ + mapAppStateParcel(), + makeUndergroundParcel(environment: environment).unfocused(by: \AppState.undergroundState), + ] + + for p in parcels { + let delivery = p.build(store) + store.integrate(delivery: delivery) + presentationRouter.register(delivery: delivery) + } + + store.addImplicitNavigationMiddleware() + store.addPresentationReducers() + + self.store = store + + tabBarController = presentationRouter.makeViewController(identifier: TabBarController.PresentationIdentifier().typeErased) as! TabBarController + tabBarController.presentationRouter = presentationRouter.typeErased + tabBarController.actionHandler = store + } +} + +func mapAppStateParcel() -> Parcel { + Parcel { store in + var delivery = Parcel.Delivery() + + delivery.middlewares = [ + ForegroundDaemon(state: store.state.map { _ in ArbitraryState() }).unfocused(), + ] + delivery.reducers = [ + SetTabReducer().typeErased, + ] + + delivery.presentationRoutes = [ + SFSafariViewController.presentationRoute.typeErased.unfocused(), + TabBarController.presentationRoute.typeErased, + ] + + return delivery + } +} diff --git a/SampleApp/SampleApp/MainApp/AppDelegate.swift b/SampleApp/SampleApp/MainApp/AppDelegate.swift new file mode 100644 index 0000000..3760289 --- /dev/null +++ b/SampleApp/SampleApp/MainApp/AppDelegate.swift @@ -0,0 +1,22 @@ +import Foundation +import UIKit + +public final class AppDelegate: NSObject { + @objc public var window: UIWindow? + + let appCoordinator = AppCoordinator() + + public override init() { + super.init() + } +} + +extension AppDelegate: UIApplicationDelegate { + public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + window = UIWindow(frame: UIScreen.main.bounds) + window!.rootViewController = appCoordinator.tabBarController + window!.makeKeyAndVisible() + + return true + } +} diff --git a/SampleApp/SampleApp/MainApp/AppState.swift b/SampleApp/SampleApp/MainApp/AppState.swift new file mode 100644 index 0000000..8d5cbea --- /dev/null +++ b/SampleApp/SampleApp/MainApp/AppState.swift @@ -0,0 +1,19 @@ +import Foundation +import Resin + +public struct AppState: Equatable { + enum Tab: Int, Equatable { + case underground = 0 + case ticket = 1 + } + + var counter: Int = 0 + + var undergroundState: UndergroundState = UndergroundState() + + var modalStack: [AnyPresentationIdentifier] = [] + + var selectedTab: Tab = .underground +} + + diff --git a/SampleApp/SampleApp/MainApp/ForegroundDaemon.swift b/SampleApp/SampleApp/MainApp/ForegroundDaemon.swift new file mode 100644 index 0000000..cc0cfc4 --- /dev/null +++ b/SampleApp/SampleApp/MainApp/ForegroundDaemon.swift @@ -0,0 +1,22 @@ +import Foundation +import Incremental +import Resin +import UIKit + +final class ForegroundDaemon: Daemon { + public override func observe(state: I) -> Disposable? { + NotificationCenter.default.addObserver( + self, + selector: #selector(observe(note:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + + return nil + } + + @objc + func observe(note: Notification) { + dispatch(LoadStationsAction()) + } +} diff --git a/SampleApp/SampleApp/MainApp/SFSafariViewController+PresentationItem.swift b/SampleApp/SampleApp/MainApp/SFSafariViewController+PresentationItem.swift new file mode 100644 index 0000000..5570828 --- /dev/null +++ b/SampleApp/SampleApp/MainApp/SFSafariViewController+PresentationItem.swift @@ -0,0 +1,18 @@ +import Resin +import SafariServices + +extension SFSafariViewController { + public struct PresentationIdentifier: Resin.PresentationIdentifier { + public var url: URL + + public init(url: URL) { + self.url = url + } + } + + static var presentationRoute: PresentationRoute { + return PresentationRoute { identifier, _ in + SFSafariViewController(url: identifier.url) + } + } +} diff --git a/SampleApp/SampleApp/MainApp/SetTabAction.swift b/SampleApp/SampleApp/MainApp/SetTabAction.swift new file mode 100644 index 0000000..75a5e35 --- /dev/null +++ b/SampleApp/SampleApp/MainApp/SetTabAction.swift @@ -0,0 +1,14 @@ +import Foundation +import Resin + +struct SetTabAction: Action { + var tab: AppState.Tab +} + +struct SetTabReducer: Reducer { + func reduce(action: SetTabAction, state: inout AppState) { + state.selectedTab = action.tab + } +} + + diff --git a/SampleApp/SampleApp/MainApp/TabController.swift b/SampleApp/SampleApp/MainApp/TabController.swift new file mode 100644 index 0000000..716303a --- /dev/null +++ b/SampleApp/SampleApp/MainApp/TabController.swift @@ -0,0 +1,56 @@ +import Foundation +import Incremental +import Resin +import UIKit + +class TabBarController: UITabBarController, UITabBarControllerDelegate { + struct NavigationIdentifier: Resin.PresentationIdentifier {} + + private var viewControllerCache: ViewControllerCache! + + public init(tabs: I<[AnyPresentationIdentifier]>) { + super.init(nibName: nil, bundle: nil) + + delegate = self + + viewControllerCache = ViewControllerCache(owner: self) + + whileVisible.do { + $0.bind(keyPath: \.viewControllers, to: tabs.map(self.viewControllerCache.createViewControllersIfNeeded)) + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { + let tab = AppState.Tab(rawValue: selectedIndex) ?? .underground + dispatch(SetTabAction(tab: tab)) + } +} + +extension TabBarController { + struct PresentationIdentifier: Resin.PresentationIdentifier { + } + + static var presentationRoute: PresentationRoute { + return PresentationRoute { _, store in + let tabs: [AnyPresentationIdentifier] = [ + StationListViewController.NavigationPresentationIdentifier().typeErased, + ] + + let controller = TabBarController(tabs: I(constant: tabs)) + + controller.whileVisible.do { controller in + controller.bind(keyPath: \.selectedIndex, to: store.state[\.selectedTab.rawValue]) + } + + controller.whileAbleToPresent.do { controller in + controller.bindPresentedViewControllers(store: store, navigationStack: \.modalStack) + } + + return controller + } + } +} diff --git a/SampleApp/SampleApp/Underground/Actions/LoadLinesInfoAction.swift b/SampleApp/SampleApp/Underground/Actions/LoadLinesInfoAction.swift new file mode 100644 index 0000000..18be639 --- /dev/null +++ b/SampleApp/SampleApp/Underground/Actions/LoadLinesInfoAction.swift @@ -0,0 +1,60 @@ +import Combine +import Foundation +import Resin + +public struct LoadStationsAction: Action { +} + +final class LoadStationsMiddleware: Middleware { + var loadStations: (@escaping (Result) -> Void) -> Void + + init(loadStations: @escaping (@escaping (Result) -> Void) -> Void) { + self.loadStations = loadStations + } + + override func transform(action: LoadStationsAction, context: ActionContext, state: UndergroundState) -> Resin.Action? { + self.loadStations { [weak self] result in + self?.dispatch(SetStationsAction.done(result)) + } + + return SetStationsAction.loading + } +} + +enum SetStationsAction: Action { + case none + case loading + case done(Result) +} + +struct SetStationsReducer: Reducer { + func reduce(action: SetStationsAction, state: inout UndergroundState) { + switch action { + case .none: + state.stationStateRequest = .none + + case .loading: + state.stationStateRequest = .loading + + case let .done(result): + switch result { + case let .success(stations): + state.stationStateRequest = .success + state.stationsState = stations + + case .failure: + state.stationStateRequest = .failure + // leave the loast sucessfully loaded stations visible + } + } + } +} + + +func dummyLoadStations(completionHandler: @escaping (Result) -> Void) { + DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + 2) { + completionHandler(.success(StationsState.dummyData)) + } +} + + diff --git a/SampleApp/SampleApp/Underground/Actions/SyncFavoritesActions.swift b/SampleApp/SampleApp/Underground/Actions/SyncFavoritesActions.swift new file mode 100644 index 0000000..59d0366 --- /dev/null +++ b/SampleApp/SampleApp/Underground/Actions/SyncFavoritesActions.swift @@ -0,0 +1,69 @@ +import Foundation +import Incremental +import Resin + +enum SyncFavoritesAction: Action { + case fromUserDefaults(Set) + case toUserDefaults(Set) +} + +class SyncFavoritesMiddleware: Middleware { + private let userDefaults: UserDefaults + + private var observer: NSKeyValueObservation? + + private var disposable: Disposable? + + required public init(favoritesStations: I>, userDefaults: UserDefaults) { + self.userDefaults = userDefaults + + super.init() + + observer = userDefaults.observe(\.favorites) { [unowned self] _, changes in + if changes.newValue != changes.oldValue { + self.dispatch(SyncFavoritesAction.fromUserDefaults(changes.newValue ?? Set())) + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [unowned self] in + self.dispatch(SyncFavoritesAction.fromUserDefaults(self.userDefaults.favorites)) + + self.disposable = favoritesStations.observe { [unowned self] favorites in + self.dispatch(SyncFavoritesAction.toUserDefaults(favorites)) + } + } + } + + override func transform(action: SyncFavoritesAction, context: ActionContext, state: UndergroundState) -> Resin.Action? { + guard case let .toUserDefaults(favorites) = action else { + return action + } + + userDefaults.favorites = favorites + return nil + } +} + +struct SyncFavoritesReducer: Reducer { + public func reduce(action: SyncFavoritesAction, state: inout UndergroundState) { + guard case let .fromUserDefaults(favorites) = action else { + return + } + + state.favoriteStations = favorites + } +} + + +extension UserDefaults { + @objc var favorites: Set { + get { + let array = object(forKey: "favorites") as? [String] ?? [] + + return Set(array) + } + set { + set(Array(newValue), forKey: "favorites") + } + } +} diff --git a/SampleApp/SampleApp/Underground/Actions/ToggleFavoriteAction.swift b/SampleApp/SampleApp/Underground/Actions/ToggleFavoriteAction.swift new file mode 100644 index 0000000..91f5092 --- /dev/null +++ b/SampleApp/SampleApp/Underground/Actions/ToggleFavoriteAction.swift @@ -0,0 +1,21 @@ +import Foundation +import Incremental +import Resin + +struct ToggleFavoriteAction: Action { + var station: String + + public init(station: String) { + self.station = station + } +} + +struct ToggleFavoriteReducer: Reducer { + public func reduce(action: ToggleFavoriteAction, state: inout UndergroundState) { + if state.favoriteStations.contains(action.station) { + state.favoriteStations.remove(action.station) + } else { + state.favoriteStations.insert(action.station) + } + } +} diff --git a/SampleApp/SampleApp/Underground/Station.swift b/SampleApp/SampleApp/Underground/Station.swift new file mode 100644 index 0000000..bc3d0c7 --- /dev/null +++ b/SampleApp/SampleApp/Underground/Station.swift @@ -0,0 +1,21 @@ +import Foundation + +public struct Station: Equatable { + var lines: [String] + + var name: String + + var zones: [Int] + + public init(name: String, lines: [String], zones: [Int]) { + self.name = name + self.lines = lines + self.zones = zones + } +} + +extension Station: Hashable { + public func hash(into hasher: inout Hasher) { + name.hash(into: &hasher) + } +} diff --git a/SampleApp/SampleApp/Underground/StationsState.swift b/SampleApp/SampleApp/Underground/StationsState.swift new file mode 100644 index 0000000..7f69c6d --- /dev/null +++ b/SampleApp/SampleApp/Underground/StationsState.swift @@ -0,0 +1,1488 @@ +import Foundation + +public struct StationsState: Equatable { + var stations: [Station] = [] + var lines: [String: [String]] = [:] // line name -> [Station names] +} + + +extension StationsState { + func find(byName name: String) -> Station? { + return stations.first { $0.name == name } + } + + func find(byLine line: String) -> Station? { + return stations.first { $0.lines.contains(line) } + } + + func find(inZone zone: Int) -> Station? { + return stations.first { $0.zones.contains(zone) } + } +} + +extension StationsState { + static var dummyData: StationsState { + StationsState(stations: allStations, lines: stationsByLine) + } +} + +private let stationsByLine = [ + "Bakerloo": [ + "Harrow & Wealdstone", + "Kenton", + "South Kenton", + "North Wembley", + "Wembley Central", + "Stonebridge Park", + "Harlesden", + "Willesden Junction", + "Kensal Green", + "Queen's Park", + "Kilburn Park", + "Maida Vale", + "Warwick Avenue", + "Paddington", + "Edgware Road", + "Marylebone", + "Baker Street", + "Regent's Park", + "Oxford Circus", + "Piccadilly Circus", + "Charing Cross", + "Embankment", + "Waterloo", + "Lambeth North", + "Elephant & Castle" + ], + "Central": [ + "West Ruislip", + "Ruislip Gardens", + "South Ruislip", + "Northolt", + "Greenford", + "Perivale", + "Hanger Lane", + "Ealing Broadway", + "West Acton", + "North Acton", + "East Acton", + "White City", + "Shepherd's Bush", + "Holland Park", + "Notting Hill Gate", + "Queensway", + "Lancaster Gate", + "Marble Arch", + "Bond Street", + "Oxford Circus", + "Tottenham Court Road", + "Holborn", + "Chancery Lane", + "St. Paul's", + "Bank", + "Liverpool Street", + "Bethnal Green", + "Mile End", + "Stratford", + "Leyton", + "Leytonstone", + "Wanstead", + "Redbridge", + "Gants Hill", + "Newbury Park", + "Barkingside", + "Fairlop", + "Hainault", + "Grange Hill", + "Chigwell", + "Roding Valley", + "Snaresbrook", + "South Woodford", + "Woodford", + "Buckhurst Hill", + "Loughton", + "Debden", + "Theydon Bois", + "Epping" + ], + "Hammersmith & City": [ + "Hammersmith", + "Goldhawk Road", + "Shepherd's Bush Market", + "Wood Lane", + "Latimer Road", + "Ladbroke Grove", + "Westbourne Park", + "Royal Oak", + "Paddington", + "Edgware Road", + "Baker Street", + "Great Portland Street", + "Euston Square", + "King's Cross St Pancras", + "Farringdon", + "Barbican", + "Moorgate", + "Liverpool Street", + "Aldgate East", + "Whitechapel", + "Stepney Green", + "Mile End", + "Bow Road", + "Bromley-by-Bow", + "West Ham", + "Plaistow", + "Upton Park", + "East Ham", + "Barking" + ] +] + + +private let allStations = [ + Station( + name: "Acton Town", + lines: ["District", "Piccadilly"], + zones: [3] + ), + Station( + name: "Aldgate", + lines: ["Metropolitan", "Circle"], + zones: [1] + ), + Station( + name: "Aldgate East", + lines: ["Hammersmith & City", "District"], + zones: [1] + ), + Station( + name: "Alperton", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Amersham", + lines: ["Metropolitan"], + zones: [9] + ), + Station( + name: "Angel", + lines: ["Northern"], + zones: [1] + ), + Station( + name: "Archway", + lines: ["Northern"], + zones: [2, 3] + ), + Station( + name: "Arnos Grove", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Arsenal", + lines: ["Piccadilly"], + zones: [2] + ), + Station( + name: "Baker Street", + lines: ["Metropolitan", "Bakerloo", "Circle", "Jubilee", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Balham", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Bank", + lines: ["Waterloo & City", "Northern", "Central"], + zones: [1] + ), + Station( + name: "Barbican", + lines: ["Metropolitan", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Barking", + lines: ["District", "Hammersmith & City"], + zones: [4] + ), + Station( + name: "Barkingside", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Barons Court", + lines: ["District", "Piccadilly"], + zones: [2] + ), + Station( + name: "Bayswater", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Becontree", + lines: ["District"], + zones: [5] + ), + Station( + name: "Belsize Park", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Bermondsey", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "Bethnal Green", + lines: ["Central"], + zones: [2] + ), + Station( + name: "Blackfriars", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Blackhorse Road", + lines: ["Victoria"], + zones: [3] + ), + Station( + name: "Bond Street", + lines: ["Central", "Jubilee"], + zones: [1] + ), + Station( + name: "Borough", + lines: ["Northern"], + zones: [1] + ), + Station( + name: "Boston Manor", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Bounds Green", + lines: ["Piccadilly"], + zones: [3, 4] + ), + Station( + name: "Bow Road", + lines: ["District", "Hammersmith & City"], + zones: [2] + ), + Station( + name: "Brent Cross", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Brixton", + lines: ["Victoria"], + zones: [2] + ), + Station( + name: "Bromley-by-Bow", + lines: ["District", "Hammersmith & City"], + zones: [2, 3] + ), + Station( + name: "Buckhurst Hill", + lines: ["Central"], + zones: [5] + ), + Station( + name: "Burnt Oak", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Caledonian Road", + lines: ["Piccadilly"], + zones: [2] + ), + Station( + name: "Camden Town", + lines: ["Northern"], + zones: [2] + ), + + Station( + name: "Canada Water", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "Canary Wharf", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "Canning Town", + lines: ["Jubilee"], + zones: [3] + ), + Station( + name: "Cannon Street", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Canons Park", + lines: ["Jubilee"], + zones: [5] + ), + Station( + name: "Chalfont & Latimer", + lines: ["Metropolitan"], + zones: [8] + ), + Station( + name: "Chalk Farm", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Chancery Lane", + lines: ["Central"], + zones: [1] + ), + Station( + name: "Charing Cross", + lines: ["Bakerloo", "Northern"], + zones: [1] + ), + Station( + name: "Chesham", + lines: ["Metropolitan"], + zones: [9] + ), + Station( + name: "Chigwell", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Chiswick Park", + lines: ["District"], + zones: [3] + ), + Station( + name: "Chorleywood", + lines: ["Metropolitan"], + zones: [7] + ), + Station( + name: "Clapham Common", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Clapham North", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Clapham South", + lines: ["Northern"], + zones: [2, 3] + ), + Station( + name: "Cockfosters", + lines: ["Piccadilly"], + zones: [5] + ), + Station( + name: "Colindale", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Colliers Wood", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Covent Garden", + lines: ["Piccadilly"], + zones: [1] + ), + Station( + name: "Croxley", + lines: ["Metropolitan"], + zones: [7] + ), + Station( + name: "Dagenham East", + lines: ["District"], + zones: [5] + ), + Station( + name: "Dagenham Heathway", + lines: ["District"], + zones: [5] + ), + Station( + name: "Debden", + lines: ["Central"], + zones: [6] + ), + Station( + name: "Dollis Hill", + lines: ["Jubilee"], + zones: [3] + ), + Station( + name: "Ealing Broadway", + lines: ["District", "Central"], + zones: [3] + ), + Station( + name: "Ealing Common", + lines: ["District", "Piccadilly"], + zones: [3] + ), + Station( + name: "Earl's Court", + lines: ["District", "Piccadilly"], + zones: [1, 2] + ), + Station( + name: "East Acton", + lines: ["Central"], + zones: [2] + ), + Station( + name: "East Finchley", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "East Ham", + lines: ["District", "Hammersmith & City"], + zones: [3, 4] + ), + Station( + name: "East Putney", + lines: ["District"], + zones: [2, 3] + ), + Station( + name: "Eastcote", + lines: ["Metropolitan", "Piccadilly"], + zones: [5] + ), + Station( + name: "Edgware", + lines: ["Northern"], + zones: [5] + ), + Station( + name: "Edgware Road", + lines: ["Bakerloo"], + zones: [1] + ), + Station( + name: "Edgware Road", + lines: ["Hammersmith & City", "District", "Circle"], + zones: [1] + ), + Station( + name: "Elephant & Castle", + lines: ["Northern", "Bakerloo"], + zones: [1, 2] + ), + Station( + name: "Elm Park", + lines: ["District"], + zones: [6] + ), + Station( + name: "Embankment", + lines: ["District", "Bakerloo", "Northern", "Circle"], + zones: [1] + ), + Station( + name: "Epping", + lines: ["Central"], + zones: [6] + ), + Station( + name: "Euston", + lines: ["Northern", "Victoria"], + zones: [1] + ), + Station( + name: "Euston Square", + lines: ["Metropolitan", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Fairlop", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Farringdon", + lines: ["Metropolitan", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Finchley Central", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Finchley Road", + lines: ["Metropolitan", "Jubilee"], + zones: [2] + ), + Station( + name: "Finsbury Park", + lines: ["Piccadilly", "Victoria"], + zones: [2] + ), + Station( + name: "Fulham Broadway", + lines: ["District"], + zones: [2] + ), + Station( + name: "Gants Hill", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Gloucester Road", + lines: ["District", "Piccadilly", "Circle"], + zones: [1] + ), + Station( + name: "Golders Green", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Goldhawk Road", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Goodge Street", + lines: ["Northern"], + zones: [1] + ), + Station( + name: "Grange Hill", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Great Portland Street", + lines: ["Metropolitan", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Greenford", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Green Park", + lines: ["Piccadilly", "Victoria", "Jubilee"], + zones: [1] + ), + Station( + name: "Gunnersbury", + lines: ["District"], + zones: [3] + ), + Station( + name: "Hainault", + lines: ["Central"], + zones: [4] + ), + /// These are technically two separate stations of the same name. + /// + /// - SeeAlso: https://en.wikipedia.org/wiki/Hammersmith_tube_station_(Hammersmith_%26_City_and_Circle_lines) + /// - SeeAlso: https://en.wikipedia.org/wiki/Hammersmith_tube_station_(Piccadilly_and_District_lines) + Station( + name: "Hammersmith", + lines: ["Hammersmith & City", "Circle"] + ["District", "Piccadilly"], + zones: [2] + ), + Station( + name: "Hampstead", + lines: ["Northern"], + zones: [2, 3] + ), + Station( + name: "Hanger Lane", + lines: ["Central"], + zones: [3] + ), + Station( + name: "Harlesden", + lines: ["Bakerloo"], + zones: [3] + ), + Station( + name: "Harrow & Wealdstone", + lines: ["Bakerloo"], + zones: [5] + ), + Station( + name: "Harrow-on-the-Hill", + lines: ["Metropolitan"], + zones: [5] + ), + Station( + name: "Hatton Cross", + lines: ["Piccadilly"], + zones: [5, 6] + ), + Station( + name: "Heathrow Terminals 1, 2, 3", + lines: ["Piccadilly"], + zones: [6] + ), + Station( + name: "Heathrow Terminal 4", + lines: ["Piccadilly"], + zones: [6] + ), + Station( + name: "Heathrow Terminal 5", + lines: ["Piccadilly"], + zones: [6] + ), + Station( + name: "Hendon Central", + lines: ["Northern"], + zones: [3, 4] + ), + Station( + name: "High Barnet", + lines: ["Northern"], + zones: [5] + ), + Station( + name: "Highbury & Islington", + lines: ["Victoria"], + zones: [2] + ), + Station( + name: "Highgate", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "High Street Kensington", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Hillingdon", + lines: ["Metropolitan", "Piccadilly"], + zones: [6] + ), + Station( + name: "Holborn", + lines: ["Central", "Piccadilly"], + zones: [1] + ), + Station( + name: "Holland Park", + lines: ["Central"], + zones: [2] + ), + Station( + name: "Holloway Road", + lines: ["Piccadilly"], + zones: [2] + ), + Station( + name: "Hornchurch", + lines: ["District"], + zones: [6] + ), + Station( + name: "Hounslow Central", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Hounslow East", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Hounslow West", + lines: ["Piccadilly"], + zones: [5] + ), + Station( + name: "Hyde Park Corner", + lines: ["Piccadilly"], + zones: [1] + ), + Station( + name: "Ickenham", + lines: ["Metropolitan", "Piccadilly"], + zones: [6] + ), + Station( + name: "Kennington", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Kensal Green", + lines: ["Bakerloo"], + zones: [2] + ), + Station( + name: "Kensington (Olympia)", + lines: ["District"], + zones: [2] + ), + Station( + name: "Kentish Town", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Kenton", + lines: ["Bakerloo"], + zones: [4] + ), + Station( + name: "Kew Gardens", + lines: ["District"], + zones: [3, 4] + ), + Station( + name: "Kilburn", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "Kilburn Park", + lines: ["Bakerloo"], + zones: [2] + ), + Station( + name: "Kingsbury", + lines: ["Jubilee"], + zones: [4] + ), + Station( + name: "King's Cross St. Pancras", + lines: ["Metropolitan", "Northern", "Piccadilly", "Circle", "Victoria", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Knightsbridge", + lines: ["Piccadilly"], + zones: [1] + ), + Station( + name: "Ladbroke Grove", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Lambeth North", + lines: ["Bakerloo"], + zones: [1] + ), + Station( + name: "Lancaster Gate", + lines: ["Central"], + zones: [1] + ), + Station( + name: "Latimer Road", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Leicester Square", + lines: ["Piccadilly", "Northern"], + zones: [1] + ), + Station( + name: "Leyton", + lines: ["Central"], + zones: [3] + ), + Station( + name: "Leytonstone", + lines: ["Central"], + zones: [3, 4] + ), + Station( + name: "Liverpool Street", + lines: ["Metropolitan", "Central", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "London Bridge", + lines: ["Northern", "Jubilee"], + zones: [1] + ), + Station( + name: "Loughton", + lines: ["Central"], + zones: [6] + ), + Station( + name: "Maida Vale", + lines: ["Bakerloo"], + zones: [2] + ), + Station( + name: "Manor House", + lines: ["Piccadilly"], + zones: [2, 3] + ), + Station( + name: "Mansion House", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Marble Arch", + lines: ["Central"], + zones: [1] + ), + Station( + name: "Marylebone", + lines: ["Bakerloo"], + zones: [1] + ), + Station( + name: "Mile End", + lines: ["District", "Hammersmith & City", "Central"], + zones: [2] + ), + Station( + name: "Mill Hill East", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Monument", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Moorgate", + lines: ["Metropolitan", "Northern", "Circle", "Hammersmith & City"], + zones: [1] + ), + Station( + name: "Moor Park", + lines: ["Metropolitan"], + zones: [6, 7] + ), + Station( + name: "Morden", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Mornington Crescent", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Neasden", + lines: ["Jubilee"], + zones: [3] + ), + Station( + name: "Newbury Park", + lines: ["Central"], + zones: [4] + ), + Station( + name: "North Acton", + lines: ["Central"], + zones: [2, 3] + ), + Station( + name: "North Ealing", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "North Greenwich", + lines: ["Jubilee"], + zones: [2, 3] + ), + Station( + name: "North Harrow", + lines: ["Metropolitan"], + zones: [5] + ), + Station( + name: "North Wembley", + lines: ["Bakerloo"], + zones: [4] + ), + Station( + name: "Northfields", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "Northolt", + lines: ["Central"], + zones: [5] + ), + Station( + name: "Northwick Park", + lines: ["Metropolitan"], + zones: [4] + ), + Station( + name: "Northwood", + lines: ["Metropolitan"], + zones: [6] + ), + Station( + name: "Northwood Hills", + lines: ["Metropolitan"], + zones: [6] + ), + Station( + name: "Notting Hill Gate", + lines: ["District", "Central", "Circle"], + zones: [1, 2] + ), + Station( + name: "Oakwood", + lines: ["Piccadilly"], + zones: [5] + ), + Station( + name: "Old Street", + lines: ["Northern"], + zones: [1] + ), + Station( + name: "Osterley", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Oval", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Oxford Circus", + lines: ["Central", "Bakerloo", "Victoria"], + zones: [1] + ), + Station( + name: "Paddington", + lines: ["Hammersmith & City", "District", "Circle", "Bakerloo"], + zones: [1] + ), + Station( + name: "Park Royal", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "Parsons Green", + lines: ["District"], + zones: [2] + ), + Station( + name: "Perivale", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Piccadilly Circus", + lines: ["Bakerloo", "Piccadilly"], + zones: [1] + ), + Station( + name: "Pimlico", + lines: ["Victoria"], + zones: [1] + ), + Station( + name: "Pinner", + lines: ["Metropolitan"], + zones: [5] + ), + Station( + name: "Plaistow", + lines: ["District", "Hammersmith & City"], + zones: [3] + ), + Station( + name: "Preston Road", + lines: ["Metropolitan"], + zones: [4] + ), + Station( + name: "Putney Bridge", + lines: ["District"], + zones: [2] + ), + Station( + name: "Queen's Park", + lines: ["Bakerloo"], + zones: [2] + ), + Station( + name: "Queensbury", + lines: ["Jubilee"], + zones: [4] + ), + Station( + name: "Queensway", + lines: ["Central"], + zones: [1] + ), + Station( + name: "Ravenscourt Park", + lines: ["District"], + zones: [2] + ), + Station( + name: "Rayners Lane", + lines: ["Metropolitan", "Piccadilly"], + zones: [5] + ), + Station( + name: "Redbridge", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Regent's Park", + lines: ["Bakerloo"], + zones: [1] + ), + Station( + name: "Richmond", + lines: ["District"], + zones: [4] + ), + Station( + name: "Rickmansworth", + lines: ["Metropolitan"], + zones: [7] + ), + Station( + name: "Roding Valley", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Royal Oak", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Ruislip", + lines: ["Metropolitan", "Piccadilly"], + zones: [6] + ), + Station( + name: "Ruislip Gardens", + lines: ["Central"], + zones: [5] + ), + Station( + name: "Ruislip Manor", + lines: ["Metropolitan", "Piccadilly"], + zones: [6] + ), + Station( + name: "Russell Square", + lines: ["Piccadilly"], + zones: [1] + ), + Station( + name: "St. James's Park", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "St. John's Wood", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "St. Paul's", + lines: ["Central"], + zones: [1] + ), + Station( + name: "Seven Sisters", + lines: ["Victoria"], + zones: [3] + ), + Station( + name: "Shepherd's Bush", + lines: ["Central"], + zones: [2] + ), + Station( + name: "Shepherd's Bush Market", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Sloane Square", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Snaresbrook", + lines: ["Central"], + zones: [4] + ), + Station( + name: "South Ealing", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "South Harrow", + lines: ["Piccadilly"], + zones: [5] + ), + Station( + name: "South Kensington", + lines: ["District", "Piccadilly", "Circle"], + zones: [1] + ), + Station( + name: "South Kenton", + lines: ["Bakerloo"], + zones: [4] + ), + Station( + name: "South Ruislip", + lines: ["Central"], + zones: [5] + ), + Station( + name: "South Wimbledon", + lines: ["Northern"], + zones: [3, 4] + ), + Station( + name: "South Woodford", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Southfields", + lines: ["District"], + zones: [3] + ), + Station( + name: "Southgate", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Southwark", + lines: ["Jubilee"], + zones: [1] + ), + Station( + name: "Stamford Brook", + lines: ["District"], + zones: [2] + ), + Station( + name: "Stanmore", + lines: ["Jubilee"], + zones: [5] + ), + Station( + name: "Stepney Green", + lines: ["District", "Hammersmith & City"], + zones: [2] + ), + Station( + name: "Stockwell", + lines: ["Northern", "Victoria"], + zones: [2] + ), + Station( + name: "Stonebridge Park", + lines: ["Bakerloo"], + zones: [3] + ), + Station( + name: "Stratford", + lines: ["Central", "Jubilee"], + zones: [3] + ), + Station( + name: "Sudbury Hill", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Sudbury Town", + lines: ["Piccadilly"], + zones: [4] + ), + Station( + name: "Swiss Cottage", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "Temple", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Theydon Bois", + lines: ["Central"], + zones: [6] + ), + Station( + name: "Tooting Bec", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Tooting Broadway", + lines: ["Northern"], + zones: [3] + ), + Station( + name: "Tottenham Court Road", + lines: ["Central", "Northern"], + zones: [1] + ), + Station( + name: "Tottenham Hale", + lines: ["Victoria"], + zones: [3] + ), + Station( + name: "Totteridge & Whetstone", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "Tower Hill", + lines: ["District", "Circle"], + zones: [1] + ), + Station( + name: "Tufnell Park", + lines: ["Northern"], + zones: [2] + ), + Station( + name: "Turnham Green", + lines: ["District", "Piccadilly"], + zones: [2, 3] + ), + Station( + name: "Turnpike Lane", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "Upminster", + lines: ["District"], + zones: [6] + ), + Station( + name: "Upminster Bridge", + lines: ["District"], + zones: [6] + ), + Station( + name: "Upney", + lines: ["District"], + zones: [4] + ), + Station( + name: "Upton Park", + lines: ["District", "Hammersmith & City"], + zones: [3] + ), + Station( + name: "Uxbridge", + lines: ["Metropolitan", "Piccadilly"], + zones: [6] + ), + Station( + name: "Vauxhall", + lines: ["Victoria"], + zones: [1, 2] + ), + Station( + name: "Victoria", + lines: ["District", "Circle", "Victoria"], + zones: [1] + ), + Station( + name: "Walthamstow Central", + lines: ["Victoria"], + zones: [3] + ), + Station( + name: "Wanstead", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Warren Street", + lines: ["Northern", "Victoria"], + zones: [1] + ), + Station( + name: "Warwick Avenue", + lines: ["Bakerloo"], + zones: [2] + ), + Station( + name: "Waterloo", + lines: ["Waterloo & City", "Bakerloo", "Northern", "Jubilee"], + zones: [1] + ), + Station( + name: "Watford", + lines: ["Metropolitan"], + zones: [7] + ), + Station( + name: "Wembley Central", + lines: ["Bakerloo"], + zones: [4] + ), + Station( + name: "Wembley Park", + lines: ["Metropolitan", "Jubilee"], + zones: [4] + ), + Station( + name: "West Acton", + lines: ["Central"], + zones: [3] + ), + Station( + name: "West Brompton", + lines: ["District"], + zones: [2] + ), + Station( + name: "West Finchley", + lines: ["Northern"], + zones: [4] + ), + Station( + name: "West Ham", + lines: ["District", "Hammersmith & City", "Jubilee"], + zones: [3] + ), + Station( + name: "West Hampstead", + lines: ["Jubilee"], + zones: [2] + ), + Station( + name: "West Harrow", + lines: ["Metropolitan"], + zones: [5] + ), + Station( + name: "West Kensington", + lines: ["District"], + zones: [2] + ), + Station( + name: "West Ruislip", + lines: ["Central"], + zones: [6] + ), + Station( + name: "Westbourne Park", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Westminster", + lines: ["District", "Circle", "Jubilee"], + zones: [1] + ), + Station( + name: "White City", + lines: ["Central"], + zones: [2] + ), + Station( + name: "Whitechapel", + lines: ["District", "Hammersmith & City"], + zones: [2] + ), + Station( + name: "Willesden Green", + lines: ["Jubilee"], + zones: [2, 3] + ), + Station( + name: "Willesden Junction", + lines: ["Bakerloo"], + zones: [2, 3] + ), + Station( + name: "Wimbledon", + lines: ["District"], + zones: [3] + ), + Station( + name: "Wimbledon Park", + lines: ["District"], + zones: [3] + ), + Station( + name: "Wood Green", + lines: ["Piccadilly"], + zones: [3] + ), + Station( + name: "Wood Lane", + lines: ["Hammersmith & City", "Circle"], + zones: [2] + ), + Station( + name: "Woodford", + lines: ["Central"], + zones: [4] + ), + Station( + name: "Woodside Park", + lines: ["Northern"], + zones: [4] + ) +] diff --git a/SampleApp/SampleApp/Underground/UndergroundParcel.swift b/SampleApp/SampleApp/Underground/UndergroundParcel.swift new file mode 100644 index 0000000..2be8a9c --- /dev/null +++ b/SampleApp/SampleApp/Underground/UndergroundParcel.swift @@ -0,0 +1,35 @@ +import Incremental +import Resin +import SafariServices +import UIKit + +public typealias Environment = ( + userDefaults: UserDefaults, + loadStations: (@escaping (Result) -> Void) -> Void +) + +public func makeUndergroundParcel(environment: Environment) -> Parcel { + return Parcel { store in + var result = Parcel.Delivery() + + result.presentationRoutes = [ + LineListViewController.presentationRoute.unfocused(by: \.stationsState).typeErased, + StationDetailViewController.presentationRoute.typeErased, + StationListViewController.presentationRoute.typeErased, + StationListViewController.navigationPresentationRoute.typeErased, + ] + + result.middlewares = [ + LoadStationsMiddleware(loadStations: environment.loadStations), + SyncFavoritesMiddleware(favoritesStations: store.state[\.favoriteStations], userDefaults: environment.userDefaults), + ] + + result.reducers = [ + SetStationsReducer().typeErased, + ToggleFavoriteReducer().typeErased, + SyncFavoritesReducer().typeErased, + ] + + return result + } +} diff --git a/SampleApp/SampleApp/Underground/UndergroundState.swift b/SampleApp/SampleApp/Underground/UndergroundState.swift new file mode 100644 index 0000000..89b6e2f --- /dev/null +++ b/SampleApp/SampleApp/Underground/UndergroundState.swift @@ -0,0 +1,18 @@ +import Foundation +import Resin + +public struct UndergroundState: Equatable { + var navigationStack: [AnyPresentationIdentifier] = [] + + var favoriteStations: Set = [] + + var stationsState = StationsState() + var stationStateRequest: RequestState = .none +} + +enum RequestState: Equatable { + case none + case loading + case failure + case success +} diff --git a/SampleApp/SampleApp/Underground/ViewControllers/LineListViewController.swift b/SampleApp/SampleApp/Underground/ViewControllers/LineListViewController.swift new file mode 100644 index 0000000..6f11b3e --- /dev/null +++ b/SampleApp/SampleApp/Underground/ViewControllers/LineListViewController.swift @@ -0,0 +1,105 @@ +import Foundation +import Incremental +import Resin +import UIKit + +class LineListViewController: UITableViewController { + let viewModel: I + + private var dataSource: DataSource! + + init(viewModel: I) { + self.viewModel = viewModel + super.init(style: .grouped) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + + dataSource = DataSource(tableView: tableView) + tableView.dataSource = dataSource + tableView.delegate = dataSource + + whileVisible.do { (owner: LineListViewController) in + [ + owner.bind(keyPath: \.title, to: owner.viewModel[\.line]), + owner.bind(keyPath: \.dataSource.stations, to: owner.viewModel[\.stations]), + ] + } + } + +} + +extension LineListViewController { + class DataSource: NSObject, UITableViewDataSource, UITableViewDelegate { + var stations: [String] = [] { + didSet { + tableView?.reloadData() + } + } + + weak var tableView: UITableView? + + init(tableView: UITableView) { + self.tableView = tableView + super.init() + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return stations.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.accessoryType = .disclosureIndicator + cell.textLabel?.text = stations[indexPath.row] + + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let name = stations[indexPath.row] + let identifier = StationDetailViewController.PresentationIdentifier(name: name) + + tableView.dispatch(ImplicitPresentationAction.push(identifier)) + } + } +} + +extension LineListViewController { + struct ViewModel: Resin.ViewModel, DefaultInit { + var line: String = "" + var stations: [String] = [] + var isStale = false + } +} + +extension LineListViewController { + struct PresentationIdentifier: Resin.PresentationIdentifier { + public var line: String + + public init(line: String) { + self.line = line + } + } + + static var presentationRoute: PresentationRoute { + return PresentationRoute { identifier, store in + let vm = store.state.map { stationState -> ViewModel in + let stations = stationState.lines[identifier.line] ?? [] + + return ViewModel( + line: identifier.line, + stations: stations + ) + } + + return LineListViewController(viewModel: vm) + } + } +} diff --git a/SampleApp/SampleApp/Underground/ViewControllers/StationListViewController.swift b/SampleApp/SampleApp/Underground/ViewControllers/StationListViewController.swift new file mode 100644 index 0000000..c5aa28b --- /dev/null +++ b/SampleApp/SampleApp/Underground/ViewControllers/StationListViewController.swift @@ -0,0 +1,118 @@ +import Foundation +import Incremental +import Resin +import UIKit + +class StationListViewController: UITableViewController { + let viewModel: I + + private var dataSource: DataSource! + + init(viewModel: I) { + self.viewModel = viewModel + super.init(style: .grouped) + + title = "All Stations" + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + + dataSource = DataSource(tableView: tableView) + tableView.dataSource = dataSource + tableView.delegate = dataSource + + whileVisible.do { (owner: StationListViewController) in + [ + owner.bind(keyPath: \.dataSource.stations, to: owner.viewModel[\.stations]), + ] + } + } +} + +extension StationListViewController { + class DataSource: NSObject, UITableViewDataSource, UITableViewDelegate { + var stations: [Station] = [] { + didSet { + tableView?.reloadData() + } + } + + weak var tableView: UITableView? + + init(tableView: UITableView) { + self.tableView = tableView + super.init() + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return stations.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.accessoryType = .disclosureIndicator + cell.textLabel?.text = stations[indexPath.row].name + + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let name = stations[indexPath.row].name + + let identifier = StationDetailViewController.PresentationIdentifier(name: name) + tableView.dispatch(ImplicitPresentationAction.push(identifier)) + } + } +} + +extension StationListViewController { + struct ViewModel: Resin.ViewModel, DefaultInit { + var stations: [Station] = [] + var isLoading = false + var isStale = false + } +} + + + +extension StationListViewController { + // To Present the VC individually + struct PresentationIdentifier: Resin.PresentationIdentifier { + public init() {} + } + + static var presentationRoute: PresentationRoute { + return PresentationRoute { _, store in + + let vm = store.state.map { undergroundState -> ViewModel in + ViewModel( + stations: undergroundState.stationsState.stations, + isLoading: undergroundState.stationStateRequest == .loading + ) + } + return StationListViewController(viewModel: vm) + } + } +} + +extension StationListViewController { + // to present he VC in a UINavigationController bound to the state + struct NavigationPresentationIdentifier: Resin.PresentationIdentifier { + } + + static var navigationPresentationRoute: PresentationRoute { + return PresentationRoute { _, store in + UINavigationController( + store: store, + root: StationListViewController.PresentationIdentifier().typeErased, + navigationStack: \.navigationStack + ) + } + } +} diff --git a/SampleApp/SampleApp/Underground/ViewControllers/StationsDetailsViewController.swift b/SampleApp/SampleApp/Underground/ViewControllers/StationsDetailsViewController.swift new file mode 100644 index 0000000..08c6c5a --- /dev/null +++ b/SampleApp/SampleApp/Underground/ViewControllers/StationsDetailsViewController.swift @@ -0,0 +1,183 @@ +import Foundation +import Incremental +import Resin +import SafariServices +import UIKit + +class StationDetailViewController: UITableViewController { + let viewModel: I + + private var dataSource: DataSource! + + init(viewModel: I) { + self.viewModel = viewModel + super.init(style: .grouped) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + + dataSource = DataSource(tableView: tableView) + tableView.dataSource = dataSource + tableView.delegate = dataSource + + whileVisible.do { (owner: StationDetailViewController) in + [ + owner.bind(keyPath: \.title, to: owner.viewModel[\.station.name]), + owner.bind(keyPath: \.dataSource.viewModel, to: owner.viewModel), + ] + } + } +} + +extension StationDetailViewController { + class DataSource: NSObject, UITableViewDataSource, UITableViewDelegate { + var viewModel = StationDetailViewController.ViewModel() { + didSet { + tableView?.reloadData() + } + } + + weak var tableView: UITableView? + + init(tableView: UITableView) { + self.tableView = tableView + super.init() + } + + func numberOfSections(in tableView: UITableView) -> Int { + return 4 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch section { + case 0: + return 2 + case 1: + return viewModel.station.lines.count + case 2: + return 1 + case 3: + return 1 + default: + fatalError() + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: UITableViewCell + + switch (indexPath.section, indexPath.row) { + case (0, 0): + cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.textLabel?.text = "Name" + cell.detailTextLabel?.text = viewModel.station.name + + case (0, 1): + cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.textLabel?.text = "Zones" + cell.detailTextLabel?.text = viewModel.station.zones.map(String.init).joined(separator: " & ") + + case (1, let line): + cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.accessoryType = .disclosureIndicator + cell.textLabel?.text = viewModel.station.lines[line] + + case (2, 0): + let `switch` = UISwitch() + `switch`.setOn(viewModel.isFavorite, animated: false) + `switch`.on(.valueChanged, dispatch: ToggleFavoriteAction(station: viewModel.station.name)) + + cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.accessoryView = `switch` + cell.textLabel?.text = "Favorite" + + case (3, 0): + cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.accessoryType = .disclosureIndicator + cell.textLabel?.text = "Wikipedia Article" + default: + fatalError() + } + + return cell + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch section { + case 0, 2, 3: + return nil + case 1: + return "Lines" + default: + fatalError() + } + } + + func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + return indexPath.section == 1 || indexPath.section == 3 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch (indexPath.section, indexPath.row) { + case (1, let line): + let identifier = LineListViewController.PresentationIdentifier(line: viewModel.station.lines[line]) + + tableView.dispatch(ImplicitPresentationAction.push(identifier)) + case (3, 0): + let identifier = SFSafariViewController.PresentationIdentifier(url: URL(string: "http://example.org")!) + + tableView.dispatch(ImplicitPresentationAction.push(identifier)) + default: + fatalError() + } + } + } +} + +extension StationDetailViewController { + struct ViewModel: Resin.ViewModel, DefaultInit { + var station: Station = Station(name: "", lines: [], zones: []) + var isFavorite = false + var isStale = false + } +} + +extension StationDetailViewController { + struct PresentationIdentifier: Resin.PresentationIdentifier { + public var name: String + + public init(name: String) { + self.name = name + } + } + + static var presentationRoute: PresentationRoute { + return PresentationRoute { identifier, store in + let viewModel = store.state + .mapWithFallback { + StationDetailViewController.ViewModel(stationName: identifier.name, state: $0) + } + + return StationDetailViewController(viewModel: viewModel) + } + } +} + +extension StationDetailViewController.ViewModel { + init?(stationName: String, state: UndergroundState) { + guard let station = state.stationsState.find(byName: stationName) else { + return nil + } + + self.init( + station: station, + isFavorite: state.favoriteStations.contains(stationName) + ) + } +} diff --git a/SampleApp/SampleApp/main.swift b/SampleApp/SampleApp/main.swift new file mode 100644 index 0000000..4246795 --- /dev/null +++ b/SampleApp/SampleApp/main.swift @@ -0,0 +1,4 @@ +import Foundation +import UIKit + +UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, NSStringFromClass(UIApplication.self), NSStringFromClass(AppDelegate.self)) diff --git a/Sources/Incremental/Incremental+NSObject.swift b/Sources/Incremental/Incremental+NSObject.swift index 113a975..4c6f4ca 100644 --- a/Sources/Incremental/Incremental+NSObject.swift +++ b/Sources/Incremental/Incremental+NSObject.swift @@ -66,9 +66,15 @@ extension IBox where V: NSObject { @available(macOS 10, iOS 10, *) extension NSObjectProtocol where Self: NSObject { /// One-way binding - public func bind(keyPath: ReferenceWritableKeyPath, _ i: I) -> Disposable { - return i.observe { - self[keyPath: keyPath] = $0 + public func bind(keyPath: ReferenceWritableKeyPath, to i: I) -> Disposable { + return i.observe { [weak self] in + self?[keyPath: keyPath] = $0 + } + } + + public func bind(keyPath: ReferenceWritableKeyPath, to i: I) -> Disposable { + return i.observe { [weak self] in + self?[keyPath: keyPath] = $0 } } } diff --git a/Sources/Incremental/Incremental.swift b/Sources/Incremental/Incremental.swift index e19a89c..9a93932 100644 --- a/Sources/Incremental/Incremental.swift +++ b/Sources/Incremental/Incremental.swift @@ -304,6 +304,18 @@ public final class I: AnyI, Node { result.strongReferences.add(addReader(reader)) return result } + + public func reduce(eq: @escaping (B,B) -> Bool, _ initial: B, _ transform: @escaping (B, A) -> B) -> I { + var previous = initial + + return map(eq: eq) { value in + let result = transform(previous, value) + + defer { previous = result } + + return result + } + } func mutate(_ transform: (inout A) -> ()) { var newValue = value! @@ -322,6 +334,10 @@ extension I { return flatMap(eq: ==, transform) } + public func reduce(_ initial: B, _ transform: @escaping (B, A) -> B) -> I { + return reduce(eq: ==, initial, transform) + } + public func zip2(_ other: I, _ with: @escaping (A,B) -> C) -> I { return flatMap { value in other.map { with(value, $0) } } } diff --git a/Sources/Incremental/Info.plist b/Sources/Incremental/Info.plist new file mode 100644 index 0000000..4a4ab57 --- /dev/null +++ b/Sources/Incremental/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSHumanReadableCopyright + Copyright © 2017 objc.io. All rights reserved. + NSPrincipalClass + + + diff --git a/Sources/Resin/Action.swift b/Sources/Resin/Action.swift new file mode 100644 index 0000000..1378873 --- /dev/null +++ b/Sources/Resin/Action.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Value types conforming to the `Action` protocol are used to perform changes +/// to the `Store`. +/// +/// In order to make use of an `Action`, you need to either write a `Middleware` +/// or a `Reducer` and register them with the `Store` you send this `Action` to. +public protocol Action { } + +/// Actions that need to be executed and perform changes in the store immediately +/// (in the same runloop as they are dispatched) must conform to this protocol. +/// +/// This should *only* be used to change state in response to system events, +/// such as a `UIApplicationWillEnterForegroundNotification` or a +/// `UINavigationController` delegate callback +public protocol SystemResponseAction: Action { } diff --git a/Sources/Resin/ActionContext.swift b/Sources/Resin/ActionContext.swift new file mode 100644 index 0000000..499b460 --- /dev/null +++ b/Sources/Resin/ActionContext.swift @@ -0,0 +1,18 @@ +import UIKit + +public struct ActionContext { + public var file: String + + public var line: Int + + public var view: UIView? + + public var viewController: UIViewController? + + public init(file: String, line: Int, view: UIView? = nil, viewController: UIViewController? = nil) { + self.file = file + self.line = line + self.view = view + self.viewController = viewController + } +} diff --git a/Sources/Resin/ActionHandler.swift b/Sources/Resin/ActionHandler.swift new file mode 100644 index 0000000..3d6b0b5 --- /dev/null +++ b/Sources/Resin/ActionHandler.swift @@ -0,0 +1,36 @@ +import Foundation + +public protocol ActionHandler: class { + /// Handles an action. + /// + /// - Important: You should call `dispatch` instead, this method only exists + /// since we can't pass default arguments to `file` and `line`. + func handle(_ action: Action, context: ActionContext) +} + +public extension ActionHandler { + func dispatch(_ action: Action, file: String = #file, line: Int = #line) { + dispatch(action, context: ActionContext(file: file, line: line)) + } + + /// Dispatches an action. + func dispatch(_ action: Action, context: ActionContext) { + DispatchQueue.main.async { + self.handle(action, context: context) + } + } + + func dispatchWithSynchronousExecution(_ action: SystemResponseAction, file: String = #file, line: Int = #line) { + dispatchWithSynchronousExecution(action, context: ActionContext(file: file, line: line)) + } + + /// Dispatches a `SystemResponseAction` with the intent of being executed + /// in the same run loop in which it was called. + /// + /// Must be called on the main thread. + func dispatchWithSynchronousExecution(_ action: SystemResponseAction, context: ActionContext) { + dispatchPrecondition(condition: .onQueue(.main)) + + handle(action, context: context) + } +} diff --git a/Sources/Resin/ActionTarget.swift b/Sources/Resin/ActionTarget.swift new file mode 100644 index 0000000..f6db7bf --- /dev/null +++ b/Sources/Resin/ActionTarget.swift @@ -0,0 +1,17 @@ +import Foundation + +final class ActionTarget: NSObject { + let closure: (AnyObject) -> Void + + unowned let owner: AnyObject + + init(owner: AnyObject, closure: @escaping (AnyObject) -> Void) { + self.closure = closure + self.owner = owner + } + + @objc + func handle() { + closure(owner) + } +} diff --git a/Sources/Resin/AnyMiddleware+Optics.swift b/Sources/Resin/AnyMiddleware+Optics.swift new file mode 100644 index 0000000..c5cd2a5 --- /dev/null +++ b/Sources/Resin/AnyMiddleware+Optics.swift @@ -0,0 +1,95 @@ +import Foundation + +/// This method create a new Middleware for a state that contains the +/// state of the receiver. +/// +/// This allows us to write `Middleware` focused on most specific state +/// they need to know about and defer the integration into the `Superstate`. + +/// - Parameters: +/// - by: A `WritableKeyPath` that maps from the superstate to the state +/// of the receiver. +extension AnyMiddleware { + public func unfocused(by keyPath: KeyPath) -> AnyMiddleware { + return ProxyMiddleware(keyPath: keyPath, proxied: self) + } +} + +extension AnyMiddleware where State == ArbitraryState { + public func unfocused(to superState: Superstate.Type = Superstate.self) -> AnyMiddleware { + return unfocused(by: ArbitraryState.arbitraryKeyPath()) + } +} + +private final class ProxyMiddleware: AnyMiddleware { + override var actionHandler: ActionHandler? { + get { + return proxied.actionHandler + } + set { + proxied.actionHandler = newValue + } + } + + let keyPath: KeyPath + + let proxied: AnyMiddleware + + init(keyPath: KeyPath, proxied: AnyMiddleware) { + self.keyPath = keyPath + self.proxied = proxied + } + + override public func transform(action: Resin.Action, context: ActionContext, state: Superstate) -> Resin.Action? { + return proxied.transform(action: action, context: context, state: state[keyPath: keyPath]) + } +} + +/// this unfocus a non-optional middleware into an optional one, only performing the transform if the state is non-nil +extension Middleware { + public func optional(behavior: OptionalMiddlwareBehavior) -> Middleware { + return OptionalProxyMiddleware(behavior: behavior, proxied: self) + } +} + +/// This defines a middleware behavior when the optional `State` is nil. +/// - `consume`: the middleware "consumes" the action and returns nil, other middlewares or reducers will NOT be able to act on it +/// - `passthrough`: the middleware returns the action untouched, so that it can be handled by other middlewares and reducers +public enum OptionalMiddlwareBehavior { + case consume + case passthrough +} + +private final class OptionalProxyMiddleware: Middleware { + override var actionHandler: ActionHandler? { + get { + return proxied.actionHandler + } + set { + proxied.actionHandler = newValue + } + } + + let proxied: AnyMiddleware + let behavior: OptionalMiddlwareBehavior + + init(behavior: OptionalMiddlwareBehavior, proxied: AnyMiddleware) { + self.behavior = behavior + self.proxied = proxied + } + + override public func transform(action: Action, context: ActionContext, state: State?) -> Resin.Action? { + guard let actualState = state else { + switch behavior { + case .consume: + print("\(type(of: proxied)) optional middleware : state(\(type(of: state))) is nil => consuming \(type(of: action))") + return nil + case .passthrough: + print("\(type(of: proxied)) optional middleware : state(\(type(of: state))) is nil => letting \(type(of: action)) pass through") + return action + } + } + + return proxied.transform(action: action, context: context, state: actualState) + } +} diff --git a/Sources/Resin/AnyReducer+Optics.swift b/Sources/Resin/AnyReducer+Optics.swift new file mode 100644 index 0000000..afd3523 --- /dev/null +++ b/Sources/Resin/AnyReducer+Optics.swift @@ -0,0 +1,37 @@ +import Foundation + +extension AnyReducer { + /// These methods create a new Reducer for a state that contains the + /// state of the receiver. + /// + /// This allows us to write `Reducer` focused on most specific state + /// they need to know about and defer the integration into the `Superstate`. + + /// - Parameters: + /// - by: A `WritableKeyPath` that maps from the superstate to the state + /// of the receiver. + public func unfocused(by keyPath: WritableKeyPath) -> AnyReducer { + return AnyReducer(actionIdentifier: actionIdentifier) { [perform] action, superstate in + var property = superstate[keyPath: keyPath] + + perform(action, &property) + + superstate[keyPath: keyPath] = property + } + } +} + +extension AnyReducer { + /// this unfocus a non-optional reducer into an optional one, only applying the action if the state is non-nil + public func optional() -> AnyReducer { + return AnyReducer(actionIdentifier: actionIdentifier) { [perform] action, optionalState in + guard var state = optionalState else { + print("\(type(of: self)) optional reducer : state \(type(of: optionalState)) is nil => not running") + return + } + + perform(action, &state) + optionalState = state + } + } +} diff --git a/Sources/Resin/ArbitraryState.swift b/Sources/Resin/ArbitraryState.swift new file mode 100644 index 0000000..6c97888 --- /dev/null +++ b/Sources/Resin/ArbitraryState.swift @@ -0,0 +1,25 @@ +import Foundation + +/// A placeholder state that can be derived from any `Equatable`. +/// +/// Used for `PresentationRoute`s that don't require an explicit state. +public struct ArbitraryState: Equatable, DefaultInit { + public init() { } + + public static func arbitraryKeyPath() -> WritableKeyPath { + return \S.arbitraryState + } + + public static func == (lhs: ArbitraryState, rhs: ArbitraryState) -> Bool { + return true + } +} + +private extension Equatable { + var arbitraryState: ArbitraryState { + get { + return ArbitraryState() + } + set {} + } +} diff --git a/Sources/Resin/Daemon.swift b/Sources/Resin/Daemon.swift new file mode 100644 index 0000000..ff103a8 --- /dev/null +++ b/Sources/Resin/Daemon.swift @@ -0,0 +1,41 @@ +import Foundation +import Incremental + +/// A `Daemon` offers a way to observe `State` instead of catching an `Action` +/// +/// Usually used for background processing, such as ensuring some outside system remains +/// in sync with `State`. +open class Daemon: AnyMiddleware { + private var disposable: Disposable? + + private let state: I + + public init(state: I) { + self.state = state + + super.init() + } + + open func observe(state: I) -> Disposable? { + return nil + } + + override public var actionHandler: ActionHandler? { + willSet { + stop() + } + didSet { + guard actionHandler != nil else { return } + + start() + } + } + + open func start() { + disposable = observe(state: state) + } + + open func stop() { + disposable = nil + } +} diff --git a/Sources/Resin/DefaultInit.swift b/Sources/Resin/DefaultInit.swift new file mode 100644 index 0000000..d97fd82 --- /dev/null +++ b/Sources/Resin/DefaultInit.swift @@ -0,0 +1,5 @@ +import Foundation + +public protocol DefaultInit { + init() +} diff --git a/Sources/Resin/Disposable+Composition.swift b/Sources/Resin/Disposable+Composition.swift new file mode 100644 index 0000000..630a0c9 --- /dev/null +++ b/Sources/Resin/Disposable+Composition.swift @@ -0,0 +1,12 @@ +import Incremental + +extension Disposable: ExpressibleByArrayLiteral { + public convenience init(arrayLiteral elements: Disposable...) { + var disposables = elements + + self.init { + disposables = [] + _ = disposables + } + } +} diff --git a/Sources/Resin/Equatable+IdentityKeyPath.swift b/Sources/Resin/Equatable+IdentityKeyPath.swift new file mode 100644 index 0000000..3c276ab --- /dev/null +++ b/Sources/Resin/Equatable+IdentityKeyPath.swift @@ -0,0 +1,13 @@ +internal extension Equatable { + /// Used to form an identity key path. + /// + /// - SeeAlso: https://forums.swift.org/t/some-small-keypath-extensions-identity-and-tuple-components/13729 + var keyPathSelf: Self { + get { + return self + } + set { + self = newValue + } + } +} diff --git a/Sources/Resin/I+ViewModel.swift b/Sources/Resin/I+ViewModel.swift new file mode 100644 index 0000000..797c1b4 --- /dev/null +++ b/Sources/Resin/I+ViewModel.swift @@ -0,0 +1,25 @@ +import UIKit +import Incremental + +extension I { + /// Helper to map a state `I` to an `I`. + /// + /// The `transform` generates an optional result, in which case the result + /// will be generated using the `ViewModel.fallback(previous:current:)` + /// method. + /// + /// By default this will return a `ViewModel` with `isStale` set whenever + /// the transform returns `nil`. If there was a previous value, that one + /// will be returned (as stale), or if no value ever existed, `.empty` will + /// be returned as stale. + /// + /// A `ViewModel` can override the `ViewModel.fallback(previous:current:)` + /// method to provide different behaviour. + public func mapWithFallback(_ transform: @escaping (A) -> B?) -> I { + return map(eq: ==, transform).withFallback() + } + + public func withFallback() -> I where A == V?, V: ViewModel { + return self.reduce(V.empty, V.fallback) + } +} diff --git a/Sources/Resin/IdentifiableByType.swift b/Sources/Resin/IdentifiableByType.swift new file mode 100644 index 0000000..a332b10 --- /dev/null +++ b/Sources/Resin/IdentifiableByType.swift @@ -0,0 +1,34 @@ +/// A value-type that can be used as a key when mapping types to values in a +/// dictionary. +internal struct TypeIdentifier: Hashable, RawRepresentable, CustomStringConvertible { + public let rawValue: ObjectIdentifier + #if DEBUG + public let name: String + #endif + + public init(value: Any) { + self.init(type: type(of: value)) + } + + public init(type: Any.Type) { + self.rawValue = ObjectIdentifier(Swift.type(of: type)) + #if DEBUG + self.name = Mirror(reflecting: type).description + #endif + } + + public init?(rawValue: ObjectIdentifier) { + self.rawValue = rawValue + #if DEBUG + self.name = rawValue.debugDescription + #endif + } + + public var description: String { + #if DEBUG + return "TypeIdentifier(\(name)-\(rawValue.debugDescription))" + #else + return "TypeIdentifier(\(rawValue.debugDescription))" + #endif + } +} diff --git a/Sources/Resin/ImplicitPresentationAction.swift b/Sources/Resin/ImplicitPresentationAction.swift new file mode 100644 index 0000000..d8fbe16 --- /dev/null +++ b/Sources/Resin/ImplicitPresentationAction.swift @@ -0,0 +1,98 @@ +import Incremental + +public struct ImplicitPresentationAction: Action { + enum Presentation { + case push(AnyPresentationIdentifier) + case pop + case popSpecific(AnyPresentationIdentifier) + + @available(*, deprecated, message: "This will be removed in the future.") + case replaceTop(AnyPresentationIdentifier) + } + + var presentation: Presentation + + public static func pop(_ identifier: N? = nil) -> ImplicitPresentationAction { + return pop(identifier?.typeErased) + } + + public static func pop(_ identifier: AnyPresentationIdentifier? = nil) -> ImplicitPresentationAction { + return ImplicitPresentationAction(presentation: identifier.map(Presentation.popSpecific) ?? .pop) + } + + public static func push(_ identifier: N) -> ImplicitPresentationAction { + return push(identifier.typeErased) + } + + public static func push(_ identifier: AnyPresentationIdentifier) -> ImplicitPresentationAction { + return ImplicitPresentationAction(presentation: .push(identifier)) + } + + @available(*, deprecated, message: "This will be removed in the future.") + public static func replaceTop(_ identifier: N) -> ImplicitPresentationAction { + return replaceTop(identifier.typeErased) + } + + @available(*, deprecated, message: "This will be removed in the future.") + public static func replaceTop(_ identifier: AnyPresentationIdentifier) -> ImplicitPresentationAction { + return ImplicitPresentationAction(presentation: .replaceTop(identifier)) + } +} + +internal final class ImplicitPresentationMiddleware: Middleware { + override func transform(action: ImplicitPresentationAction, context: ActionContext, state: ArbitraryState) -> Action? { + guard let viewController = context.viewController else { + fatalError("Action \(action) was not dispatched from a view controller context in \(context.file):\(context.line).") + } + + // TODO: We should inject the presentation router here through whatever + // dependency mechanism we come up with. + let presentationRouter = viewController.presentationRouter! + + let requiresModalPresentation: Bool + + switch action.presentation { + case .pop: + requiresModalPresentation = type(of: viewController).requiresModalPresentation + case .popSpecific(let identifier): + requiresModalPresentation = presentationRouter.requiresModalPresentation(identifier: identifier) + case .push(let identifier): + requiresModalPresentation = presentationRouter.requiresModalPresentation(identifier: identifier) + case .replaceTop(let identifier): + requiresModalPresentation = presentationRouter.requiresModalPresentation(identifier: identifier) + } + + /// TODO: This mechanism shouldn't use the navigation controller but + /// instead use something like + // `targetViewController(forAction:sender:)`. + let presentationKeyPath = requiresModalPresentation ? nil : viewController.navigationController?.navigationIdentifiersKeyPath + + guard let targetKeyPath = presentationKeyPath ?? viewController.resolvedPresentationKeyPath else { + return nil + } + + let presentationAction: PresentationAction + + switch action.presentation { + case .pop: + presentationAction = PresentationAction(popFrom: targetKeyPath) + case .popSpecific(let identifier): + presentationAction = PresentationAction(popFrom: targetKeyPath, identifier: identifier) + case .push(let identifier): + presentationAction = PresentationAction(push: identifier, onto: targetKeyPath) + case .replaceTop(let identifier): + presentationAction = PresentationAction(replaceTopWith: identifier, onto: targetKeyPath) + } + + dispatch(presentationAction, context: context) + + return nil + } +} + +public extension RootStore { + /// TODO: We should probably just install this by default. + func addImplicitNavigationMiddleware() { + add(middleware: ImplicitPresentationMiddleware().unfocused()) + } +} diff --git a/Sources/Resin/Lifecycle.swift b/Sources/Resin/Lifecycle.swift new file mode 100644 index 0000000..24fb6eb --- /dev/null +++ b/Sources/Resin/Lifecycle.swift @@ -0,0 +1,78 @@ +import Foundation +import Incremental + +/// A `Lifecycle` can be used to manage bindings on an object that should be +/// limited to certain parts of its life-cycle. +/// +/// For example, most view bindings only need to be active while a view +/// controller is visible. A `Lifecycle` can be used to automatically create +/// the necessary bindings when the view appears and to dispose of them when the +/// view disappears. +/// +/// It is parameterized by `OwnerType`, which is the type of object the +/// lifecycle belongs to. +public final class Lifecycle { + internal var underlying: AnyLifecycle + + /// Initializes the Lifecycle with a type-erased `AnyLifecycle`. + /// + /// You need to make sure that the owner of the underlying `AnyLifecycle` + /// is type-compatible with `OwnerType`. + public init(underlying: AnyLifecycle) { + self.underlying = underlying + } + + /// Adds a binding to the Lifecycle. + /// + /// The binding will be executed whenever the Lifecycle is running or + /// immediately if that is already the case. + public func `do`(closure: @escaping (OwnerType) -> Disposable) { + underlying.add { owner in + [closure(owner as! OwnerType)] + } + } +} + +/// A type-erased Lifecycle. +public final class AnyLifecycle { + internal var closures: [(AnyObject) -> [Disposable]] = [] + + internal var disposables: [Disposable] = [] + + internal var isRunning: Bool = false + + internal unowned let owner: AnyObject + + /// Initializes the `AnyLifecycle` with an object it will keep an unowned + /// reference to. + public init(owner: AnyObject) { + self.owner = owner + } + + internal func add(closure: @escaping (AnyObject) -> [Disposable]) { + closures.append(closure) + + if isRunning { + disposables.append(contentsOf: closure(owner)) + } + } + + /// Starts the lifecycle, creating the bindings if necessary. + public func start() { + guard !isRunning else { return } + + disposables = closures.flatMap { $0(owner) } + + isRunning = true + } + + /// Stops the lifecycle, disposing of all `Disposable`s that were created + /// as necessary. + public func stop() { + guard isRunning else { return } + + disposables = [] + + isRunning = false + } +} diff --git a/Sources/Resin/Middleware.swift b/Sources/Resin/Middleware.swift new file mode 100644 index 0000000..cb4d7cd --- /dev/null +++ b/Sources/Resin/Middleware.swift @@ -0,0 +1,36 @@ +import Foundation +import Incremental + +open class AnyMiddleware { + open weak var actionHandler: ActionHandler? + + open func transform(action: Action, context: ActionContext, state: State) -> Action? { + return action + } + + public init() {} + + public func dispatch(_ action: Action, file: String = #file, line: Int = #line) { + let context = ActionContext(file: file, line: line) + + dispatch(action, context: context) + } + + public func dispatch(_ action: Action, context: ActionContext) { + actionHandler!.dispatch(action, context: context) + } +} + +open class Middleware: AnyMiddleware { + open func transform(action: Action, context: ActionContext, state: State) -> Resin.Action? { + return action + } + + override open func transform(action: Resin.Action, context: ActionContext, state: State) -> Resin.Action? { + if let action = action as? Action { + return transform(action: action, context: context, state: state) + } else { + return action + } + } +} diff --git a/Sources/Resin/NSObjectProtocol+Lifecycle.swift b/Sources/Resin/NSObjectProtocol+Lifecycle.swift new file mode 100644 index 0000000..cf086ab --- /dev/null +++ b/Sources/Resin/NSObjectProtocol+Lifecycle.swift @@ -0,0 +1,22 @@ +import Foundation + +private var deallocationLifecycleKey = "deallocationLifecycleKey" + +extension NSObjectProtocol { + public var untilDeallocated: Lifecycle { + return Lifecycle(underlying: underlyingDeallocationLifecycle) + } + + internal var underlyingDeallocationLifecycle: AnyLifecycle { + if let lifecycle = objc_getAssociatedObject(self, &deallocationLifecycleKey) as? AnyLifecycle { + return lifecycle + } + + let lifecycle = AnyLifecycle(owner: self) + lifecycle.start() + + objc_setAssociatedObject(self, &deallocationLifecycleKey, lifecycle, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + return lifecycle + } +} diff --git a/Sources/Resin/Parcel.swift b/Sources/Resin/Parcel.swift new file mode 100644 index 0000000..ab8c0d3 --- /dev/null +++ b/Sources/Resin/Parcel.swift @@ -0,0 +1,64 @@ +import Foundation + +/// This struct encapsulates everything a module needs to expose to be integrated into a store +public struct Parcel { + public struct Delivery { + public var middlewares: [AnyMiddleware] + + public var presentationRoutes: [AnyPresentationRoute] + + public var reducers: [AnyReducer] + + public init(middlewares: [AnyMiddleware] = [], presentationRoutes: [AnyPresentationRoute] = [], reducers: [AnyReducer] = []) { + self.middlewares = middlewares + self.presentationRoutes = presentationRoutes + self.reducers = reducers + } + } + + public var build: (Store) -> Parcel.Delivery + + public init(build: @escaping (Store) -> Parcel.Delivery) { + self.build = build + } +} + +extension Parcel { + public func unfocused(by keyPath: WritableKeyPath) -> Parcel { + return Parcel { store in + let localStore = store.focused(by: keyPath) + return self.build(localStore).unfocused(by: keyPath) + } + } + + public func combining(with other: Parcel) -> Parcel { + Parcel { store in + self.build(store).combining(with: other.build(store)) + } + } +} + +extension Parcel.Delivery { + public func unfocused(by keyPath: WritableKeyPath) -> Parcel.Delivery { + var result = Parcel.Delivery() + result.middlewares = middlewares.map { $0.unfocused(by: keyPath) } + result.presentationRoutes = presentationRoutes.map { $0.unfocused(by: keyPath) } + result.reducers = reducers.map { $0.unfocused(by: keyPath) } + return result + } + + public func combining(with other: Parcel.Delivery) -> Parcel.Delivery { + Parcel.Delivery( + middlewares: middlewares + other.middlewares, + presentationRoutes: presentationRoutes + other.presentationRoutes, + reducers: reducers + other.reducers + ) + } +} + +extension Parcel where State == ArbitraryState { + /// Unfocuses a `Parcel` to a `Parcel` for any `S`. + public func unfocused(to superstate: Superstate.Type = Superstate.self) -> Parcel { + return unfocused(by: ArbitraryState.arbitraryKeyPath()) + } +} diff --git a/Sources/Resin/PresentationAction.swift b/Sources/Resin/PresentationAction.swift new file mode 100644 index 0000000..819a27b --- /dev/null +++ b/Sources/Resin/PresentationAction.swift @@ -0,0 +1,171 @@ +import Foundation + +public struct PresentationAction: Action { + public enum Presentation: Equatable { + case force([AnyPresentationIdentifier]) + case pop + case popSpecific(AnyPresentationIdentifier) + case popToTargetCount(Int) + case push(AnyPresentationIdentifier) + + @available(*, deprecated, message: "This will be removed in the future.") + case replace(AnyPresentationIdentifier) // Replaces top PresentationIdentifier + } + + public var presentation: Presentation + + public var target: AnyKeyPath + + public init(push identifier: N, onto keyPath: WritableKeyPath) { + self.init(push: identifier.typeErased, onto: keyPath as AnyKeyPath) + } + + public init(push identifier: AnyPresentationIdentifier, onto keyPath: WritableKeyPath) { + self.init(push: identifier, onto: keyPath as AnyKeyPath) + } + + public init(push identifier: AnyPresentationIdentifier, onto keyPath: AnyKeyPath) { + precondition(type(of: keyPath).valueType is [AnyPresentationIdentifier].Type) + + presentation = .push(identifier) + target = keyPath + } + + public init(popFrom keyPath: WritableKeyPath, identifier: AnyPresentationIdentifier? = nil) { + self.init(popFrom: keyPath as AnyKeyPath) + } + + public init(popFrom keyPath: AnyKeyPath, identifier: AnyPresentationIdentifier? = nil) { + presentation = identifier.map(Presentation.popSpecific) ?? .pop + target = keyPath + } + + public init(popToTargetCount count: Int, from keyPath: WritableKeyPath) { + presentation = .popToTargetCount(count) + target = keyPath + } + + public init(force identifiers: [AnyPresentationIdentifier], onto keyPath: WritableKeyPath) { + presentation = .force(identifiers) + target = keyPath + } + + @available(*, deprecated, message: "This will be removed in the future.") + public init(replaceTopWith identifier: N, onto keyPath: WritableKeyPath) { + self.init(replaceTopWith: identifier.typeErased, onto: keyPath as AnyKeyPath) + } + + @available(*, deprecated, message: "This will be removed in the future.") + public init(replaceTopWith identifier: AnyPresentationIdentifier, onto keyPath: WritableKeyPath) { + self.init(replaceTopWith: identifier, onto: keyPath as AnyKeyPath) + } + + @available(*, deprecated, message: "This will be removed in the future.") + public init(replaceTopWith identifier: AnyPresentationIdentifier, onto keyPath: AnyKeyPath) { + precondition(type(of: keyPath).valueType is [AnyPresentationIdentifier].Type) + + presentation = .replace(identifier) + target = keyPath + } + + public init(force identifiers: [N], onto keyPath: WritableKeyPath) { + self.init(force: identifiers.map { $0.typeErased }, onto: keyPath) + } +} + +internal struct PresentationReducer: Reducer { + // swiftlint:disable:next cyclomatic_complexity + public func reduce(action: PresentationAction, state: inout State) { + guard let keyPath = action.target as? WritableKeyPath else { + print("Target key path \(action.target) is not compatible with \(State.self))") + return + } + + var stack: [AnyPresentationIdentifier] { + get { + return state[keyPath: keyPath] + } + set { + state[keyPath: keyPath] = newValue + } + } + + switch action.presentation { + case .force(let identifiers): + stack = identifiers + case .pop: + guard !stack.isEmpty else { + print("Couldn't pop from \(action.target)") + return + } + + stack.removeLast() + case .popSpecific(let identifier): + if stack.last == identifier { + stack.removeLast() + } + case .popToTargetCount(let count): + guard stack.count >= count else { + print("Couldn't pop to target count from \(action.target)") + return + } + + stack.removeLast(stack.count - count) + case .push(let identifier): + stack.append(identifier) + case .replace(let identifier): + guard !stack.isEmpty else { + print("No identifiers to replace from \(action.target)") + return + } + + stack[stack.count - 1] = identifier + } + } +} + +/// Used exclusively to catch state up with UIKit. +/// +/// For example, when a back button is pressed in UINavigationController +/// this can be called from navigationController:didShow:animated: to sync state. +public struct SynchronousPresentationAction: SystemResponseAction { + public enum Presentation { + case popToTargetCount(Int) + } + + public var presentation: Presentation + + public var target: AnyKeyPath + + public init(popToTargetCount count: Int, from keyPath: AnyKeyPath) { + presentation = .popToTargetCount(count) + target = keyPath + } +} + +internal struct SynchronousPresentationReducer: Reducer { + public func reduce(action: SynchronousPresentationAction, state: inout State) { + switch action.presentation { + case .popToTargetCount(let count): + guard let keyPath = action.target as? WritableKeyPath else { + print("Target key path \(action.target) is not compatible with \(State.self)") + return + } + + guard state[keyPath: keyPath].count >= count else { + print("Couldn't pop to target count from \(action.target)") + return + } + + state[keyPath: keyPath].removeLast(state[keyPath: keyPath].count - count) + } + } +} + +public extension RootStore { + /// TODO: We should probably just install these by default. + func addPresentationReducers() { + add(reducer: PresentationReducer().typeErased) + add(reducer: SynchronousPresentationReducer().typeErased) + } +} diff --git a/Sources/Resin/PresentationIdentifier.swift b/Sources/Resin/PresentationIdentifier.swift new file mode 100644 index 0000000..384e13c --- /dev/null +++ b/Sources/Resin/PresentationIdentifier.swift @@ -0,0 +1,35 @@ +import UIKit + +/// A `PresentationIdentifier` identifies a target that can be navigated to, +/// e.g. using a `UINavigationController`. +public protocol PresentationIdentifier: Equatable {} + +public extension PresentationIdentifier { + var typeErased: AnyPresentationIdentifier { + return AnyPresentationIdentifier(presentationIdentifier: self) + } +} + +/// A type-erased presentation identifier. +public struct AnyPresentationIdentifier: Equatable { + private let equal: (Any) -> Bool + + internal let underlying: Any + + internal let underlyingTypeIdentifier: TypeIdentifier + + internal init(presentationIdentifier: N) { + equal = { rhs in + guard let other = rhs as? N else { return false } + + return presentationIdentifier == other + } + + underlying = presentationIdentifier + underlyingTypeIdentifier = TypeIdentifier(value: presentationIdentifier) + } + + public static func == (lhs: AnyPresentationIdentifier, rhs: AnyPresentationIdentifier) -> Bool { + return lhs.equal(rhs.underlying) + } +} diff --git a/Sources/Resin/PresentationRoute.swift b/Sources/Resin/PresentationRoute.swift new file mode 100644 index 0000000..5a4d13c --- /dev/null +++ b/Sources/Resin/PresentationRoute.swift @@ -0,0 +1,110 @@ +import Foundation +import Incremental +import UIKit + +/// A `PresentationRoute` is used to create a `UIViewController` for a given +/// `PresentationIdentifier` without having to know the context in which it is +/// created. +public struct PresentationRoute { + private let isModal: (Identifier) -> Bool + + private let make: (Identifier, Store) -> ViewController + + public init(isModal: @escaping (Identifier) -> Bool = { _ in ViewController.requiresModalPresentation }, make: @escaping (Identifier, Store) -> ViewController) { + self.isModal = isModal + self.make = make + } + + public func makeViewController(identifier: Identifier, store: Store) -> ViewController { + return make(identifier, store) + } + + public func requiresModalPresentation(identifier: Identifier) -> Bool { + return isModal(identifier) + } +} + +public extension PresentationRoute { + var typeErased: AnyPresentationRoute { + return AnyPresentationRoute(isModal: isModal, make: make) + } +} + +extension PresentationRoute { + public func unfocused(by keyPath: WritableKeyPath) -> PresentationRoute { + return PresentationRoute { identifier, store in + self.makeViewController(identifier: identifier, store: store.focused(by: keyPath)) + } + } +} + +extension PresentationRoute where State == ArbitraryState { + public init(isModal: @escaping (Identifier) -> Bool = { _ in ViewController.requiresModalPresentation }, make: @escaping (Identifier) -> ViewController) { + self.make = { identifier, _ in + make(identifier) + } + self.isModal = isModal + } + + public func makeViewController(identifier: Identifier) -> ViewController { + let store = RootStore(state: ArbitraryState()) + + return make(identifier, store) + } +} + +public struct AnyPresentationRoute { + private let isModal: (AnyPresentationIdentifier) -> Bool + + private var make: (AnyPresentationIdentifier, Store) -> UIViewController + + internal var underlyingTypeIdentifier: TypeIdentifier + + internal init(isModal: @escaping (N) -> Bool = { _ in V.requiresModalPresentation }, make: @escaping (N, Store) -> V) { + self.init(isModal: { isModal($0.underlying as! N) }, underlyingTypeIdentifier: TypeIdentifier(type: N.self), make: { make($0.underlying as! N, $1) }) + } + + private init(isModal: @escaping (AnyPresentationIdentifier) -> Bool, underlyingTypeIdentifier: TypeIdentifier, make: @escaping (AnyPresentationIdentifier, Store) -> UIViewController) { + self.isModal = isModal + self.make = make + self.underlyingTypeIdentifier = underlyingTypeIdentifier + } + + public func makeViewController(identifier: AnyPresentationIdentifier, store: Store) -> UIViewController { + precondition(underlyingTypeIdentifier == identifier.underlyingTypeIdentifier) + + return make(identifier, store) + } + + public func requiresModalPresentation(identifier: AnyPresentationIdentifier) -> Bool { + precondition(underlyingTypeIdentifier == identifier.underlyingTypeIdentifier) + + return isModal(identifier) + } +} + +extension AnyPresentationRoute { + public func unfocused(by keyPath: WritableKeyPath) -> AnyPresentationRoute { + return AnyPresentationRoute(isModal: isModal, underlyingTypeIdentifier: underlyingTypeIdentifier) { identifier, store in + return self.make(identifier, store.focused(by: keyPath)) + } + } +} + +extension AnyPresentationRoute where State == ArbitraryState { + public func makeViewController(identifier: AnyPresentationIdentifier) -> UIViewController { + precondition(underlyingTypeIdentifier == identifier.underlyingTypeIdentifier) + + let store = RootStore(state: ArbitraryState()) + + return make(identifier, store) + } + + public func unfocused(to superstate: Superstate.Type = Superstate.self) -> AnyPresentationRoute { + let make = self.make + + return AnyPresentationRoute(isModal: isModal, underlyingTypeIdentifier: underlyingTypeIdentifier) { identifier, store in + return make(identifier, store.focused(by: ArbitraryState.arbitraryKeyPath())) + } + } +} diff --git a/Sources/Resin/PresentationRouter.swift b/Sources/Resin/PresentationRouter.swift new file mode 100644 index 0000000..87e8d67 --- /dev/null +++ b/Sources/Resin/PresentationRouter.swift @@ -0,0 +1,73 @@ +import Incremental +import UIKit + +public final class PresentationRouter { + private var routes: [TypeIdentifier: AnyPresentationRoute] = [:] + + private let store: Store + + public init(store: Store) { + self.store = store + } + + public func register(route: AnyPresentationRoute) { + routes[route.underlyingTypeIdentifier] = route + } + + public func register(routes: [AnyPresentationRoute]) { + routes.forEach { register(route: $0) } + } + + public func register(route: PresentationRoute) { + register(route: route.typeErased) + } + + public func makeViewController(identifier: AnyPresentationIdentifier) -> UIViewController { + guard let route = routes[identifier.underlyingTypeIdentifier] else { + fatalError("No route registered for \(identifier)") + } + + return route.makeViewController(identifier: identifier, store: store) + } + + public func requiresModalPresentation(identifier: AnyPresentationIdentifier) -> Bool { + guard let route = routes[identifier.underlyingTypeIdentifier] else { + fatalError("No route registered for \(identifier)") + } + + return route.requiresModalPresentation(identifier: identifier) + } +} + +extension PresentationRouter { + public var typeErased: AnyPresentationRouter { + return AnyPresentationRouter(presentationRouter: self) + } +} + +public final class AnyPresentationRouter { + private let isModal: (AnyPresentationIdentifier) -> Bool + + private let make: (AnyPresentationIdentifier) -> UIViewController + + internal init(presentationRouter: PresentationRouter) { + isModal = presentationRouter.requiresModalPresentation + make = presentationRouter.makeViewController + } + + public func makeViewController(identifier: AnyPresentationIdentifier) -> UIViewController { + return make(identifier) + } + + public func requiresModalPresentation(identifier: AnyPresentationIdentifier) -> Bool { + return isModal(identifier) + } +} + +extension PresentationRouter { + public func register(delivery: Parcel.Delivery) { + delivery.presentationRoutes.forEach { route in + register(route: route) + } + } +} diff --git a/Sources/Resin/Reducer.swift b/Sources/Resin/Reducer.swift new file mode 100644 index 0000000..0ead981 --- /dev/null +++ b/Sources/Resin/Reducer.swift @@ -0,0 +1,47 @@ +import Foundation + +/// A `Reducer` modifies a `State` based on an `Action` it receives using a pure +/// function. +public protocol Reducer { + associatedtype Action: Resin.Action + + associatedtype State: Equatable + + func reduce(action: Action, state: inout State) +} + +public extension Reducer { + var typeErased: AnyReducer { + return AnyReducer(reducer: self.reduce) + } +} + +/// An `AnyReducer` erases the `Action` type of a `Reducer`. +public struct AnyReducer { + internal let actionIdentifier: TypeIdentifier + + internal let perform: (Any, inout State) -> Void + + internal init(actionIdentifier: TypeIdentifier, perform: @escaping (Any, inout State) -> Void) { + self.actionIdentifier = actionIdentifier + self.perform = perform + } + + /// Initializes a new Reducer. + /// + /// - Parameters: + /// - reducer: A pure function that modifies a state based on an `Action`. + public init(reducer: @escaping (A, inout State) -> Void) { + actionIdentifier = TypeIdentifier(type: A.self) + + perform = { action, state in + if let actual = action as? A { + reducer(actual, &state) + } + } + } + + public func reduce(action: Any, state: inout State) { + perform(action, &state) + } +} diff --git a/Sources/Resin/Store+Parcel.swift b/Sources/Resin/Store+Parcel.swift new file mode 100644 index 0000000..e9b2db7 --- /dev/null +++ b/Sources/Resin/Store+Parcel.swift @@ -0,0 +1,13 @@ +import Foundation + +extension RootStore { + public func integrate(delivery: Parcel.Delivery) { + for middleware in delivery.middlewares { + add(middleware: middleware) + } + + for reducer in delivery.reducers { + add(reducer: reducer) + } + } +} diff --git a/Sources/Resin/Store.swift b/Sources/Resin/Store.swift new file mode 100644 index 0000000..a476eb6 --- /dev/null +++ b/Sources/Resin/Store.swift @@ -0,0 +1,125 @@ +import Dispatch +import Incremental + +private protocol AnyStore: ActionHandler { + var keyPath: AnyKeyPath { get } +} + +/// A `Store` offers a way to subscribe to a `State` and handles updates to it +/// using `Reducer`s. +public class Store: AnyStore { + public let state: I + + public let keyPath: AnyKeyPath + + private let parent: AnyStore? + + fileprivate init(state: I, parent: AnyStore!, keyPath: AnyKeyPath) { + self.state = state + self.parent = parent + self.keyPath = keyPath + } + + /// Handles an Action. + /// + /// - Parameters: + /// - action: The action to handle, if no `Reducer` is set up for this + /// `Action` type, an error will be logged. + public func handle(_ action: Action, context: ActionContext) { + parent!.handle(action, context: context) + } + + /// Creates a new Store that focuses on a part of the receiver's State. + /// + /// This method allows us to pass up Stores focused on the most specific + /// state that other components need to care about. + /// + /// The resulting store forwards all actions to the receiver. + /// + /// - Parameters: + /// - by: A `KeyPath` that maps from the state of the receiver to a more + /// specific substate. + public func focused(by keyPath: WritableKeyPath) -> Store { + let combined = self.keyPath.appending(path: keyPath)! + + return Store(state: state[keyPath], parent: self, keyPath: combined) + } +} + +/// A `Store` can be in direct control of the middleware, reducers and state +/// or be a proxy of a different store. +/// +/// This allows us to give separate components a different view on the same +/// data. +public final class RootStore: Store { + var middleware: [AnyMiddleware] = [] + + var reducers: [TypeIdentifier: AnyReducer] = [:] + + private let stateInput: Input + + private var isCurrentlyExecutingAction = false + + private var underlyingState: State { + didSet { + stateInput.write(underlyingState) + } + } + + /// Initializes a store. + /// + /// - Parameters: + /// - state: The initial state of the store. All further modifications to + /// this state will be made by handling actions. + public init(state: State) { + self.stateInput = Input(state) + self.underlyingState = state + + super.init(state: stateInput.i, parent: nil, keyPath: \State.self) + } + + public func add(middleware: AnyMiddleware) { + middleware.actionHandler = self + + self.middleware.append(middleware) + } + + public func add(reducer: AnyReducer) { + reducers[reducer.actionIdentifier] = reducer + } + + public override func handle(_ action: Action, context: ActionContext) { + guard !isCurrentlyExecutingAction else { + print("Error: Action caused another Action to dispatch before completing") + return + } + + isCurrentlyExecutingAction = true + defer { + isCurrentlyExecutingAction = false + } + + print("Handling action \(type(of: action))") + + // Run our Action through the middleware, if at any step action gets + // caught, abort. + let transformedAction: Action? = middleware.reduce(action as Action?) { action, middle in + guard let action = action else { return nil } + + return middle.transform(action: action, context: context, state: underlyingState) + } + + guard let action = transformedAction else { return } + + /// If an action makes it past the Middleware, it needs to be handled on + /// the main queue. + dispatchPrecondition(condition: .onQueue(.main)) + + guard let reducer = reducers[TypeIdentifier(value: action)] else { + print("Action dispatched in \(context.file):\(context.line) was not handled.") + return + } + + reducer.reduce(action: action, state: &underlyingState) + } +} diff --git a/Sources/Resin/UIBarButtonItem+Resin.swift b/Sources/Resin/UIBarButtonItem+Resin.swift new file mode 100644 index 0000000..21114b0 --- /dev/null +++ b/Sources/Resin/UIBarButtonItem+Resin.swift @@ -0,0 +1,59 @@ +import ObjectiveC +import UIKit + +private var actionTargetKey = "actionTargetKey" + +extension UIBarButtonItem { + private var actionTarget: ActionTarget? { + get { + return objc_getAssociatedObject(self, &actionTargetKey) as? ActionTarget + } + set { + objc_setAssociatedObject(self, &actionTargetKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public convenience init(image: UIImage?, style: UIBarButtonItem.Style, source: UIViewController, action: Action) { + self.init(image: image, style: style, source: source, dispatch: { _ in action }) + } + + public convenience init(image: UIImage?, style: UIBarButtonItem.Style, source: UIViewController, dispatch: @escaping (UIBarButtonItem) -> Action) { + self.init(image: image, style: style, target: nil, action: nil) + + self.dispatch(from: source, do: dispatch) + } + + public convenience init(title: String?, style: UIBarButtonItem.Style, source: UIViewController, action: Action) { + self.init(title: title, style: style, source: source, dispatch: { _ in action }) + } + + public convenience init(title: String?, style: UIBarButtonItem.Style, source: UIViewController, dispatch: @escaping (UIBarButtonItem) -> Action) { + self.init(title: title, style: style, target: nil, action: nil) + + self.dispatch(from: source, do: dispatch) + } + + public convenience init(barButtonSystemItem: UIBarButtonItem.SystemItem, source: UIViewController, action: Action) { + self.init(barButtonSystemItem: barButtonSystemItem, source: source, dispatch: { _ in action }) + } + + public convenience init(barButtonSystemItem: UIBarButtonItem.SystemItem, source: UIViewController, dispatch: @escaping (UIBarButtonItem) -> Action) { + self.init(barButtonSystemItem: barButtonSystemItem, target: nil, action: nil) + + self.dispatch(from: source, do: dispatch) + } + + public func `do`(closure: @escaping (UIBarButtonItem) -> Void) { + action = #selector(ActionTarget.handle) + actionTarget = ActionTarget(owner: self) { closure($0 as! UIBarButtonItem) } + target = actionTarget + } + + public func dispatch(from source: UIViewController, do closure: @escaping (UIBarButtonItem) -> Action) { + `do` { [unowned source = source] item in + let action = closure(item) + + source.dispatch(action) + } + } +} diff --git a/Sources/Resin/UIControl+Resin.swift b/Sources/Resin/UIControl+Resin.swift new file mode 100644 index 0000000..9d2a7da --- /dev/null +++ b/Sources/Resin/UIControl+Resin.swift @@ -0,0 +1,49 @@ +import ObjectiveC +import UIKit + +private var associatedObjectKey = "resin_UIControlActionTargetKey" + +public protocol UIControlProtocol: AnyObject { + func addTarget(_ target: Any?, action: Selector, for controlEvents: UIControl.Event) +} + +extension UIControlProtocol { + private var actionTargets: [ActionTarget] { + get { + return objc_getAssociatedObject(self, &associatedObjectKey) as? [ActionTarget] ?? [] + } + set { + objc_setAssociatedObject(self, &associatedObjectKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public func on(_ events: UIControl.Event, do closure: @escaping (Self) -> Void) { + let actionTarget = ActionTarget(owner: self) { control in closure(control as! Self) } + + actionTargets.append(actionTarget) + + addTarget(actionTarget, action: #selector(ActionTarget.handle), for: events) + } +} + +extension UIControl: UIControlProtocol { + public func on(_ events: UIControl.Event, file: String = #file, line: Int = #line, dispatch action: Action) { + on(events, file: file, line: line) { action } + } + + public func on(_ events: UIControl.Event, file: String = #file, line: Int = #line, dispatch closure: @escaping () -> Action?) { + on(events) { control in + guard let action = closure() else { return } + + control.dispatch(action, file: file, line: line) + } + } + + public func on(_ events: UIControl.Event, file: String = #file, line: Int = #line, push identifier: N) { + on(events, file: file, line: line, push: identifier.typeErased) + } + + public func on(_ events: UIControl.Event, file: String = #file, line: Int = #line, push identifier: AnyPresentationIdentifier) { + on(events, file: file, line: line, dispatch: ImplicitPresentationAction.push(identifier)) + } +} diff --git a/Sources/Resin/UIGestureRecognizer+Resin.swift b/Sources/Resin/UIGestureRecognizer+Resin.swift new file mode 100644 index 0000000..6c1dfcc --- /dev/null +++ b/Sources/Resin/UIGestureRecognizer+Resin.swift @@ -0,0 +1,30 @@ +import UIKit + +private var associatedObjectKey = "resin_UIGestureRecognizerActionTargetKey" + +extension UIGestureRecognizer { + private var actionTargets: [ActionTarget] { + get { + return objc_getAssociatedObject(self, &associatedObjectKey) as? [ActionTarget] ?? [] + } + set { + objc_setAssociatedObject(self, &associatedObjectKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public func addAction(file: String = #file, line: Int = #line, dispatch action: Action) { + addAction(file: file, line: line) { action } + } + + public func addAction(file: String = #file, line: Int = #line, dispatch closure: @escaping () -> Action?) { + let actionTarget = ActionTarget(owner: self) { gestureRecognizer in + guard let action = closure() else { return } + + gestureRecognizer.view?.dispatch(action, file: file, line: line) + } + + actionTargets.append(actionTarget) + + addTarget(actionTarget, action: #selector(ActionTarget.handle)) + } +} diff --git a/Sources/Resin/UINavigationController+Resin.swift b/Sources/Resin/UINavigationController+Resin.swift new file mode 100644 index 0000000..8849f5d --- /dev/null +++ b/Sources/Resin/UINavigationController+Resin.swift @@ -0,0 +1,92 @@ +import Incremental +import UIKit + +private var navigationDelegateProxyKey = "navigationDelegateProxyKey" +private var navigationViewControllerCacheKey = "navigationViewControllerCacheKey" +private var navigationIdentifiersKeyPathKey = "navigationIdentifiersKeyPathKey" + +extension UINavigationController { + fileprivate var navigationDelegateProxy: NavigationDelegateProxy? { + get { + return objc_getAssociatedObject(self, &navigationDelegateProxyKey) as? NavigationDelegateProxy + } + set { + objc_setAssociatedObject(self, &navigationDelegateProxyKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + var navigationIdentifiersKeyPath: AnyKeyPath? { + get { + return objc_getAssociatedObject(self, &navigationIdentifiersKeyPathKey) as? AnyKeyPath + } + set { + objc_setAssociatedObject(self, &navigationIdentifiersKeyPathKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private(set) internal var navigationViewControllerCache: ViewControllerCache { + get { + if let cache = objc_getAssociatedObject(self, &navigationViewControllerCacheKey) as? ViewControllerCache { + return cache + } + + let cache = ViewControllerCache(owner: self) + + self.navigationViewControllerCache = cache + + return cache + } + set { + objc_setAssociatedObject(self, &navigationViewControllerCacheKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public convenience init(store: Store, root: AnyPresentationIdentifier? = nil, navigationStack keyPath: KeyPath) { + self.init() + + self.navigationIdentifiersKeyPath = store.keyPath.appending(path: keyPath) + self.navigationDelegateProxy = NavigationDelegateProxy() + + self.delegate = self.navigationDelegateProxy + + let identifiers = store.state[keyPath] + .map { ids -> [AnyPresentationIdentifier] in + if let r = root { + return [r] + ids + } else { + return ids + } + } + + whileVisible.do { navigationController in + let _navigationController = navigationController + + return identifiers + .map(_navigationController.navigationViewControllerCache.createViewControllersIfNeeded) + .observe { [unowned _navigationController] viewControllers in + let inTransition = _navigationController.isBeingPresented || _navigationController.isBeingDismissed + let isVisible = _navigationController.viewIfLoaded?.window != nil + + let animated = !inTransition && isVisible + + if animated && _navigationController.viewControllers == Array(viewControllers.dropLast()) { + _navigationController.pushViewController(viewControllers.last!, animated: animated) + } else { + _navigationController.setViewControllers(viewControllers, animated: animated) + } + } + } + } +} + +private class NavigationDelegateProxy: NSObject, UINavigationControllerDelegate { + public func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { + let targetCount = navigationController.viewControllers.count - 1 + + guard let target = navigationController.navigationIdentifiersKeyPath else { return } + + let action = SynchronousPresentationAction(popToTargetCount: targetCount, from: target) + + navigationController.dispatchWithSynchronousExecution(action) + } +} diff --git a/Sources/Resin/UIView+Resin.swift b/Sources/Resin/UIView+Resin.swift new file mode 100644 index 0000000..50cb758 --- /dev/null +++ b/Sources/Resin/UIView+Resin.swift @@ -0,0 +1,42 @@ +import UIKit +import ObjectiveC + +extension UIView { + /// The `ActionHandler` the receiver should send its actions to. + /// + /// Set the corresponding `actionHandler` property on the view controller + /// that owns the view tree containing the receiver to make it accessible + /// here. + public var actionHandler: ActionHandler? { + get { + let controller = responderChain.first { $0 is UIViewController } as? UIViewController + return controller?.actionHandler + } + } + + private var responderChain: AnyIterator { + var responder = next + + return AnyIterator { + defer { + responder = responder?.next + } + + return responder + } + } + + public func dispatch(_ action: Action, file: String = #file, line: Int = #line) { + let controller = responderChain.first { $0 is UIViewController } as? UIViewController + + let context = ActionContext(file: file, line: line, view: self, viewController: controller) + + actionHandler?.dispatch(action, context: context) + } + + public func dispatchWithSynchronousExecution(_ action: SystemResponseAction, file: String = #file, line: Int = #line) { + let context = ActionContext(file: file, line: line, view: self) + + actionHandler?.dispatchWithSynchronousExecution(action, context: context) + } +} diff --git a/Sources/Resin/UIViewController+Lifecycle.swift b/Sources/Resin/UIViewController+Lifecycle.swift new file mode 100644 index 0000000..aa16ead --- /dev/null +++ b/Sources/Resin/UIViewController+Lifecycle.swift @@ -0,0 +1,162 @@ +import Dispatch +import Foundation +import ObjectiveC +import UIKit + +// Run this only once. +private let installAppearanceHooks: Void = { + let `class`: AnyClass = objc_getClass("UIViewController") as! AnyClass + + do { + typealias BlockSignature = @convention(block) (/* self */ UIViewController) -> Void + typealias FunctionSignature = @convention(c) (/* self */ UIViewController, /* cmd */ Selector) -> Void + + let selector = #selector(UIViewController.viewDidLoad) + + let viewDidLoad = class_getInstanceMethod(`class`, selector)! + + let oldImplementation = unsafeBitCast(method_getImplementation(viewDidLoad), to: FunctionSignature.self) + + let newImplementation: BlockSignature = { `self` in + oldImplementation(self, selector) + + self.whileViewIsLoadedLifecycle.start() + } + + method_setImplementation(viewDidLoad, imp_implementationWithBlock(newImplementation)) + } + + typealias BlockSignature = @convention(block) (/* self */ UIViewController, /* animated */ Bool) -> Void + typealias FunctionSignature = @convention(c) (/* self */ UIViewController, /* cmd */ Selector, /* animated */ Bool) -> Void + + do { + let selector = #selector(UIViewController.viewWillAppear(_:)) + + let viewWillAppear = class_getInstanceMethod(`class`, selector)! + + let oldImplementation = unsafeBitCast(method_getImplementation(viewWillAppear), to: FunctionSignature.self) + + let newImplementation: BlockSignature = { `self`, animated in + oldImplementation(self, selector, animated) + + self.whileVisibleLifecycle.start() + } + + method_setImplementation(viewWillAppear, imp_implementationWithBlock(newImplementation)) + } + + do { + let selector = #selector(UIViewController.viewDidAppear(_:)) + + let viewDidAppear = class_getInstanceMethod(`class`, selector)! + + let oldImplementation = unsafeBitCast(method_getImplementation(viewDidAppear), to: FunctionSignature.self) + + let newImplementation: BlockSignature = { `self`, animated in + oldImplementation(self, selector, animated) + + self.whileAbleToPresentLifecycle.start() + } + + method_setImplementation(viewDidAppear, imp_implementationWithBlock(newImplementation)) + } + + do { + let selector = #selector(UIViewController.viewDidDisappear(_:)) + + let viewDidDisappear = class_getInstanceMethod(`class`, selector)! + + let oldImplementation = unsafeBitCast(method_getImplementation(viewDidDisappear), to: FunctionSignature.self) + + let newImplementation: BlockSignature = { `self`, animated in + self.whileVisibleLifecycle.stop() + + let isRootViewController = self == UIApplication.shared.keyWindow?.rootViewController + + if self.presentingViewController == nil && !isRootViewController { + self.whileAbleToPresentLifecycle.stop() + } + + oldImplementation(self, selector, animated) + } + + method_setImplementation(viewDidDisappear, imp_implementationWithBlock(newImplementation)) + } +}() + +public protocol UIViewControllerLifecycle: AnyObject {} + +private var appearanceLifecycleKey = "appearanceLifecycle" +private var presentabilityLifecycleLifecycleKey = "inViewHierarchyLifecycle" +private var viewIsLoadedLifecycleKey = "viewIsLoadedLifecycle" + +extension UIViewControllerLifecycle { + /// A `Lifecycle` that is running as long as the receiver's able to present + /// view controllers. Runs from `viewDidAppear(_:)` until it is removed + /// from thew view controller hierarchy. + public var whileAbleToPresent: Lifecycle { + return Lifecycle(underlying: whileAbleToPresentLifecycle) + } + + fileprivate var whileAbleToPresentLifecycle: AnyLifecycle { + if let lifecycle = objc_getAssociatedObject(self, &presentabilityLifecycleLifecycleKey) as? AnyLifecycle { + return lifecycle + } + + installAppearanceHooks + + let lifecycle = AnyLifecycle(owner: self) + + objc_setAssociatedObject(self, &presentabilityLifecycleLifecycleKey, lifecycle, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + return lifecycle + } + + /// A `Lifecycle` that is running as long as the receiver's view is loaded, + /// as determined by `viewDidLoad(_:)`, and until the receiver is + /// deallocated. + public var whileViewIsLoaded: Lifecycle { + return Lifecycle(underlying: whileViewIsLoadedLifecycle) + } + + fileprivate var whileViewIsLoadedLifecycle: AnyLifecycle { + if let lifecycle = objc_getAssociatedObject(self, &viewIsLoadedLifecycleKey) as? AnyLifecycle { + return lifecycle + } + + installAppearanceHooks + + let lifecycle = AnyLifecycle(owner: self) + + if (self as! UIViewController).isViewLoaded { + lifecycle.start() + } + + objc_setAssociatedObject(self, &viewIsLoadedLifecycleKey, lifecycle, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + return lifecycle + } + + /// A `Lifecycle` that is running as long as the receiver's view is visible, + /// as determined by the calls to `viewWillAppear(_:)` and + /// `viewDidDisappear(_:)`. + public var whileVisible: Lifecycle { + return Lifecycle(underlying: whileVisibleLifecycle) + } + + fileprivate var whileVisibleLifecycle: AnyLifecycle { + if let lifecycle = objc_getAssociatedObject(self, &appearanceLifecycleKey) as? AnyLifecycle { + return lifecycle + } + + installAppearanceHooks + + let lifecycle = AnyLifecycle(owner: self) + + objc_setAssociatedObject(self, &appearanceLifecycleKey, lifecycle, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + return lifecycle + } +} + +extension UIViewController: UIViewControllerLifecycle {} diff --git a/Sources/Resin/UIViewController+ModalPresentation.swift b/Sources/Resin/UIViewController+ModalPresentation.swift new file mode 100644 index 0000000..fb16d6f --- /dev/null +++ b/Sources/Resin/UIViewController+ModalPresentation.swift @@ -0,0 +1,161 @@ +import Incremental +import UIKit + +public extension UIViewController { + func bindPresentedViewControllers(store: Store, navigationStack keyPath: WritableKeyPath) -> Disposable { + precondition(presentationIdentifiersKeyPath == nil, "Presented view controllers are already bound.") + + self.presentationIdentifiersKeyPath = store.keyPath.appending(path: keyPath) + + let identifiers = store.state[keyPath] + + return [ + identifiers + .map(self.presentationViewControllerCache.createViewControllersIfNeeded) + .observe { viewControllers in + let keyPath = self.presentationIdentifiersKeyPath + + /// In order to keep the navigation stack in sync with the + /// modal presentation, dispatch a + /// `SynchronousPresentationAction` whenever one of the view + /// controllers gets dismissed. + viewControllers.enumerated().forEach { (index, viewController) in + let synchronousAction = SynchronousPresentationAction(popToTargetCount: index, from: keyPath!) + + viewController.onDismiss = { [unowned viewController] in + viewController.dispatchWithSynchronousExecution(synchronousAction) + } + } + + self.present(viewControllers: viewControllers, animated: true) + }, + Disposable { + self.presentationIdentifiersKeyPath = nil + } + ] + } +} + +// Run this only once. +private let installDisappearanceHooks: Void = { + typealias BlockSignature = @convention(block) (/* self */ UIViewController, /* animated */ Bool) -> Void + typealias FunctionSignature = @convention(c) (/* self */ UIViewController, /* cmd */ Selector, /* animated */ Bool) -> Void + + let `class`: AnyClass = objc_getClass("UIViewController") as! AnyClass + + do { + let viewDidDisappear = class_getInstanceMethod(`class`, #selector(UIViewController.viewDidDisappear(_:)))! + + let oldImplementation = unsafeBitCast(method_getImplementation(viewDidDisappear), to: FunctionSignature.self) + + let newImplementation: BlockSignature = { `self`, animated in + if self.presentingViewController == nil { + self.onDismiss?() + self.onDismiss = nil + } + + oldImplementation(self, #selector(UIViewController.viewDidDisappear(_:)), animated) + } + + method_setImplementation(viewDidDisappear, imp_implementationWithBlock(newImplementation)) + } +}() + +private var onDismissKey = "onDismissKey" +private var presentationIdentifiersKeyPathKey = "presentationIdentifiersKeyPathKey" +private var presentationViewControllerCacheKey = "presentationViewControllerCacheKey" + +extension UIViewController { + var onDismiss: (() -> ())? { + get { + installDisappearanceHooks + + return objc_getAssociatedObject(self, &onDismissKey) as? () -> () + } + set { + installDisappearanceHooks + + objc_setAssociatedObject(self, &onDismissKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + } + } + + var presentationIdentifiersKeyPath: AnyKeyPath? { + get { + return objc_getAssociatedObject(self, &presentationIdentifiersKeyPathKey) as? AnyKeyPath + } + set { + objc_setAssociatedObject(self, &presentationIdentifiersKeyPathKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + private var presentationViewControllerCache: ViewControllerCache { + get { + if let cache = objc_getAssociatedObject(self, &presentationViewControllerCacheKey) as? ViewControllerCache { + return cache + } + + let cache = ViewControllerCache(owner: self) + + self.presentationViewControllerCache = cache + + return cache + } + set { + objc_setAssociatedObject(self, &presentationViewControllerCacheKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +} + +extension UIViewController { + var resolvedPresentationKeyPath: AnyKeyPath? { + // Walk up the modal presentation chain first, as we assume that there + // are no two view controllers that bind modal presentation on top of + // each other. + return presentingViewController?.resolvedPresentationKeyPath + // If we're bound ourselves, return our + // `presentationIdentifiersKeyPath`. + ?? presentationIdentifiersKeyPath + // Otherwise, recursively ask the parent. + ?? parent?.resolvedPresentationKeyPath + } +} + +private extension UIViewController { + func present(viewControllers: [UIViewController], animated: Bool) { + guard !viewControllers.isEmpty else { + if presentedViewController != nil { + dismiss(animated: animated) + } + return + } + + let toBePresented = viewControllers.first! + + guard presentedViewController != toBePresented else { + toBePresented.present(viewControllers: Array(viewControllers.dropFirst()), animated: animated) + return + } + + // If presenting multiple view conrollers at once, only animate the + // presentation of the last view controller. + // + // This prevents loading issues in `SFSafariViewController`. + let actuallyAnimated = animated && viewControllers.count == 1 + + if presentedViewController != nil { + presentedViewController?.actionHandler = nil + presentedViewController?.presentationRouter = nil + + dismiss(animated: actuallyAnimated) { + self.present(viewControllers: viewControllers, animated: animated) + } + } else { + toBePresented.actionHandler = actionHandler + toBePresented.presentationRouter = presentationRouter + + self.present(toBePresented, animated: actuallyAnimated) { + toBePresented.present(viewControllers: Array(viewControllers.dropFirst()), animated: animated) + } + } + } +} diff --git a/Sources/Resin/UIViewController+RequiresModalPresentation.swift b/Sources/Resin/UIViewController+RequiresModalPresentation.swift new file mode 100644 index 0000000..0aa492e --- /dev/null +++ b/Sources/Resin/UIViewController+RequiresModalPresentation.swift @@ -0,0 +1,26 @@ +import UIKit +import SafariServices + +extension SFSafariViewController { + @objc override open class var requiresModalPresentation: Bool { + return true + } +} + +extension UIActivityViewController { + @objc override open class var requiresModalPresentation: Bool { + return true + } +} + +extension UINavigationController { + @objc override open class var requiresModalPresentation: Bool { + return true + } +} + +extension UIViewController { + @objc open class var requiresModalPresentation: Bool { + return false + } +} diff --git a/Sources/Resin/UIViewController+Resin.swift b/Sources/Resin/UIViewController+Resin.swift new file mode 100644 index 0000000..162f654 --- /dev/null +++ b/Sources/Resin/UIViewController+Resin.swift @@ -0,0 +1,41 @@ +import UIKit + +private var actionHandlerKey = "actionHandlerKey" +private var presentationRouterKey = "presentationRouterKey" + +extension UIViewController { + /// The `ActionHandler` used by this view controller. + /// + /// It's a view controllers responsibility to bridge its child views and the + /// `Action` infrastructure. Use this property to make an `ActionHandler` + /// available to the entire view tree owned by the receiver. + public var actionHandler: ActionHandler? { + get { + return objc_getAssociatedObject(self, &actionHandlerKey) as? ActionHandler ?? parent?.actionHandler + } + set { + objc_setAssociatedObject(self, &actionHandlerKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public var presentationRouter: AnyPresentationRouter? { + get { + return objc_getAssociatedObject(self, &presentationRouterKey) as? AnyPresentationRouter ?? parent?.presentationRouter + } + set { + objc_setAssociatedObject(self, &presentationRouterKey, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + public func dispatch(_ action: Action, file: String = #file, line: Int = #line) { + let context = ActionContext(file: file, line: line, viewController: self) + + actionHandler?.dispatch(action, context: context) + } + + public func dispatchWithSynchronousExecution(_ action: SystemResponseAction, file: String = #file, line: Int = #line) { + let context = ActionContext(file: file, line: line, viewController: self) + + actionHandler?.dispatchWithSynchronousExecution(action, context: context) + } +} diff --git a/Sources/Resin/ViewControllerCache.swift b/Sources/Resin/ViewControllerCache.swift new file mode 100644 index 0000000..dacb798 --- /dev/null +++ b/Sources/Resin/ViewControllerCache.swift @@ -0,0 +1,45 @@ +import Incremental +import UIKit + +public final class ViewControllerCache { + internal struct ControllerItemAssociation: Equatable { + var controller: UIViewController + var item: AnyPresentationIdentifier + + static func == (lhs: ControllerItemAssociation, rhs: ControllerItemAssociation) -> Bool { + return lhs.controller === rhs.controller && lhs.item == rhs.item + } + } + + private var currentControllers: [ControllerItemAssociation] = [] + + unowned let owner: UIViewController + + public init(owner: UIViewController) { + self.owner = owner + } + + private func makeController(index: Int, identifier: AnyPresentationIdentifier) -> ControllerItemAssociation? { + // If the controller at the given index is already of the correct type, + // nothing needs to be done. + if currentControllers.indices.contains(index) && currentControllers[index].item == identifier { + return currentControllers[index] + } + + guard let controller = owner.presentationRouter?.makeViewController(identifier: identifier) else { + return nil + } + + return ControllerItemAssociation(controller: controller, item: identifier) + } + + public func createViewControllersIfNeeded(presentationIdentifiers: [AnyPresentationIdentifier]) -> [UIViewController] { + let associations = presentationIdentifiers.enumerated().compactMap(makeController) + + defer { + currentControllers = associations + } + + return associations.map { $0.controller } + } +} diff --git a/Sources/Resin/ViewModel.swift b/Sources/Resin/ViewModel.swift new file mode 100644 index 0000000..3e2c47e --- /dev/null +++ b/Sources/Resin/ViewModel.swift @@ -0,0 +1,38 @@ +import Foundation + +public protocol ViewModel: Equatable { + /// A view model that represents the empty state. + static var empty: Self { get } + + /// This method produces a "best effort" fallback `ViewModel` in cases where + /// the right data isn't available but a `ViewModel` is required regardless. + /// + /// - Parameters: + /// - previous: The last view model that was available. + /// - current: The best view model that is available currently. + /// - Returns: A "best effort" view model to use if no data is available or + /// `current` otherwise. + static func fallback(previous: Self, current: Self?) -> Self + + /// If `true`, the view model is considered out of date and represents the + /// last known good state. + var isStale: Bool { get set } +} + +public extension ViewModel { + static func fallback(previous: Self, current: Self?) -> Self { + if let current = current { + return current + } else { + var copy = previous + copy.isStale = true + return copy + } + } +} + +public extension ViewModel where Self: DefaultInit { + static var empty: Self { + return Self() + } +} diff --git a/readme.md b/readme.md index 1af9075..a6960b8 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,54 @@ +# Incremental + +This repository provides two libraries: **Incremental** and **Resin**. + +## Incremental + This is an implementation of incremental programming. It's based on the ideas in [incremental](https://blog.janestreet.com/introducing-incremental/), which are on turn based on the ideas of [self-adjusting computation](http://www.umut-acar.org/self-adjusting-computation). For usage, see the [laufpark stechlin](https://github.com/chriseidhof/laufpark-stechlin) app. + +## Resin + +Resin uses Incremental and additionally provides a framework way to manage application state. + +An app using Resin would typically have a single, shared struct that contains the entire application state. Different parts of the app would then *focus* this state to only operate on their subset of the state. This helps keep the concerns of different modules isolated from each other. + +As a trivial example, consider + +```swift +struct AppState { + var foo: Foo + var bar: Bar +} +``` + +where the app has two components (see *Parcel* below), one of which would operates on a state of the type `Foo`, and the other one on `Bar`. + +### Actions + +Resin enforces that changes to this global state can only be done through an `Action`. + +An `Action` has value semantics. And it can trigger two things: a `Reducer` can use an action to udpate the app state; and a `Middleware` can handle an `Action` to trigger side effects. + +A note of caution: It is important to stress, that the channel to submit actions **is not** intended to be used as an event stream, and should **absolutely not** be misused as such. An `Action` is supposed to be a way for one part of he app to tell another to **do something**. This is **not** a way to notify about events of changes. `NSNotification` and observing the app state are better vehicles for that. + +### Store + +The app has a (root) store that holds onto the `AppState`. It only exposes the `AppState` through an (Incremental) `I` such that various parts of the app can observe change of the state and react upon those changes. + +`Middleware` and `Reducer`s can be registered with the store and any `Action` that gets dispatched will then be send to those. The `Reducer`s are the only ones that are given an opportunity to update the state. Their handler gets an `inout AppState` to operate on. + +As different parts of the app operate on subsets of the all encompasing `AppState`, each part can operate on a `Store` that’s tied to the root store, but only operates on its subset (as noted above). The `Middleware` and `Reducer`s can then also operate on such a store’s subset, which transparently ties into the root store. + +### Navigation + +tbd + +### Parcel + +Each part (usually a Swift module) of an app has a combination of `Reducer`s, `Middleware`, and `PresentationRoute`s to implement its features. All of these needs to be registered with the store. In order to facilitate that, a `Parcel` can wrap a combination of all of these. The parcel can then be registered with the store, which in turn will register all of its `Reducer`s, `Middleware`, and `PresentationRoute`s. + +The benefit of this, is that the app itself only needs to register the `Parcel`s that it uses, and doesn’t have to know which `Reducer`s, `Middleware`, and `PresentationRoute`s it needs. This makes updating code within a specific part / domain of an app easier. If, e.g. a new `Middleware` is added, it just needs to be added to its `Parcel`, and will then automatically be registered with the app. + +Additionally, a module that has a parcel, does not have to expose its `Reducer`s, etc. as `public`. They only need to be added to the module’s `Parcel`. This reduces the API surface of a module.