diff --git a/Examples/PuddlesExampleiOS/PuddlesExampleiOS.xcodeproj/project.pbxproj b/Examples/PuddlesExampleiOS/PuddlesExampleiOS.xcodeproj/project.pbxproj index 4535a37a1..e1d900b3f 100644 --- a/Examples/PuddlesExampleiOS/PuddlesExampleiOS.xcodeproj/project.pbxproj +++ b/Examples/PuddlesExampleiOS/PuddlesExampleiOS.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 9531A73C29681866009D7688 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 9531A74329681889009D7688 /* Puddles */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Puddles; path = ../..; sourceTree = ""; }; 958AF81C296AF6E9004F8E61 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 958AF81D296DF035004F8E61 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 9594A765296819B700063CE7 /* Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Root.swift; sourceTree = ""; }; 9594A76729681A2300063CE7 /* Home.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Home.swift; sourceTree = ""; }; 9594A76B29681AA500063CE7 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; @@ -77,14 +78,14 @@ 9531A73429681866009D7688 /* PuddlesExampleiOS */ = { isa = PBXGroup; children = ( + 958AF81D296DF035004F8E61 /* Info.plist */, 9531A73529681866009D7688 /* ExampleApp.swift */, 958AF81C296AF6E9004F8E61 /* README.md */, - 9594A7802968BF1100063CE7 /* Helper */, 9594A7782968B3D100063CE7 /* Models */, 9594A7752968A2F200063CE7 /* Services */, 9594A764296819A900063CE7 /* Coordinators */, - 9594A76A29681A8400063CE7 /* Share Components */, 9594A76929681A7E00063CE7 /* Views */, + 9594A7802968BF1100063CE7 /* Helper */, 9531A73929681866009D7688 /* Assets.xcassets */, 9531A73B29681866009D7688 /* Preview Content */, ); @@ -123,13 +124,6 @@ path = Views; sourceTree = ""; }; - 9594A76A29681A8400063CE7 /* Share Components */ = { - isa = PBXGroup; - children = ( - ); - path = "Share Components"; - sourceTree = ""; - }; 9594A7752968A2F200063CE7 /* Services */ = { isa = PBXGroup; children = ( @@ -377,6 +371,7 @@ DEVELOPMENT_TEAM = 7F6BJZY5B3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = PuddlesExampleiOS/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -409,6 +404,7 @@ DEVELOPMENT_TEAM = 7F6BJZY5B3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = PuddlesExampleiOS/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Coordinators/Home.swift b/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Coordinators/Home.swift index 2444946bd..3d980092a 100644 --- a/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Coordinators/Home.swift +++ b/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Coordinators/Home.swift @@ -33,6 +33,8 @@ struct Home: Coordinator { let searchResults: HomeView.SearchResultsLoadingState @State var searchQuery: String = "" + @State var showSheet: Bool = false + var entryView: some View { HomeView( interface: viewInterface, @@ -45,22 +47,34 @@ struct Home: Coordinator { .navigationTitle("Events") } - func navigation() -> some NavigationPattern { + func handleDeeplink(url: URL) -> DeepLinkPropagation { + print("received »\(url)«") + viewInterface.sendAction(.searchQueryUpdated("ABCD Test")) + showSheet = true + + return .hasFinished + } + + func navigation() -> some NavigationPattern { + Sheet(isActive: $showSheet) { + Text("OK") + } } func interfaces() -> some InterfaceObservation { - AsyncInterfaceObserver(viewInterface) { action in - await handleViewAction(action) + InterfaceObserver(viewInterface) { action in + handleViewAction(action) } } - func handleViewAction(_ action: HomeView.Action) async { + func handleViewAction(_ action: HomeView.Action) { switch action { case .eventTapped: print("Event Tapped") case .searchQueryUpdated(query: let query): searchQuery = query + // Pass through action to own interface. // The instance responsible for providing searchResults needs to decide on debouncing/throttling etc. interface.sendAction(.searchQueryUpdated(query)) diff --git a/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Coordinators/Root.swift b/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Coordinators/Root.swift index a11f7ef2d..a4c1ad0eb 100644 --- a/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Coordinators/Root.swift +++ b/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Coordinators/Root.swift @@ -42,6 +42,13 @@ struct Root: Coordinator { } } + // To test deep linking, enter the following into a console: + // xcrun simctl openurl booted "puddles://com.something" + func handleDeeplink(url: URL) -> DeepLinkPropagation { + print("received »\(url)«") + return .shouldContinue + } + func start() async { do { self.events = .loading @@ -55,20 +62,22 @@ struct Root: Coordinator { } func interfaces() -> some InterfaceObservation { - AsyncInterfaceObserver(homeInterface) { action in - await handleHomeAction(action) + InterfaceObserver(homeInterface) { action in + handleHomeAction(action) } AsyncChannelObserver(helper.searchChannel) { channel in - for await query in channel.debounce(for: .seconds(0-5)) { + for await query in channel.debounce(for: .seconds(0.5)) { searchEvents(query: query) } } } - func handleHomeAction(_ action: Home.Action) async { + func handleHomeAction(_ action: Home.Action) { switch action { case .searchQueryUpdated(let query): - await helper.searchChannel.send(query) + Task { + await helper.searchChannel.send(query) + } } } diff --git a/Examples/PuddlesExampleiOS/PuddlesExampleiOS/ExampleApp.swift b/Examples/PuddlesExampleiOS/PuddlesExampleiOS/ExampleApp.swift index 2e117ebbd..4a3d28fb9 100644 --- a/Examples/PuddlesExampleiOS/PuddlesExampleiOS/ExampleApp.swift +++ b/Examples/PuddlesExampleiOS/PuddlesExampleiOS/ExampleApp.swift @@ -21,6 +21,7 @@ // import SwiftUI +import Puddles @main struct ExampleApp: App { @@ -30,6 +31,7 @@ struct ExampleApp: App { WindowGroup { Root() .environmentObject(services) + .deepLinkRoot() } } } diff --git a/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Info.plist b/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Info.plist new file mode 100644 index 000000000..01ae62f68 --- /dev/null +++ b/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Info.plist @@ -0,0 +1,19 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + puddlesexample + CFBundleURLSchemes + + puddles + + + + + diff --git a/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Views/HomeView.swift b/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Views/HomeView.swift index f27ed624c..f636cd0ac 100644 --- a/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Views/HomeView.swift +++ b/Examples/PuddlesExampleiOS/PuddlesExampleiOS/Views/HomeView.swift @@ -22,7 +22,6 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { @ObservedObject var interface: Interface @@ -78,7 +77,6 @@ struct HomeView: View { Button(event.name) { } - } .listStyle(.insetGrouped) } @@ -116,7 +114,7 @@ extension HomeView { struct HomeView_Previews: PreviewProvider { static var previews: some View { NavigationView { - Preview(HomeView.init, state: .mock) { action, state in + Preview(HomeView.init, state: .mock) { action, $state in switch action { case .eventTapped: state.events = .loaded(state.events.value! + [.random]) diff --git a/Examples/Quizzles/Quizzles.xcodeproj/project.pbxproj b/Examples/Quizzles/Quizzles.xcodeproj/project.pbxproj new file mode 100644 index 000000000..886d89a05 --- /dev/null +++ b/Examples/Quizzles/Quizzles.xcodeproj/project.pbxproj @@ -0,0 +1,477 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 958AF82B296F232D004F8E61 /* QuizzlesApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958AF82A296F232D004F8E61 /* QuizzlesApp.swift */; }; + 958AF82F296F232E004F8E61 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 958AF82E296F232E004F8E61 /* Assets.xcassets */; }; + 958AF832296F232E004F8E61 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 958AF831296F232E004F8E61 /* Preview Assets.xcassets */; }; + 958AF83B296F235B004F8E61 /* Puddles in Frameworks */ = {isa = PBXBuildFile; productRef = 958AF83A296F235B004F8E61 /* Puddles */; }; + 958AF842296F2409004F8E61 /* Quiz.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958AF841296F2409004F8E61 /* Quiz.swift */; }; + 958AF844296F243B004F8E61 /* Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958AF843296F243B004F8E61 /* Root.swift */; }; + 958AF846296F24A7004F8E61 /* QuizList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958AF845296F24A7004F8E61 /* QuizList.swift */; }; + 958AF848296F2500004F8E61 /* QuizListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958AF847296F2500004F8E61 /* QuizListView.swift */; }; + 958AF84B296F27C8004F8E61 /* LoadingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958AF84A296F27C8004F8E61 /* LoadingState.swift */; }; + 958AF84E296F27E6004F8E61 /* Array+repeating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958AF84C296F27E6004F8E61 /* Array+repeating.swift */; }; + 958AF84F296F27E6004F8E61 /* Faker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958AF84D296F27E6004F8E61 /* Faker.swift */; }; + 958AF852296F27F4004F8E61 /* Fakery in Frameworks */ = {isa = PBXBuildFile; productRef = 958AF851296F27F4004F8E61 /* Fakery */; }; + 958AF856296F2F41004F8E61 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958AF855296F2F41004F8E61 /* LoadingView.swift */; }; + 958AF858296F3256004F8E61 /* PreviewDebugTools in Frameworks */ = {isa = PBXBuildFile; productRef = 958AF857296F3256004F8E61 /* PreviewDebugTools */; }; + 958AF85A296F37F6004F8E61 /* QuizCreationSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958AF859296F37F6004F8E61 /* QuizCreationSheet.swift */; }; + 958AF85D296F3EE2004F8E61 /* QuizCreationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 958AF85C296F3EE2004F8E61 /* QuizCreationView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 958AF827296F232D004F8E61 /* Quizzles.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Quizzles.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 958AF82A296F232D004F8E61 /* QuizzlesApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizzlesApp.swift; sourceTree = ""; }; + 958AF82E296F232E004F8E61 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 958AF831296F232E004F8E61 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 958AF838296F233E004F8E61 /* Puddles */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Puddles; path = ../..; sourceTree = ""; }; + 958AF841296F2409004F8E61 /* Quiz.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quiz.swift; sourceTree = ""; }; + 958AF843296F243B004F8E61 /* Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Root.swift; sourceTree = ""; }; + 958AF845296F24A7004F8E61 /* QuizList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizList.swift; sourceTree = ""; }; + 958AF847296F2500004F8E61 /* QuizListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizListView.swift; sourceTree = ""; }; + 958AF84A296F27C8004F8E61 /* LoadingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingState.swift; sourceTree = ""; }; + 958AF84C296F27E6004F8E61 /* Array+repeating.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Array+repeating.swift"; path = "../../../PuddlesExampleiOS/PuddlesExampleiOS/Helper/Array+repeating.swift"; sourceTree = ""; }; + 958AF84D296F27E6004F8E61 /* Faker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Faker.swift; path = ../../../PuddlesExampleiOS/PuddlesExampleiOS/Helper/Faker.swift; sourceTree = ""; }; + 958AF855296F2F41004F8E61 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; + 958AF859296F37F6004F8E61 /* QuizCreationSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizCreationSheet.swift; sourceTree = ""; }; + 958AF85B296F382B004F8E61 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 958AF85C296F3EE2004F8E61 /* QuizCreationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuizCreationView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 958AF824296F232D004F8E61 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 958AF852296F27F4004F8E61 /* Fakery in Frameworks */, + 958AF858296F3256004F8E61 /* PreviewDebugTools in Frameworks */, + 958AF83B296F235B004F8E61 /* Puddles in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 958AF81E296F232D004F8E61 = { + isa = PBXGroup; + children = ( + 958AF838296F233E004F8E61 /* Puddles */, + 958AF829296F232D004F8E61 /* Quizzles */, + 958AF828296F232D004F8E61 /* Products */, + 958AF839296F235B004F8E61 /* Frameworks */, + ); + sourceTree = ""; + }; + 958AF828296F232D004F8E61 /* Products */ = { + isa = PBXGroup; + children = ( + 958AF827296F232D004F8E61 /* Quizzles.app */, + ); + name = Products; + sourceTree = ""; + }; + 958AF829296F232D004F8E61 /* Quizzles */ = { + isa = PBXGroup; + children = ( + 958AF85B296F382B004F8E61 /* Info.plist */, + 958AF82A296F232D004F8E61 /* QuizzlesApp.swift */, + 958AF840296F2404004F8E61 /* Models */, + 958AF83E296F23FB004F8E61 /* Coordinators */, + 958AF83F296F2400004F8E61 /* Views */, + 958AF82E296F232E004F8E61 /* Assets.xcassets */, + 958AF849296F27C0004F8E61 /* Helper */, + 958AF830296F232E004F8E61 /* Preview Content */, + ); + path = Quizzles; + sourceTree = ""; + }; + 958AF830296F232E004F8E61 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 958AF831296F232E004F8E61 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 958AF839296F235B004F8E61 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 958AF83E296F23FB004F8E61 /* Coordinators */ = { + isa = PBXGroup; + children = ( + 958AF843296F243B004F8E61 /* Root.swift */, + 958AF845296F24A7004F8E61 /* QuizList.swift */, + 958AF859296F37F6004F8E61 /* QuizCreationSheet.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; + 958AF83F296F2400004F8E61 /* Views */ = { + isa = PBXGroup; + children = ( + 958AF854296F2F35004F8E61 /* Shared Components */, + 958AF847296F2500004F8E61 /* QuizListView.swift */, + 958AF85C296F3EE2004F8E61 /* QuizCreationView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 958AF840296F2404004F8E61 /* Models */ = { + isa = PBXGroup; + children = ( + 958AF841296F2409004F8E61 /* Quiz.swift */, + ); + path = Models; + sourceTree = ""; + }; + 958AF849296F27C0004F8E61 /* Helper */ = { + isa = PBXGroup; + children = ( + 958AF84A296F27C8004F8E61 /* LoadingState.swift */, + 958AF84C296F27E6004F8E61 /* Array+repeating.swift */, + 958AF84D296F27E6004F8E61 /* Faker.swift */, + ); + path = Helper; + sourceTree = ""; + }; + 958AF854296F2F35004F8E61 /* Shared Components */ = { + isa = PBXGroup; + children = ( + 958AF855296F2F41004F8E61 /* LoadingView.swift */, + ); + path = "Shared Components"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 958AF826296F232D004F8E61 /* Quizzles */ = { + isa = PBXNativeTarget; + buildConfigurationList = 958AF835296F232E004F8E61 /* Build configuration list for PBXNativeTarget "Quizzles" */; + buildPhases = ( + 958AF823296F232D004F8E61 /* Sources */, + 958AF824296F232D004F8E61 /* Frameworks */, + 958AF825296F232D004F8E61 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Quizzles; + packageProductDependencies = ( + 958AF83A296F235B004F8E61 /* Puddles */, + 958AF851296F27F4004F8E61 /* Fakery */, + 958AF857296F3256004F8E61 /* PreviewDebugTools */, + ); + productName = Quizzles; + productReference = 958AF827296F232D004F8E61 /* Quizzles.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 958AF81F296F232D004F8E61 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1420; + LastUpgradeCheck = 1420; + TargetAttributes = { + 958AF826296F232D004F8E61 = { + CreatedOnToolsVersion = 14.2; + }; + }; + }; + buildConfigurationList = 958AF822296F232D004F8E61 /* Build configuration list for PBXProject "Quizzles" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 958AF81E296F232D004F8E61; + packageReferences = ( + 958AF850296F27F4004F8E61 /* XCRemoteSwiftPackageReference "Fakery" */, + ); + productRefGroup = 958AF828296F232D004F8E61 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 958AF826296F232D004F8E61 /* Quizzles */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 958AF825296F232D004F8E61 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 958AF832296F232E004F8E61 /* Preview Assets.xcassets in Resources */, + 958AF82F296F232E004F8E61 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 958AF823296F232D004F8E61 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 958AF84B296F27C8004F8E61 /* LoadingState.swift in Sources */, + 958AF844296F243B004F8E61 /* Root.swift in Sources */, + 958AF82B296F232D004F8E61 /* QuizzlesApp.swift in Sources */, + 958AF85A296F37F6004F8E61 /* QuizCreationSheet.swift in Sources */, + 958AF848296F2500004F8E61 /* QuizListView.swift in Sources */, + 958AF846296F24A7004F8E61 /* QuizList.swift in Sources */, + 958AF84E296F27E6004F8E61 /* Array+repeating.swift in Sources */, + 958AF842296F2409004F8E61 /* Quiz.swift in Sources */, + 958AF84F296F27E6004F8E61 /* Faker.swift in Sources */, + 958AF85D296F3EE2004F8E61 /* QuizCreationView.swift in Sources */, + 958AF856296F2F41004F8E61 /* LoadingView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 958AF833296F232E004F8E61 /* 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++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + 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 = 16.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; + }; + 958AF834296F232E004F8E61 /* 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++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + 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 = 16.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; + }; + 958AF836296F232E004F8E61 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Quizzles/Preview Content\""; + DEVELOPMENT_TEAM = 7F6BJZY5B3; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Quizzles/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftedmind.puddles.Quizzles; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 958AF837296F232E004F8E61 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Quizzles/Preview Content\""; + DEVELOPMENT_TEAM = 7F6BJZY5B3; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Quizzles/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftedmind.puddles.Quizzles; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 958AF822296F232D004F8E61 /* Build configuration list for PBXProject "Quizzles" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 958AF833296F232E004F8E61 /* Debug */, + 958AF834296F232E004F8E61 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 958AF835296F232E004F8E61 /* Build configuration list for PBXNativeTarget "Quizzles" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 958AF836296F232E004F8E61 /* Debug */, + 958AF837296F232E004F8E61 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 958AF850296F27F4004F8E61 /* XCRemoteSwiftPackageReference "Fakery" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/vadymmarkov/Fakery"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 958AF83A296F235B004F8E61 /* Puddles */ = { + isa = XCSwiftPackageProductDependency; + productName = Puddles; + }; + 958AF851296F27F4004F8E61 /* Fakery */ = { + isa = XCSwiftPackageProductDependency; + package = 958AF850296F27F4004F8E61 /* XCRemoteSwiftPackageReference "Fakery" */; + productName = Fakery; + }; + 958AF857296F3256004F8E61 /* PreviewDebugTools */ = { + isa = XCSwiftPackageProductDependency; + productName = PreviewDebugTools; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 958AF81F296F232D004F8E61 /* Project object */; +} diff --git a/Examples/Quizzles/Quizzles.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/Quizzles/Quizzles.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Examples/Quizzles/Quizzles.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/Quizzles/Quizzles.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/Quizzles/Quizzles.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Examples/Quizzles/Quizzles.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Examples/Quizzles/Quizzles.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Quizzles/Quizzles.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..c37f34993 --- /dev/null +++ b/Examples/Quizzles/Quizzles.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,50 @@ +{ + "pins" : [ + { + "identity" : "fakery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vadymmarkov/Fakery", + "state" : { + "branch" : "master", + "revision" : "fb7ca285377a2163867fa2c2e61d06d5f6eaedfb" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "branch" : "main", + "revision" : "a07a3f41b5165e73757de6a30dc8eec81a0e356f" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "branch" : "main", + "revision" : "a3217706ad049ca058743003e065767773cc56cc" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "branch" : "main", + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34" + } + } + ], + "version" : 2 +} diff --git a/Examples/Quizzles/Quizzles.xcodeproj/xcshareddata/IDETemplateMacros.plist b/Examples/Quizzles/Quizzles.xcodeproj/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 000000000..e1fbfbcf1 --- /dev/null +++ b/Examples/Quizzles/Quizzles.xcodeproj/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,28 @@ + + + + + FILEHEADER + +// Copyright © ___YEAR___ ___FULLUSERNAME___ and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + + diff --git a/Examples/Quizzles/Quizzles/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/Quizzles/Quizzles/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Examples/Quizzles/Quizzles/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Quizzles/Quizzles/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/Quizzles/Quizzles/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..13613e3ee --- /dev/null +++ b/Examples/Quizzles/Quizzles/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Quizzles/Quizzles/Assets.xcassets/Contents.json b/Examples/Quizzles/Quizzles/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Examples/Quizzles/Quizzles/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Quizzles/Quizzles/Coordinators/QuizCreationSheet.swift b/Examples/Quizzles/Quizzles/Coordinators/QuizCreationSheet.swift new file mode 100644 index 000000000..b80506087 --- /dev/null +++ b/Examples/Quizzles/Quizzles/Coordinators/QuizCreationSheet.swift @@ -0,0 +1,88 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI +import Combine +import Puddles + +struct QuizCreationSheet: Coordinator { + @Environment(\.dismiss) private var dismiss + @StateObject var viewInterface: Interface = .init() + + let onFinish: (_ createdQuiz: Quiz?) -> Void + + @State private var draftQuiz: Quiz = .draft + + var viewState: QuizCreationView.ViewState { + .init( + quiz: draftQuiz + ) + } + + func modify(coordinator: CoordinatorContent) -> some View { + NavigationStack { + coordinator + } + .interactiveDismissDisabled() + } + + var entryView: some View { + QuizCreationView(interface: viewInterface, state: viewState) + .navigationTitle("New Quiz") + .toolbar { + toolbar + } + } + + func navigation() -> some NavigationPattern { + + } + + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: QuizCreationView.Action) { + switch action { + case .quizNameChanged(let newName): + draftQuiz.name = newName + } + } + + @ToolbarContentBuilder private var toolbar: some ToolbarContent { + ToolbarItem { + Button("Cancel", role: .cancel) { + onFinish(nil) + dismiss() + } + } + ToolbarItem(placement: .primaryAction) { + Button("Save") { + onFinish(draftQuiz) + dismiss() + } + .bold() + } + } +} diff --git a/Examples/Quizzles/Quizzles/Coordinators/QuizList.swift b/Examples/Quizzles/Quizzles/Coordinators/QuizList.swift new file mode 100644 index 000000000..442fe42b8 --- /dev/null +++ b/Examples/Quizzles/Quizzles/Coordinators/QuizList.swift @@ -0,0 +1,91 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI +import Combine +import Puddles + +struct QuizList: Coordinator { + @StateObject var viewInterface: Interface = .init() + + @Queryable private var quizCreation + let quizzes: QuizListView.Quizzes + + var viewState: QuizListView.ViewState { + .init( + quizzes: quizzes + ) + } + + var entryView: some View { + QuizListView(interface: viewInterface, state: viewState) + .navigationTitle("Quizzes") + .toolbar { toolbar } + } + + func navigation() -> some NavigationPattern { + QueryControlled(by: quizCreation) { isActive, query in + Sheet(isActive: isActive) { + QuizCreationSheet { createdQuiz in + query.answer(withOptional: createdQuiz) + } + } + } + } + + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + + } + } + + private func handleViewAction(_ action: QuizListView.Action) async { + switch action { + case .quizTapped: + break + } + } + + func handleDeeplink(url: URL) -> DeepLinkPropagation { + .shouldContinue + } + + @ToolbarContentBuilder private var toolbar: some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + queryQuizCreation() + } label: { + Image(systemName: "plus") + } + } + } + + private func queryQuizCreation() { + Task { + do { + let quiz = try await quizCreation.query() + print(quiz) + } catch QueryError.queryCancelled { + } catch {} + } + } +} diff --git a/Examples/Quizzles/Quizzles/Coordinators/Root.swift b/Examples/Quizzles/Quizzles/Coordinators/Root.swift new file mode 100644 index 000000000..40b2ddca6 --- /dev/null +++ b/Examples/Quizzles/Quizzles/Coordinators/Root.swift @@ -0,0 +1,47 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI +import Puddles + +struct Root: Coordinator { + + @State var quizzes: QuizListView.Quizzes = .loaded(.repeating(.mock, count: 5)) + + var entryView: some View { + QuizList(quizzes: quizzes) + } + + func modify(coordinator: CoordinatorContent) -> some View { + NavigationStack { + coordinator + } + } + + func navigation() -> some NavigationPattern { + + } + + func interfaces() -> some InterfaceObservation { + + } +} diff --git a/Examples/Quizzles/Quizzles/Helper/LoadingState.swift b/Examples/Quizzles/Quizzles/Helper/LoadingState.swift new file mode 100644 index 000000000..3c3168802 --- /dev/null +++ b/Examples/Quizzles/Quizzles/Helper/LoadingState.swift @@ -0,0 +1,85 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +public enum LoadingState { + case initial + case loading + case loaded(Value) + case failure(Error) + + public var value: Value? { + switch self { + case .initial, .loading, .failure: + return nil + case .loaded(let value): + return value + } + } + + public var isLoading: Bool { + switch self { + case .initial, .loaded, .failure: + return false + case .loading: + return true + } + } + + public var isFailure: Bool { + switch self { + case .initial, .loaded, .loading: + return false + case .failure: + return true + } + } + + public var isLoaded: Bool { + switch self { + case .initial, .failure, .loading: + return false + case .loaded: + return true + } + } +} + + +extension LoadingState: Equatable where Value: Equatable { + + public static func == (lhs: LoadingState, rhs: LoadingState) -> Bool { + switch (lhs, rhs) { + case (.initial, .initial): + return true + case (.loading, .loading): + return true + case (.loaded(let left), .loaded(let right)): + return left == right + case (.failure, .failure): + return true // Technically wrong! + default: + return false + } + } +} diff --git a/Examples/Quizzles/Quizzles/Info.plist b/Examples/Quizzles/Quizzles/Info.plist new file mode 100644 index 000000000..0843d3920 --- /dev/null +++ b/Examples/Quizzles/Quizzles/Info.plist @@ -0,0 +1,19 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + Quizzles Link + CFBundleURLSchemes + + quizzles + + + + + diff --git a/Examples/Quizzles/Quizzles/Models/Quiz.swift b/Examples/Quizzles/Quizzles/Models/Quiz.swift new file mode 100644 index 000000000..d993010bd --- /dev/null +++ b/Examples/Quizzles/Quizzles/Models/Quiz.swift @@ -0,0 +1,39 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +public struct Quiz: Identifiable, Hashable, Sendable { + public var id: UUID = .init() + public var name: String +} + +public extension Quiz { + + static var draft: Quiz { + .init(name: "") + } + + static var mock: Quiz { + .init(name: faker.name.name()) + } +} diff --git a/Examples/Quizzles/Quizzles/Preview Content/Preview Assets.xcassets/Contents.json b/Examples/Quizzles/Quizzles/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Examples/Quizzles/Quizzles/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Quizzles/Quizzles/QuizzlesApp.swift b/Examples/Quizzles/Quizzles/QuizzlesApp.swift new file mode 100644 index 000000000..38d260f8a --- /dev/null +++ b/Examples/Quizzles/Quizzles/QuizzlesApp.swift @@ -0,0 +1,39 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI +import Puddles + +@main +struct QuizzlesApp: App { + + init() { + Puddles.configureLog() + } + + var body: some Scene { + WindowGroup { + Root() + .deepLinkRoot() + } + } +} diff --git a/Examples/Quizzles/Quizzles/Views/QuizCreationView.swift b/Examples/Quizzles/Quizzles/Views/QuizCreationView.swift new file mode 100644 index 000000000..b9f5cfa3a --- /dev/null +++ b/Examples/Quizzles/Quizzles/Views/QuizCreationView.swift @@ -0,0 +1,76 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI +import Puddles +import PreviewDebugTools +import Combine + +struct QuizCreationView: View { + @ObservedObject var interface: Interface + + let state: ViewState + + @State private var name: String = "" + private var nameBinding: Binding { + .init { + state.quiz.name + } set: { newValue in +// name = newValue + interface.sendAction(.quizNameChanged(newName: newValue)) + } + } + + var body: some View { + Form { + TextField("Quiz Name", text: nameBinding) + } + } +} + +extension QuizCreationView { + struct ViewState { + var quiz: Quiz + + static var mock: ViewState { + .init(quiz: .mock) + } + } + + enum Action { + case quizNameChanged(newName: String) + } +} + +struct QuizCreationView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + Preview(QuizCreationView.init, state: .mock) { action, $state in + switch action { + case .quizNameChanged(let newName): + break + } + } + .navigationTitle("Quizzes") + } + } +} diff --git a/Examples/Quizzles/Quizzles/Views/QuizListView.swift b/Examples/Quizzles/Quizzles/Views/QuizListView.swift new file mode 100644 index 000000000..b24a349f7 --- /dev/null +++ b/Examples/Quizzles/Quizzles/Views/QuizListView.swift @@ -0,0 +1,97 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI +import Puddles +import PreviewDebugTools +import Combine + +struct QuizListView: View { + @ObservedObject var interface: Interface + let state: ViewState + + var body: some View { + List { + switch state.quizzes { + case .initial, .loading: + LoadingView() + case .failure: + Text("Fehler") + case .loaded(let quizzes): + loadedContent(quizzes: quizzes) + } + } + .animation(.default, value: state.quizzes) + } + + @ViewBuilder private func loadedContent(quizzes: [Quiz]) -> some View { + ForEach(quizzes) { quiz in + Button(quiz.name) { + interface.sendAction(.quizTapped(quiz)) + } + } + } +} + +extension QuizListView { + + typealias Quizzes = LoadingState<[Quiz], Swift.Error> + + struct ViewState { + var quizzes: Quizzes + + static var mock: ViewState { + .init(quizzes: .loaded(.repeating(.mock, count: 5))) + } + } + + enum Action { + case quizTapped(Quiz) + } +} + +struct QuizListView_Previews: PreviewProvider { + static var previews: some View { + NavigationStack { + Preview(QuizListView.init, state: .mock) { action, state in + + } + .overlay(alignment: .bottom) { $state in + HStack { + DebugButton("Loading") { + state.quizzes = .loading + } + .disabled(state.quizzes.isLoading) + DebugButton("Error") { + state.quizzes = .failure(.debug) + } + .disabled(state.quizzes.isFailure) + DebugButton("Success") { + state.quizzes = .loaded(.repeating(.mock, count: 5)) + } + .disabled(state.quizzes.isLoaded) + } + } + .navigationTitle("Quizzes") + } + } +} diff --git a/Examples/Quizzles/Quizzles/Views/Shared Components/LoadingView.swift b/Examples/Quizzles/Quizzles/Views/Shared Components/LoadingView.swift new file mode 100644 index 000000000..f47bbc57e --- /dev/null +++ b/Examples/Quizzles/Quizzles/Views/Shared Components/LoadingView.swift @@ -0,0 +1,36 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI + +struct LoadingView: View { + var body: some View { + ProgressView() + .frame(maxWidth: .infinity) + } +} + +struct LoadingView_Previews: PreviewProvider { + static var previews: some View { + LoadingView() + } +} diff --git a/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.pbxproj b/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.pbxproj new file mode 100644 index 000000000..9dd97f0cf --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.pbxproj @@ -0,0 +1,401 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 95D666BF29719BC700708286 /* FirstChapterApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95D666BE29719BC700708286 /* FirstChapterApp.swift */; }; + 95D666C329719BC700708286 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 95D666C229719BC700708286 /* Assets.xcassets */; }; + 95D666C629719BC700708286 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 95D666C529719BC700708286 /* Preview Assets.xcassets */; }; + 95D666CF29719BE600708286 /* PreviewDebugTools in Frameworks */ = {isa = PBXBuildFile; productRef = 95D666CE29719BE600708286 /* PreviewDebugTools */; }; + 95D666D129719BE600708286 /* Puddles in Frameworks */ = {isa = PBXBuildFile; productRef = 95D666D029719BE600708286 /* Puddles */; }; + 95D666D329719C1100708286 /* Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95D666D229719C1100708286 /* Root.swift */; }; + 95D666D529719CCD00708286 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95D666D429719CCD00708286 /* HomeView.swift */; }; + 95D666DB2971B6D200708286 /* QueryableDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95D666DA2971B6D200708286 /* QueryableDemo.swift */; }; + 95D666DD2971B71000708286 /* QueryableDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95D666DC2971B71000708286 /* QueryableDemoView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 95D666BB29719BC700708286 /* FirstChapter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FirstChapter.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 95D666BE29719BC700708286 /* FirstChapterApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstChapterApp.swift; sourceTree = ""; }; + 95D666C229719BC700708286 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 95D666C529719BC700708286 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 95D666CC29719BDA00708286 /* Puddles */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Puddles; path = ../../..; sourceTree = ""; }; + 95D666D229719C1100708286 /* Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Root.swift; sourceTree = ""; }; + 95D666D429719CCD00708286 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 95D666DA2971B6D200708286 /* QueryableDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryableDemo.swift; sourceTree = ""; }; + 95D666DC2971B71000708286 /* QueryableDemoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryableDemoView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 95D666B829719BC700708286 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 95D666D129719BE600708286 /* Puddles in Frameworks */, + 95D666CF29719BE600708286 /* PreviewDebugTools in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 95D666B229719BC700708286 = { + isa = PBXGroup; + children = ( + 95D666CC29719BDA00708286 /* Puddles */, + 95D666BD29719BC700708286 /* FirstChapter */, + 95D666BC29719BC700708286 /* Products */, + 95D666CD29719BE600708286 /* Frameworks */, + ); + sourceTree = ""; + }; + 95D666BC29719BC700708286 /* Products */ = { + isa = PBXGroup; + children = ( + 95D666BB29719BC700708286 /* FirstChapter.app */, + ); + name = Products; + sourceTree = ""; + }; + 95D666BD29719BC700708286 /* FirstChapter */ = { + isa = PBXGroup; + children = ( + 95D666BE29719BC700708286 /* FirstChapterApp.swift */, + 95D666D729719FD700708286 /* Coordinators */, + 95D666D629719CD100708286 /* Views */, + 95D666C229719BC700708286 /* Assets.xcassets */, + 95D666C429719BC700708286 /* Preview Content */, + ); + path = FirstChapter; + sourceTree = ""; + }; + 95D666C429719BC700708286 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 95D666C529719BC700708286 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 95D666CD29719BE600708286 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 95D666D629719CD100708286 /* Views */ = { + isa = PBXGroup; + children = ( + 95D666D429719CCD00708286 /* HomeView.swift */, + 95D666DC2971B71000708286 /* QueryableDemoView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 95D666D729719FD700708286 /* Coordinators */ = { + isa = PBXGroup; + children = ( + 95D666D229719C1100708286 /* Root.swift */, + 95D666DA2971B6D200708286 /* QueryableDemo.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 95D666BA29719BC700708286 /* FirstChapter */ = { + isa = PBXNativeTarget; + buildConfigurationList = 95D666C929719BC700708286 /* Build configuration list for PBXNativeTarget "FirstChapter" */; + buildPhases = ( + 95D666B729719BC700708286 /* Sources */, + 95D666B829719BC700708286 /* Frameworks */, + 95D666B929719BC700708286 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = FirstChapter; + packageProductDependencies = ( + 95D666CE29719BE600708286 /* PreviewDebugTools */, + 95D666D029719BE600708286 /* Puddles */, + ); + productName = FirstChapter; + productReference = 95D666BB29719BC700708286 /* FirstChapter.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 95D666B329719BC700708286 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1420; + LastUpgradeCheck = 1420; + TargetAttributes = { + 95D666BA29719BC700708286 = { + CreatedOnToolsVersion = 14.2; + }; + }; + }; + buildConfigurationList = 95D666B629719BC700708286 /* Build configuration list for PBXProject "FirstChapter" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 95D666B229719BC700708286; + productRefGroup = 95D666BC29719BC700708286 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 95D666BA29719BC700708286 /* FirstChapter */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 95D666B929719BC700708286 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 95D666C629719BC700708286 /* Preview Assets.xcassets in Resources */, + 95D666C329719BC700708286 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 95D666B729719BC700708286 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 95D666D529719CCD00708286 /* HomeView.swift in Sources */, + 95D666DD2971B71000708286 /* QueryableDemoView.swift in Sources */, + 95D666DB2971B6D200708286 /* QueryableDemo.swift in Sources */, + 95D666BF29719BC700708286 /* FirstChapterApp.swift in Sources */, + 95D666D329719C1100708286 /* Root.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 95D666C729719BC700708286 /* 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++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + 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 = 16.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; + }; + 95D666C829719BC700708286 /* 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++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + 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 = 16.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; + }; + 95D666CA29719BC700708286 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"FirstChapter/Preview Content\""; + DEVELOPMENT_TEAM = 7F6BJZY5B3; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftedmind.puddles.tutorials.FirstChapter; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 95D666CB29719BC700708286 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"FirstChapter/Preview Content\""; + DEVELOPMENT_TEAM = 7F6BJZY5B3; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftedmind.puddles.tutorials.FirstChapter; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 95D666B629719BC700708286 /* Build configuration list for PBXProject "FirstChapter" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 95D666C729719BC700708286 /* Debug */, + 95D666C829719BC700708286 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 95D666C929719BC700708286 /* Build configuration list for PBXNativeTarget "FirstChapter" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 95D666CA29719BC700708286 /* Debug */, + 95D666CB29719BC700708286 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 95D666CE29719BE600708286 /* PreviewDebugTools */ = { + isa = XCSwiftPackageProductDependency; + productName = PreviewDebugTools; + }; + 95D666D029719BE600708286 /* Puddles */ = { + isa = XCSwiftPackageProductDependency; + productName = Puddles; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 95D666B329719BC700708286 /* Project object */; +} diff --git a/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..f269a2cb3 --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "branch" : "main", + "revision" : "0ebc8058fc12663699fd364f94193ffc7322840f" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "branch" : "main", + "revision" : "a3217706ad049ca058743003e065767773cc56cc" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "branch" : "main", + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34" + } + } + ], + "version" : 2 +} diff --git a/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/xcshareddata/IDETemplateMacros.plist b/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 000000000..e1fbfbcf1 --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter.xcodeproj/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,28 @@ + + + + + FILEHEADER + +// Copyright © ___YEAR___ ___FULLUSERNAME___ and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + + diff --git a/Examples/Tutorials/FirstChapter/FirstChapter/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/Tutorials/FirstChapter/FirstChapter/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Tutorials/FirstChapter/FirstChapter/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/Tutorials/FirstChapter/FirstChapter/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..13613e3ee --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Tutorials/FirstChapter/FirstChapter/Assets.xcassets/Contents.json b/Examples/Tutorials/FirstChapter/FirstChapter/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Tutorials/FirstChapter/FirstChapter/Coordinators/QueryableDemo.swift b/Examples/Tutorials/FirstChapter/FirstChapter/Coordinators/QueryableDemo.swift new file mode 100644 index 000000000..b81c7c538 --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter/Coordinators/QueryableDemo.swift @@ -0,0 +1,83 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI +import Puddles + +struct QueryableDemo: Coordinator { + @StateObject var viewInterface: Interface = .init() + + @Queryable private var deletionConfirmation + + var viewState: QueryableDemoView.ViewState { + .init( + + ) + } + + var entryView: some View { + QueryableDemoView(interface: viewInterface, state: viewState) + } + + func navigation() -> some NavigationPattern { + QueryControlled(by: deletionConfirmation) { isActive, query in + Alert( + title: "Do you want to delete this?", + isPresented: isActive) { + Button("Cancel", role: .cancel) { + query.answer(with: false) + } + Button("OK") { + query.answer(with: true) + } + } message: { + Text("This cannot be reversed!") + } + } + } + + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: QueryableDemoView.Action) { + switch action { + case .deleteButtonTapped: + Task { + do { + if try await deletionConfirmation.query() { + delete() + } + } catch { + // Error Handling + } + } + } + } + + private func delete() { + print("Delete") + } + +} diff --git a/Examples/Tutorials/FirstChapter/FirstChapter/Coordinators/Root.swift b/Examples/Tutorials/FirstChapter/FirstChapter/Coordinators/Root.swift new file mode 100644 index 000000000..6fa0176dc --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter/Coordinators/Root.swift @@ -0,0 +1,87 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI +import Puddles + +struct Root: Coordinator { + @StateObject var viewInterface: Interface = .init() + @State var buttonTapCount: Int = 40 + + @State private var isShowingPage: Bool = false + @State private var isShowingQueryableDemo: Bool = false + + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } + + var entryView: some View { + HomeView(interface: viewInterface, state: viewState) + .navigationTitle("Home") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Queryable Demo") { + isShowingQueryableDemo = true + } + } + } + } + + + func modify(coordinator: CoordinatorContent) -> some View { + NavigationStack { + coordinator + } + } + + func navigation() -> some NavigationPattern { + Push(isActive: $isShowingPage) { + Text("🎉") + .font(.largeTitle) + .navigationTitle("Yay!") + } + Sheet(isActive: $isShowingQueryableDemo) { + QueryableDemo() + } + } + + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: HomeView.Action) { + switch action { + case .buttonTaped: + buttonTapCount += 1 + if buttonTapCount == 42 { + isShowingPage = true + } + } + } + +} + diff --git a/Examples/Tutorials/FirstChapter/FirstChapter/FirstChapterApp.swift b/Examples/Tutorials/FirstChapter/FirstChapter/FirstChapterApp.swift new file mode 100644 index 000000000..ef75ac09e --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter/FirstChapterApp.swift @@ -0,0 +1,32 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI + +@main +struct FirstChapterApp: App { + var body: some Scene { + WindowGroup { + Root() + } + } +} diff --git a/Examples/Tutorials/FirstChapter/FirstChapter/Preview Content/Preview Assets.xcassets/Contents.json b/Examples/Tutorials/FirstChapter/FirstChapter/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Tutorials/FirstChapter/FirstChapter/Views/HomeView.swift b/Examples/Tutorials/FirstChapter/FirstChapter/Views/HomeView.swift new file mode 100644 index 000000000..31fff8905 --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter/Views/HomeView.swift @@ -0,0 +1,79 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import SwiftUI +import Puddles + +struct HomeView: View { + @ObservedObject var interface: Interface + let state: ViewState + + var body: some View { + VStack { + Text("Button tapped \(state.buttonTapCount) times.") + Button("Tap Me") { + interface.sendAction(.buttonTaped) + } + } + } + +} + +extension HomeView { + + struct ViewState { + var buttonTapCount: Int + + init(buttonTapCount: Int = 0) { + self.buttonTapCount = buttonTapCount + } + + static var mock: ViewState { + .init(buttonTapCount: 10) + } + } + + enum Action { + case buttonTaped + } +} + +struct HomeView_Previews: PreviewProvider { + static var previews: some View { + Preview(HomeView.init, state: .mock) { action, $state in + switch action { + case .buttonTaped: + state.buttonTapCount += 1 + } + } + .fullScreenPreview() + .onStart { $state in + try? await Task.sleep(nanoseconds: 2_000_000_000) + state.buttonTapCount = 40 + } + .overlay(alignment: .bottom) { $state in + Button("Reset") { + state.buttonTapCount = 0 + } + } + } +} diff --git a/Examples/Tutorials/FirstChapter/FirstChapter/Views/QueryableDemoView.swift b/Examples/Tutorials/FirstChapter/FirstChapter/Views/QueryableDemoView.swift new file mode 100644 index 000000000..3a28bc948 --- /dev/null +++ b/Examples/Tutorials/FirstChapter/FirstChapter/Views/QueryableDemoView.swift @@ -0,0 +1,55 @@ +// +// Copyright © 2023 Dennis Müller and all collaborators +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +import SwiftUI +import Puddles + +struct QueryableDemoView: View { + @ObservedObject var interface: Interface + let state: ViewState + + var body: some View { + Button("Delete") { + interface.sendAction(.deleteButtonTapped) + } + } + +} + +extension QueryableDemoView { + + struct ViewState { + + } + + enum Action { + case deleteButtonTapped + } +} + + +struct QueryableDemoView_Previews: PreviewProvider { + static var previews: some View { + Preview(QueryableDemoView.init, state: .init()) { action, $state in + + } + } +} diff --git a/Package.swift b/Package.swift index fce5b8193..f5fc35952 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,8 @@ let package = Package( .library( name: "Puddles", targets: ["Puddles"] - ) + ), + .library(name: "PreviewDebugTools", targets: ["PreviewDebugTools"]) ], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", branch: "main"), @@ -23,6 +24,12 @@ let package = Package( dependencies: [ .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), ] + ), + .target( + name: "PreviewDebugTools", + dependencies: [ + "Puddles" + ] ) ] ) diff --git a/Sources/PreviewDebugTools/DebugError.swift b/Sources/PreviewDebugTools/DebugError.swift new file mode 100644 index 000000000..486cde5c6 --- /dev/null +++ b/Sources/PreviewDebugTools/DebugError.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct DebugError: Error { + public init() {} +} + +public extension Error where Self == DebugError { + static var debug: DebugError { + .init() + } +} diff --git a/Sources/PreviewDebugTools/Preview+DebugButton.swift b/Sources/PreviewDebugTools/Preview+DebugButton.swift new file mode 100644 index 000000000..cf33b29c9 --- /dev/null +++ b/Sources/PreviewDebugTools/Preview+DebugButton.swift @@ -0,0 +1,38 @@ +import SwiftUI +import Puddles + +public struct DebugButton: View { + + var title: String + var action: () -> Void + + public init(_ title: String, action: @escaping () -> Void) { + self.title = title + self.action = action + } + + public var body: some View { + Button(action: { + action() + }, label: { + Text(title) + .minimumScaleFactor(0.5) + .padding(6) + .background { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .foregroundColor(Color(uiColor: .systemRed)) + .opacity(0.8) + } + .shadow(radius: 5) + }) + .buttonStyle(.plain) + } +} + + + +public struct PreviewDebugButton_Previews: PreviewProvider { + public static var previews: some View { + DebugButton("Test") {} + } +} diff --git a/Sources/Puddles/Coordinator+Body.swift b/Sources/Puddles/Coordinator+Body.swift index 654fa1196..af7314c53 100644 --- a/Sources/Puddles/Coordinator+Body.swift +++ b/Sources/Puddles/Coordinator+Body.swift @@ -1,7 +1,11 @@ import SwiftUI +import Combine /// A helper view taking an `entryView` and configuring it for use as a ``Puddles/Coordinator``. public struct CoordinatorBody: View { + @Environment(\.deepLinkHandler) private var deepLinkHandler + + private var onDeepLink: (_ url: URL) -> DeepLinkPropagation /// The root view of the `Coordinator` as provided in ``Puddles/Coordinator/entryView-swift.property``. private let entryView: C.EntryView @@ -24,13 +28,15 @@ public struct CoordinatorBody: View { navigation: C.NavigationContent, interfaces: C.Interfaces, firstAppearHandler: @escaping () async -> Void, - finalDisappearHandler: @escaping () -> Void + finalDisappearHandler: @escaping () -> Void, + onDeepLink: @escaping (_: URL) -> DeepLinkPropagation ) { self.entryView = entryView self.navigation = navigation self.interfaces = interfaces self.firstAppearHandler = firstAppearHandler self.finalDisappearHandler = finalDisappearHandler + self.onDeepLink = onDeepLink } public var body: some View { @@ -38,7 +44,7 @@ public struct CoordinatorBody: View { entryView } .background(navigation) - .background(interfaces) + .background(interfaces) .background { ViewLifetimeHelper { await firstAppearHandler() @@ -46,6 +52,20 @@ public struct CoordinatorBody: View { finalDisappearHandler() } } + .onAppear { + if let url = deepLinkHandler.url { + if onDeepLink(url) == .hasFinished { + deepLinkHandler.url = nil + } + } + } + .onChange(of: deepLinkHandler) { deepLinkHandler in + if let url = deepLinkHandler.url { + if onDeepLink(url) == .hasFinished { + deepLinkHandler.url = nil + } + } + } } } diff --git a/Sources/Puddles/Coordinator.swift b/Sources/Puddles/Coordinator.swift index f18239411..390e4219a 100644 --- a/Sources/Puddles/Coordinator.swift +++ b/Sources/Puddles/Coordinator.swift @@ -12,12 +12,17 @@ public protocol Coordinator: View { associatedtype EntryView: View /// The navigation that the ``Puddles/Coordinator`` defines, - /// which is built inside ``Puddles/Coordinator/navigation()`` using a ``NavigationBuilder``. + /// which is built inside the ``Puddles/Coordinator/navigation()`` method using a ``NavigationBuilder``. /// /// This can be inferred by providing an implementation for ``Puddles/Coordinator/navigation()``. /// The implementation can be empty to define an empty navigation. associatedtype NavigationContent: NavigationPattern + /// The collective interfaces that the ``Puddles/Coordinator`` observes, + /// which are built inside the ``Puddles/Coordinator/interfaces()`` using a ``Puddles/InterfaceObservationBuilder``. + /// + /// This can be inferred by providing an implementation for ``Puddles/Coordinator/interfaces()``. + /// The implementation can be empty to define an empty interface observation (i.e. no observation). associatedtype Interfaces: InterfaceObservation /// The final `View` type for the ``Puddles/Coordinator``, @@ -56,8 +61,30 @@ public protocol Coordinator: View { /// - Returns: The navigation content for the ``Puddles/Coordinator``. @NavigationBuilder @MainActor func navigation() -> NavigationContent + /// The collection of interfaces that the ``Puddles/Coordinator`` observes. + /// + /// The interface observations are built using a ``Puddles/InterfaceObservationBuilder`` result builder. An example implementation would look like this: + /// + /// ```swift + /// func interfaces() -> Interfaces { + /// InterfaceObserver(viewInterface) { action in + /// handleViewAction(action) + /// } + /// } + /// ``` + /// + /// The interface observations are internally added to the coordinator's content view. + /// + /// - Returns: The interface observations for the ``Puddles/Coordinator``. @InterfaceObservationBuilder @MainActor func interfaces() -> Interfaces + /// Experimental support for deep linking. + /// + /// A default implementation is provided and simply returns ``Puddles/DeepLinkPropagation/shouldContinue``. + /// - Parameter url: The deep link url. + /// - Returns: A propagation strategy. + @MainActor func handleDeeplink(url: URL) -> DeepLinkPropagation + /// A method that modifies the content of the ``Puddles/Coordinator``, whose view representation is passed as an argument. /// The result of this method is used as the Coordinator's `body` property. /// @@ -78,6 +105,8 @@ public protocol Coordinator: View { /// When you need to implement a `NavigationSplitView` or need iPad support, /// you need to wrap your root `Coordinator` in a view that provides the `NavigationSplitView`, as `Coordinator` currently does not support this in a convenient way. /// + /// - Tip: Due to a Swift Compiler bug, autocomplete will insert the wrong method signature for this method. It will use `FinalBody` as return type, instead of the correct `some View` type. Be aware that you have to manually correct this, or otherwise the code will not compile. + /// /// - Parameter coordinator: A representation of the fully configured coordinator content. /// - Returns: A view that modifies the provided coordinator. @ViewBuilder @MainActor func modify(coordinator: CoordinatorContent) -> FinalBody @@ -120,6 +149,9 @@ public extension Coordinator { }, finalDisappearHandler: { stop() + }, + onDeepLink: { url in + handleDeeplink(url: url) } ) ) @@ -132,4 +164,5 @@ public extension Coordinator { @MainActor func start() async {} @MainActor func stop() {} + @MainActor func handleDeeplink(url: URL) -> DeepLinkPropagation { .shouldContinue } } diff --git a/Sources/Puddles/DeepLinkHandler.swift b/Sources/Puddles/DeepLinkHandler.swift new file mode 100644 index 000000000..de7bc8908 --- /dev/null +++ b/Sources/Puddles/DeepLinkHandler.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/* + + Experimental! + + */ + +struct DeepLinkHandler: Equatable { + let id: UUID + + private let buffer: Buffer = .init() + var url: URL? { + get { buffer.url } + nonmutating set { buffer.url = newValue } + } + + private class Buffer { + var url: URL? + } + + init(id: UUID, deepLinkUrl: URL? = nil) { + self.id = id + buffer.url = deepLinkUrl + } + + static func == (lhs: DeepLinkHandler, rhs: DeepLinkHandler) -> Bool { + lhs.id == rhs.id + } +} + +private struct DeepLinkEnvironmentKey: EnvironmentKey { + static let defaultValue = DeepLinkHandler(id: .init()) +} + +extension EnvironmentValues { + var deepLinkHandler: DeepLinkHandler { + get { self[DeepLinkEnvironmentKey.self] } + set { self[DeepLinkEnvironmentKey.self] = newValue } + } +} + +public struct DeepLinkRootModifier: ViewModifier { + @State var handler: DeepLinkHandler = .init(id: .init()) + + public func body(content: Content) -> some View { + content + .environment(\.deepLinkHandler, handler) + .onOpenURL { url in + handler = .init(id: .init(), deepLinkUrl: url) + } + } +} + +public enum DeepLinkPropagation { + case shouldContinue + case hasFinished +} + +public extension View { + func deepLinkRoot() -> some View { + modifier(DeepLinkRootModifier()) + } +} diff --git a/Sources/Puddles/View Interface/Interface.swift b/Sources/Puddles/Interface/Interface.swift similarity index 81% rename from Sources/Puddles/View Interface/Interface.swift rename to Sources/Puddles/Interface/Interface.swift index b54d69f67..2c182fd96 100644 --- a/Sources/Puddles/View Interface/Interface.swift +++ b/Sources/Puddles/Interface/Interface.swift @@ -1,6 +1,8 @@ import Combine import SwiftUI +import AsyncAlgorithms +/// A wrapper around `ObservableObject` that provides an `actionPublisher` and a `sendAction(_:)` method that can be used as a unidirectional communication between views and coordinators. @MainActor public final class Interface: ObservableObject { public let actionPublisher: PassthroughSubject = .init() diff --git a/Sources/Puddles/View Interface/Observations/AccumulatedInterfaceConnection.swift b/Sources/Puddles/Interface/Observations/AccumulatedInterfaceConnection.swift similarity index 100% rename from Sources/Puddles/View Interface/Observations/AccumulatedInterfaceConnection.swift rename to Sources/Puddles/Interface/Observations/AccumulatedInterfaceConnection.swift diff --git a/Sources/Puddles/View Interface/Observations/Build Blocks/AsyncChannelObserver.swift b/Sources/Puddles/Interface/Observations/Build Blocks/AsyncChanncelObserver.swift similarity index 100% rename from Sources/Puddles/View Interface/Observations/Build Blocks/AsyncChannelObserver.swift rename to Sources/Puddles/Interface/Observations/Build Blocks/AsyncChanncelObserver.swift diff --git a/Sources/Puddles/View Interface/Observations/Build Blocks/EmptyInterfaceConnection.swift b/Sources/Puddles/Interface/Observations/Build Blocks/EmptyInterfaceConnection.swift similarity index 100% rename from Sources/Puddles/View Interface/Observations/Build Blocks/EmptyInterfaceConnection.swift rename to Sources/Puddles/Interface/Observations/Build Blocks/EmptyInterfaceConnection.swift diff --git a/Sources/Puddles/View Interface/Observations/Build Blocks/InterfaceObserver.swift b/Sources/Puddles/Interface/Observations/Build Blocks/InterfaceObserver.swift similarity index 100% rename from Sources/Puddles/View Interface/Observations/Build Blocks/InterfaceObserver.swift rename to Sources/Puddles/Interface/Observations/Build Blocks/InterfaceObserver.swift diff --git a/Sources/Puddles/View Interface/Observations/Build Blocks/InterfacePublisherObserver.swift b/Sources/Puddles/Interface/Observations/Build Blocks/InterfacePublisherObserver.swift similarity index 100% rename from Sources/Puddles/View Interface/Observations/Build Blocks/InterfacePublisherObserver.swift rename to Sources/Puddles/Interface/Observations/Build Blocks/InterfacePublisherObserver.swift diff --git a/Sources/Puddles/View Interface/Observations/Build Blocks/PublisherObserver.swift b/Sources/Puddles/Interface/Observations/Build Blocks/PublisherObserver.swift similarity index 100% rename from Sources/Puddles/View Interface/Observations/Build Blocks/PublisherObserver.swift rename to Sources/Puddles/Interface/Observations/Build Blocks/PublisherObserver.swift diff --git a/Sources/Puddles/View Interface/Observations/InterfaceObservation.swift b/Sources/Puddles/Interface/Observations/InterfaceObservation.swift similarity index 100% rename from Sources/Puddles/View Interface/Observations/InterfaceObservation.swift rename to Sources/Puddles/Interface/Observations/InterfaceObservation.swift diff --git a/Sources/Puddles/View Interface/Observations/InterfaceObservationBuilder.swift b/Sources/Puddles/Interface/Observations/InterfaceObservationBuilder.swift similarity index 100% rename from Sources/Puddles/View Interface/Observations/InterfaceObservationBuilder.swift rename to Sources/Puddles/Interface/Observations/InterfaceObservationBuilder.swift diff --git a/Sources/Puddles/Navigation/Patterns/Expectation/Expectation.swift b/Sources/Puddles/Navigation/Patterns/Expectation/Expectation.swift deleted file mode 100644 index 28635e850..000000000 --- a/Sources/Puddles/Navigation/Patterns/Expectation/Expectation.swift +++ /dev/null @@ -1,149 +0,0 @@ -import SwiftUI - -// TODO: Replace with AsyncChannel (which does exactly this, just better): https://github.com/apple/swift-async-algorithms/blob/main/Sources/AsyncAlgorithms/AsyncAlgorithms.docc/Guides/Channel.md - -/// A type that can be used to trigger the collection of some kind of disconnected, delayed data (like a `Bool` coming from a confirmation dialog view) and asynchronously `await` its retrieval in-place, without ever leaving the original scope. -/// -/// Sometimes an action triggers a flow that requires user input or some form of other delayed and disconnected data to complete. -/// An example of this would be a confirmation dialog for a deletion process. -/// -/// Such a dialog would typically be triggered from within a `Coordinator`'s ``Puddles/Coordinator/handleAction(_:)-38d52`` method and finished within the dialog's completion handlers. This logically separates the deletion flow and makes it harder to reason about the code. -/// -/// By using an ``Expectation``, you can simply `await` the results of the dialog from right within ``Puddles/Coordinator/handleAction(_:)-38d52``. -/// -/// To do this, wrap a navigation presentation (like a confirmation dialog) in an instance of ``Expecting``, passing in a binding to an ``Expectation`` instance. -/// The ``Expecting`` wrapper will provide an `isActive` binding that you can pass to your presentation. -/// Then, inside the `Coordinator`'s ``Puddles/Coordinator/handleAction(_:)-4le7d`` method, -/// you can start the presentation by calling ``Expectation/show()`` on the ``Expectation``. -/// Having done this, you can simply `await` the results of the presentation through the ``Expectation/result`` property. -/// Lastly, to finish the presentation, call ``Expectation/hide()``. -/// -/// - Important: By `await`ing a ``Expectation/result``, you have to guarantee that the expectation will be completed exactly once, no less and no more! Failing to do so will cause undefined behavior and even crashes, since this view makes use of `CheckedContinuation`s. -public struct Expectation { - - /// Internal helper type that stores the `CheckedContinuation`. - /// - /// To prevent data races, it is an actor. - private final actor Buffer { - private var continuation: CheckedContinuation? - - var hasContinuation: Bool { - continuation != nil - } - - func setContinuation(_ continuation: CheckedContinuation) { - self.continuation = continuation - } - - func complete(with result: ExpectedType) { - continuation?.resume(returning: result) - continuation = nil - } - } - - /// Helper type to hide other implementation details of ``Puddles/Expectation``. - /// This type only exposes a single method to complete the expectation. - public struct Resolver { - - private let handler: (ExpectedType) -> Void - - init(handler: @escaping (ExpectedType) -> Void) { - self.handler = handler - } - - /// Completes the expectation with a result. - /// - Parameter result: The result of the expectation. - public func complete(with result: ExpectedType) { - handler(result) - } - - public func complete() where ExpectedType == Void { - handler(()) - } - } - - /// Internal helper type that stores and continues a `CheckedContinuation` created within ``Expectation/result``. - private var buffer: Buffer - - /// Boolean flag indicating if the expectation has started, which usually coincides with a presentation being shown in a ``Puddles/Coordinator``. - var isActive: Bool = false - - /// An optional result that needs to be provided in case there is a chance that the user intervenes in a presentation and cancels the expectation. - var resultOnUserCancel: ExpectedType? - - /// Helper type to hide other implementation details of ``Expectation``. - /// This type only exposes a single method to complete the expectation. - var resolver: Resolver! - - private init(resultOnUserCancel: ExpectedType?) { - self.resultOnUserCancel = resultOnUserCancel - buffer = Buffer() - resolver = .init(handler: complete) - } - - /// Initializes an expectation that could be interrupted by user interaction. - /// - /// An example would be a dismissable sheet. - /// - Parameter resultOnUserCancel: The result that should be used to complete the expectation when it is cancelled by the user. - /// - Returns: The expectation. - public static func userCancellable(resultOnUserCancel: ExpectedType) -> Expectation { - .init(resultOnUserCancel: resultOnUserCancel) - } - - /// Initializes an expectation that is guaranteed not to be interrupted and cancelled by user interaction. - /// - /// An example would be a confirmation dialog. - /// - Returns: The expectation. - public static func guaranteedCompletion() -> Expectation { - .init(resultOnUserCancel: nil) - } - - /// Helper flag to determine if a continuation is currently stored. - /// - /// This is needed for runtime checks in ``Expecting/body`` - /// to inform about unfulfilled expectations (causing a `CheckedContinuation` to leak). - var hasContinuation: Bool { - get async { - await buffer.hasContinuation - } - } - - // MARK: - isActive accessors - - /// Start the expectation by setting its `isActive` to `true`. - public mutating func show() { - isActive = true - } - - /// Stops the expectation by setting its `isActive` to `false`. - public mutating func hide() { - isActive = false - } - - // MARK: - Completion access - - /// Completes the expectation with a result. - /// - Parameter result: The result of the expectation. - public func complete(with result: ExpectedType) { - Task { - await buffer.complete(with: result) - } - } - - // MARK: - Result access - - /// The result of the expectation. - /// - /// Make sure to start the expectation before accessing this, or it will do nothing. - public var result: ExpectedType { - get async { - // TODO: check if this has already been called and prevent it from happening again! it would override the old one. - // Or cache the result and return it. delete the cache upon show() - await withCheckedContinuation { continuation in - Task { - await buffer.setContinuation(continuation) - } - } - } - } -} diff --git a/Sources/Puddles/Navigation/Patterns/Expectation/Expecting.swift b/Sources/Puddles/Navigation/Patterns/Expectation/Expecting.swift deleted file mode 100644 index c729458f2..000000000 --- a/Sources/Puddles/Navigation/Patterns/Expectation/Expecting.swift +++ /dev/null @@ -1,38 +0,0 @@ -import SwiftUI - -/// A navigation wrapper using an ``Expectation`` to retrieve delayed asynchronous data from a contained presentation. See ``Expectation`` for more information. -public struct Expecting: NavigationPattern { - - /// A binding to the ``Expectation`` that drives this wrapper. - @Binding var expectation: Expectation - - /// A closure that is given an `isActive` binding as well as a resolver for the expectation and returns a navigation pattern, like ``Alert`` or ``Sheet``. - @ViewBuilder var destination: (_ isActive: Binding, _ expectation: Expectation.Resolver) -> Destination - - /// A navigation wrapper using an ``Expectation`` to retrieve delayed asynchronous data from a contained presentation. See ``Expectation`` for more information. - public init( - _ expectation: Binding>, - @ViewBuilder destination: @escaping (_ isActive: Binding, _ expectation: Expectation.Resolver) -> Destination - ) { - self._expectation = expectation - self.destination = destination - } -} - -extension Expecting { - public var body: some View { - destination($expectation.isActive, expectation.resolver) - .onChange(of: expectation.isActive) { isActive in - if isActive { return } - if let resultOnUserCancel = expectation.resultOnUserCancel { - expectation.complete(with: resultOnUserCancel) - } else { - Task { - if await expectation.hasContinuation { - fatalError("Expectation has been left unfulfilled despite the presentation having ended. Consider initializing the Expectation with `Expectation.userCancellable(resultOnUserCancel)` to provide a result for this case.") - } - } - } - } - } -} diff --git a/Sources/Puddles/Navigation/Patterns/Queryable/QueryControlled.swift b/Sources/Puddles/Navigation/Patterns/Queryable/QueryControlled.swift new file mode 100644 index 000000000..b5565fb35 --- /dev/null +++ b/Sources/Puddles/Navigation/Patterns/Queryable/QueryControlled.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// A navigation wrapper using a ``Puddles/Queryable`` to retrieve delayed asynchronous data from a contained presentation. See ``Puddles/Queryable`` for more information. +public struct QueryControlled: NavigationPattern { + + /// A binding to the ``Puddles/Queryable`` that drives this wrapper. + var queryable: Queryable.Wrapper + + /// A closure that is given an `isActive` binding as well as a resolver for the query and returns a navigation pattern, like ``Puddles/Alert`` or ``Puddles/Sheet``. + @ViewBuilder var destination: (_ isActive: Binding, _ query: Queryable.Resolver) -> Destination + + /// A navigation wrapper using a ``Puddles/Queryable`` to retrieve delayed asynchronous data from a contained presentation. See ``Puddles/Queryable`` for more information. + public init( + by queryable: Queryable.Wrapper, + @ViewBuilder destination: @escaping (_ isActive: Binding, _ query: Queryable.Resolver) -> Destination + ) { + self.queryable = queryable + self.destination = destination + } +} + +extension QueryControlled { + public var body: some View { + destination(queryable.isActive, queryable.resolver) + .onChange(of: queryable.isActive.wrappedValue) { newValue in + if newValue == false { + queryable.resolver.cancelQueryIfNeeded() + } + } + } +} diff --git a/Sources/Puddles/Navigation/Patterns/Queryable/Queryable.swift b/Sources/Puddles/Navigation/Patterns/Queryable/Queryable.swift new file mode 100644 index 000000000..28ca7f59b --- /dev/null +++ b/Sources/Puddles/Navigation/Patterns/Queryable/Queryable.swift @@ -0,0 +1,261 @@ +import SwiftUI + +/// A type that can be used to query the collection of some kind of disconnected, +/// delayed data (like a `Bool` coming from a confirmation dialog view) and asynchronously `await` its retrieval in-place, without ever leaving the scope. +/// +/// First, create a property of the desired data type: +/// +/// ```swift +/// @Queryable var deletionConfirmation +/// ``` +/// +/// Then, wrap a navigation presentation (like a confirmation dialog view) in an instance of ``Puddles/QueryControlled``, passing in the ``Puddles/Queryable`` property. +/// The ``Puddles/QueryControlled`` wrapper will provide an `isActive` binding that you can pass to your presentation. +/// +/// ```swift +/// QueryControlled(by: deletionConfirmation) { isActive, query in +/// Alert( +/// title: "Do you want to delete this?", +/// isPresented: isActive +/// ) { +/// Button("Cancel", role: .cancel) { +/// query.answer(with: false) +/// } +/// Button("OK") { +/// query.answer(with: true) +/// } +/// } message: { +/// Text("This cannot be reversed!") +/// } +/// } +/// ``` +/// +/// To query data collection and await the results, call ``Puddles/Queryable/Wrapper/query()`` on the ``Puddles/Queryable`` property. This will activate the wrapped navigation presentation which can then answer the query at its completion point. +/// +/// ```swift +/// do { +/// let shouldDelete = try await deletionConfirmation.query() +/// } catch {} +/// ``` +/// +/// When the Task that calls ``Puddles/Queryable/Wrapper/query()`` is cancelled, the suspended query will also cancel and deactivate (i.e. close) the wrapped navigation presentation. In that case, a ``Puddles/QueryError/queryCancelled`` error is thrown. +/// +/// For more information, see . +@propertyWrapper +public struct Queryable: DynamicProperty { + + /// A representation of the `Queryable` property wrapper type. This can be passed to ``Puddles/QueryControlled``. + public struct Wrapper { + + /// A binding to the `isActive` state inside the `@Queryable` property wrapper. + /// + /// This is used internally inside ``Puddles/Queryable/Wrapper/query()``. + var isActive: Binding + + /// A pointer to the ``Puddles/Queryable/Resolver`` object that is passed inside the closure of the ``Puddles/QueryControlled`` navigation wrapper. + /// + /// This is used internally inside ``Puddles/QueryControlled``. + var resolver: Resolver + + /// A property that stores the `Result` type to be used in logging messages. + let expectedType: Result.Type + + /// A pointer to the `Buffer` object type. + /// + /// This is used internally inside ``Puddles/Queryable/Wrapper/query()``. + private var buffer: Buffer + + /// A representation of the `Queryable` property wrapper type. This can be passed to ``Puddles/QueryControlled``. + fileprivate init( + isActive: Binding, + expectedType: Result.Type, + resolver: Resolver, + buffer: Buffer + ) { + self.isActive = isActive + self.expectedType = expectedType + self.resolver = resolver + self.buffer = buffer + } + + /// Requests the collection of data by starting a query on the `Result` type. + /// + /// This method will suspend for as long as the query is unanswered and not cancelled. When the parent Task is cancelled, this method will immediately cancel the query and throw a ``Puddles/QueryError/queryCancelled`` error. + /// + /// Creating multiple queries at the same time will cause a query conflict which is resolved using the ``Puddles/Queryable/QueryConflictPolicy`` defined in the initializer of ``Puddles/Queryable``. The default policy is ``Puddles/Queryable/QueryConflictPolicy/cancelNewQuery``. + /// - Returns: The result of the query. + public func query() async throws -> Result { + isActive.wrappedValue = true + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + Task { await buffer.storeContinuation(continuation) } + } + } onCancel: { + isActive.wrappedValue = false + Task { await buffer.resumeContinuation(throwing: QueryError.queryCancelled) } + } + } + } + + /// Boolean flag indicating if the query has started, which usually coincides with a presentation being shown in a ``Puddles/Coordinator``. + @State var isActive: Bool = false + + public var wrappedValue: Wrapper { + .init(isActive: $isActive, expectedType: Result.self, resolver: resolver, buffer: buffer) + } + + /// Internal helper type that stores and continues a `CheckedContinuation` created by calling ``Puddles/Queryable/Wrapper/query()``. + private var buffer: Buffer + + /// Helper type to hide implementation details of ``Puddles/Queryable``. + /// This type exposes convenient methods to answer (i.e. complete) a query. + private var resolver: Resolver! + + public init(queryConflictPolicy: QueryConflictPolicy = .cancelNewQuery) { + buffer = Buffer(queryConflictPolicy: queryConflictPolicy) + resolver = .init(answerHandler: resumeContinuation(returning:), errorHandler: resumeContinuation(throwing:)) + } + + /// Completes the query with a result. + /// - Parameter result: The answer to the query. + private func resumeContinuation(returning result: Result) { + Task { + await buffer.resumeContinuation(returning: result) + } + } + + /// Completes the query with an error. + /// - Parameter result: The error that should be thrown. + private func resumeContinuation(throwing error: Error) { + Task { + + // Catch an unanswered query and cancel it to prevent the stored continuation from leaking. + if case QueryInternalError.queryAutoCancel = error, + await buffer.hasContinuation { + logger.notice("Cancelling query of »\(Result.self, privacy: .public)« because presentation has terminated.") + await buffer.resumeContinuation(throwing: QueryError.queryCancelled) + return + } + + await buffer.resumeContinuation(throwing: error) + } + } +} + +extension Queryable { + + /// A query conflict resolving strategy for situations in which multiple queries are started at the same time. + public enum QueryConflictPolicy { + + /// A query conflict resolving strategy that cancels the previous, ongoing query to allow the new query to continue. + case cancelPreviousQuery + + /// A query conflict resolving strategy that cancels the new query to allow the previous, ongoing query to continue. + case cancelNewQuery + } + + /// Internal helper type that stores the `CheckedContinuation`. + /// + /// To prevent data races, it is an actor. + fileprivate final actor Buffer { + private let queryConflictPolicy: QueryConflictPolicy + private var continuation: CheckedContinuation? + + init(queryConflictPolicy: QueryConflictPolicy) { + self.queryConflictPolicy = queryConflictPolicy + } + + var hasContinuation: Bool { + continuation != nil + } + + func storeContinuation(_ continuation: CheckedContinuation) { + if self.continuation != nil { + switch queryConflictPolicy { + case .cancelPreviousQuery: + logger.warning("Cancelling previous query of »\(Result.self, privacy: .public)« to allow new query.") + self.continuation?.resume(throwing: QueryError.queryCancelled) + self.continuation = nil + case .cancelNewQuery: + logger.warning("Cancelling new query of »\(Result.self, privacy: .public)« because another query is ongoing.") + continuation.resume(throwing: QueryError.queryCancelled) + return + } + } + + self.continuation = continuation + } + + func resumeContinuation(returning result: Result) { + continuation?.resume(returning: result) + continuation = nil + } + + func resumeContinuation(throwing error: Error) { + continuation?.resume(throwing: error) + continuation = nil + } + } + + /// A type that lets you answer a query made by a call to ``Puddles/Queryable/Wrapper/query()``. + public class Resolver { + + private let answerHandler: (Result) -> Void + private let cancelHandler: (Error) -> Void + + init( + answerHandler: @escaping (Result) -> Void, + errorHandler: @escaping (Error) -> Void + ) { + self.answerHandler = answerHandler + self.cancelHandler = errorHandler + } + + /// Answer the query with a result. + /// - Parameter result: The result of the query. + public func answer(with result: Result) { + answerHandler(result) + } + + /// Answer the query with an optional result. If it is `nil`, this will call ``Puddles/Queryable/Resolver/cancelQuery()``. + /// - Parameter result: The result of the query, as an optional. + public func answer(withOptional optionalResult: Result?) { + if let optionalResult { + answerHandler(optionalResult) + } else { + cancelQuery() + } + } + + /// Answer the query. + public func answer() where Result == Void { + answerHandler(()) + } + + /// Answer the query by throwing an error. + /// - Parameter error: The error to throw. + public func answer(throwing error: Error) { + cancelHandler(error) + } + + /// Cancel the query by throwing a ``Puddles/QueryError/queryCancelled`` error. + public func cancelQuery() { + cancelHandler(QueryError.queryCancelled) + } + + /// Cancel the query by throwing a `QueryInternalError.queryAutoCancel` error. + /// + /// This is an internal helper method to distnguish between a user canceled query and a system-cancelled query. See ``Puddles/QueryControlled``'s ``Puddles/QueryControlled/body`` for an example. + func cancelQueryIfNeeded() { + cancelHandler(QueryInternalError.queryAutoCancel) + } + } +} + +fileprivate enum QueryInternalError: Swift.Error { + case queryAutoCancel +} + +public enum QueryError: Swift.Error { + case queryCancelled +} diff --git a/Sources/Puddles/PluginCoordinator.swift b/Sources/Puddles/PluginCoordinator.swift deleted file mode 100644 index 9981e4b92..000000000 --- a/Sources/Puddles/PluginCoordinator.swift +++ /dev/null @@ -1,53 +0,0 @@ -import SwiftUI - -// TODO: Implement this way to shortcut commonly used coordinators (like a settings sheet) - -/* Idea: - - Coordinator().installPlugin(\.keyPathToSomeIdentifier) { isActive in - Sheet(isActive: isActive) { - SettingsCoordinator() - } - } - - // Inside other Coordinators: - - // Stores state - @Plugin(\.keyPathToSomeIdentifier) var settings - - settings.show() - settings.hide() - settings.setVisible(true|false - - Maybe optionally combine with expectations - - */ - -public struct PluginCoordinator: View { - - public var body: some View { - Text("") - } - -} - -public struct CoordinatorPluginModifier: ViewModifier { - - @Binding var isActive: Bool - let coordinator: C - - init(isActive: Binding, coordinator: C) { - self.coordinator = coordinator - self._isActive = isActive - } - - public func body(content: Content) -> some View { - content - } -} - -public extension View { - func coordinatorPlugin(_ coordinator: some Coordinator, isActive: Binding) -> some View { - modifier(CoordinatorPluginModifier(isActive: isActive, coordinator: coordinator)) - } -} diff --git a/Sources/Puddles/Previews Utility/Preview.swift b/Sources/Puddles/Previews Utility/Preview.swift index b38dc711b..cf0f3ffd7 100644 --- a/Sources/Puddles/Previews Utility/Preview.swift +++ b/Sources/Puddles/Previews Utility/Preview.swift @@ -4,65 +4,78 @@ import SwiftUI /// /// - Important: This is only meant to be used within previews! /// -/// For more details on the view interfacing concept, see ``ViewInterface``. +/// For more details on the view interfacing concept, see ``Puddles/Interface``. public struct Preview, ViewState, Content: View, Overlay: View>: View { @State var state: ViewState @StateObject var interface: ViewInterface var content: (_ interface: ViewInterface, _ state: Binding) -> Content - var actionHandler: (_ action: Action, _ state: inout ViewState) async -> Void - var onStart: ((_ state: Binding) async throws -> Void)? + var actionHandler: (_ action: Action, _ state: Binding) -> Void + var onStart: ((_ state: Binding) async -> Void)? + + var maximizedPreviewFrame: Bool = false var overlayAlignment: Alignment = .bottom - var overlay: (_ interface: ViewInterface) -> Overlay + var debugOverlay: (_ state: Binding) -> Overlay + /// A SwiftUI previews helper type allowing to take advantage of view interfacing by providing an in-place mechanism of reacting to the view's actions. + /// + /// - Important: This is only meant to be used within previews! public init( @ViewBuilder _ content: @escaping (_ interface: ViewInterface, _ state: ViewState) -> Content, state: @autoclosure @escaping () -> ViewState, - actionHandler: @escaping (_ action: Action, _ state: inout ViewState) async -> Void + actionHandler: @escaping (_ action: Action, _ state: Binding) -> Void ) where Overlay == EmptyView { self._state = .init(wrappedValue: state()) self._interface = .init(wrappedValue: .init()) self.content = { content($0, $1.wrappedValue) } self.actionHandler = actionHandler - self.overlay = {_ in EmptyView() } + self.debugOverlay = {_ in EmptyView() } } private init( @ViewBuilder _ content: @escaping (_ interface: ViewInterface, _ state: Binding) -> Content, - @ViewBuilder overlay: @escaping (_ interface: ViewInterface) -> Overlay, + @ViewBuilder debugOverlay: @escaping (_ state: Binding) -> Overlay, overlayAlignment: Alignment, state: @autoclosure () -> ViewState, - actionHandler: @escaping (_ action: Action, _ state: inout ViewState) async -> Void + maximizedPreviewFrame: Bool, + actionHandler: @escaping (_ action: Action, _ state: Binding) -> Void ) { self._state = .init(wrappedValue: state()) self._interface = .init(wrappedValue: .init()) self.content = content + self.maximizedPreviewFrame = maximizedPreviewFrame self.actionHandler = actionHandler - self.overlay = overlay + self.debugOverlay = debugOverlay self.overlayAlignment = overlayAlignment } public var body: some View { content(interface, $state) + .frame( + maxWidth: maximizedPreviewFrame ? .infinity : nil, + maxHeight: maximizedPreviewFrame ? .infinity : nil + ) .overlay(alignment: overlayAlignment) { - overlay(interface) + debugOverlay($state) } .background( ViewLifetimeHelper { - try! await onStart?($state) + await onStart?($state) } onDeinit: {} ) .onReceive(interface.actionPublisher) { action in - Task { - var state = state - await actionHandler(action, &state) - self.state = state - } + actionHandler(action, $state) } } + + public func fullScreenPreview() -> Preview { + var copy = self + copy.maximizedPreviewFrame = true + return copy + } - public func onStart(perform: @escaping (_ state: Binding) async throws -> Void) -> Preview { + public func onStart(perform: @escaping (_ state: Binding) async -> Void) -> Preview { var copy = self copy.onStart = perform return copy @@ -70,12 +83,15 @@ public struct Preview, ViewState, Conte public func overlay( alignment: Alignment = .bottom, - @ViewBuilder overlayContent: @escaping (_ interface: ViewInterface) -> OverlayContent + maximizedPreviewFrame: Bool = true, + @ViewBuilder overlayContent: @escaping (_ state: Binding) -> OverlayContent ) -> Preview { Preview<_, _, _, _, OverlayContent>( - content, overlay: overlayContent, + content, + debugOverlay: overlayContent, overlayAlignment: alignment, state: state, + maximizedPreviewFrame: maximizedPreviewFrame, actionHandler: actionHandler ) } diff --git a/Sources/Puddles/Puddles.docc/Expectation.md b/Sources/Puddles/Puddles.docc/Expectation.md deleted file mode 100644 index 622ae5a48..000000000 --- a/Sources/Puddles/Puddles.docc/Expectation.md +++ /dev/null @@ -1,50 +0,0 @@ -# ``Puddles/Expectation`` - -## Example - -```swift -struct HomeCoordinator: Coordinator { - @StateObject var interface: MyView.Interface = .init() - - @State var deletionConfirmation = Expectation.guaranteedCompletion() - - var entryView: some View { - MyView(interface: interface) - } - - func navigation() -> some NavigationPattern { - Expecting($deletionConfirmation) { isActive, expectation in - Alert( - title: "Do you want to delete this?", - isPresented: isActive) { - Button("Cancel", role: .cancel) { - expectation.complete(with: false) - } - Button("OK") { - expectation.complete(with: true) - } - } message: { - Text("This cannot be reversed!") - } - } - } - - func handleAction(_ action: Action) async { - switch action { - case .deleteButtonTapped: - deletionConfirmation.show() - let shouldDelete = await deletionConfirmation.result - if shouldDelete { - // Delete - } - // Alerts automatically hide, so no need to do it here - } - } -} -``` - -## Topics - -### Related Types - -- ``Puddles/Expecting`` diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-01-02.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-01-02.swift index 519d32bca..4c4210401 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-01-02.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-01-02.swift @@ -11,4 +11,8 @@ struct Root: Coordinator { // Empty for now } + func interfaces() -> some InterfaceObservation { + // Empty for now + } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-01-03.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-01-03.swift index 4044cd3ed..ff6c9c93a 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-01-03.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-01-03.swift @@ -1,7 +1,7 @@ import SwiftUI @main -struct YourApp: App { +struct FirstChapterApp: App { var body: some Scene { WindowGroup { Root() diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-02.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-02.swift index 18c5c40ae..1501f7bda 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-02.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-02.swift @@ -1,9 +1,8 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { - @ObservedObject var interface: Interface + let state: ViewState var body: some View { Text("Hello, World") @@ -12,7 +11,7 @@ struct HomeView: View { } extension HomeView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() + struct ViewState { + var buttonTapCount: Int = 0 } } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-03.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-03.swift index 2e851889a..01a5a34e7 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-03.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-03.swift @@ -1,25 +1,22 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { - @ObservedObject var interface: Interface + let state: ViewState var body: some View { VStack { - Text("Button tapped \(interface.buttonTapCount) times.") - Button("Tap Me") { - // Button tapped - } + Text("Button tapped \(state.buttonTapCount) times.") + Button("Tap Me") { + + } } } } extension HomeView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() - - @Published var buttonTapCount: Int = 0 + struct ViewState { + var buttonTapCount: Int = 0 } } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-04.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-04.swift index 914e189a7..94a5d1bda 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-04.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-04.swift @@ -1,29 +1,22 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { - @ObservedObject var interface: Interface + let state: ViewState var body: some View { VStack { - Text("Button tapped \(interface.buttonTapCount) times.") - Button("Tap Me") { - interface.buttonTapped() - } + Text("Button tapped \(state.buttonTapCount) times.") + Button("Tap Me") { + // Update state? + } } } } extension HomeView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() - - @Published var buttonTapCount: Int = 0 - - func buttonTapped() { - buttonTapCount += 1 - } + struct ViewState { + var buttonTapCount: Int = 0 } } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-05.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-05.swift index 519d32bca..df0ef89fa 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-05.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-05.swift @@ -11,4 +11,9 @@ struct Root: Coordinator { // Empty for now } + + func interfaces() -> some InterfaceObservation { + // Empty for now + } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-06.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-06.swift index f6924774c..b7ec9a3b5 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-06.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/01-Introduction/essentials-02-06.swift @@ -2,14 +2,24 @@ import SwiftUI import Puddles struct Root: Coordinator { - @StateObject var interface: HomeView.Interface = .init() + @State var buttonTapCount: Int = 0 + + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } var entryView: some View { - HomeView(interface: interface) + HomeView(state: viewState) } func navigation() -> some NavigationPattern { // Empty for now } + func interfaces() -> some InterfaceObservation { + // Empty for now + } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-01.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-01.swift index 914e189a7..94a5d1bda 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-01.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-01.swift @@ -1,29 +1,22 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { - @ObservedObject var interface: Interface + let state: ViewState var body: some View { VStack { - Text("Button tapped \(interface.buttonTapCount) times.") - Button("Tap Me") { - interface.buttonTapped() - } + Text("Button tapped \(state.buttonTapCount) times.") + Button("Tap Me") { + // Update state? + } } } } extension HomeView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() - - @Published var buttonTapCount: Int = 0 - - func buttonTapped() { - buttonTapCount += 1 - } + struct ViewState { + var buttonTapCount: Int = 0 } } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-02.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-02.swift index 5f8d7646e..644cda04a 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-02.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-02.swift @@ -1,30 +1,24 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { - @ObservedObject var interface: Interface + @ObservedObject var interface: Interface + let state: ViewState var body: some View { VStack { - Text("Button tapped \(interface.buttonTapCount) times.") - Button("Tap Me") { - interface.buttonTapped() - } + Text("Button tapped \(state.buttonTapCount) times.") + Button("Tap Me") { + // Update state? + } } } } extension HomeView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() - - @Published var buttonTapCount: Int = 0 - - func buttonTapped() { - buttonTapCount += 1 - } + struct ViewState { + var buttonTapCount: Int = 0 } enum Action { diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-03.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-03.swift index 5e9eacf03..221fd5315 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-03.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-03.swift @@ -1,26 +1,24 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { - @ObservedObject var interface: Interface + @ObservedObject var interface: Interface + let state: ViewState var body: some View { VStack { - Text("Button tapped \(interface.buttonTapCount) times.") - Button("Tap Me") { - interface.sendAction(.buttonTapped) - } + Text("Button tapped \(state.buttonTapCount) times.") + Button("Tap Me") { + interface.sendAction(.buttonTapped) + } } } } extension HomeView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() - - @Published var buttonTapCount: Int = 0 + struct ViewState { + var buttonTapCount: Int = 0 } enum Action { diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-04.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-04.swift index f6924774c..b7ec9a3b5 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-04.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-04.swift @@ -2,14 +2,24 @@ import SwiftUI import Puddles struct Root: Coordinator { - @StateObject var interface: HomeView.Interface = .init() + @State var buttonTapCount: Int = 0 + + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } var entryView: some View { - HomeView(interface: interface) + HomeView(state: viewState) } func navigation() -> some NavigationPattern { // Empty for now } + func interfaces() -> some InterfaceObservation { + // Empty for now + } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-05.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-05.swift index 7cd514314..8b4f25eae 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-05.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-05.swift @@ -2,17 +2,25 @@ import SwiftUI import Puddles struct Root: Coordinator { - @StateObject var interface: HomeView.Interface = .init() + @StateObject var viewInterface: Interface = .init() + @State var buttonTapCount: Int = 0 + + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } var entryView: some View { - HomeView(interface: interface) + HomeView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { // Empty for now } - func handleAction(_ action: Action) async { - + func interfaces() -> some InterfaceObservation { + // Empty for now } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-06.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-06.swift index 55edda96b..7b45195b2 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-06.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/02-View-Interactions/02-ViewInteractions-01-06.swift @@ -2,20 +2,34 @@ import SwiftUI import Puddles struct Root: Coordinator { - @StateObject var interface: HomeView.Interface = .init() + @StateObject var viewInterface: Interface = .init() + @State var buttonTapCount: Int = 0 + + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } var entryView: some View { - HomeView(interface: interface) + HomeView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { // Empty for now } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: Action) async { switch action { case .buttonTapped: - interface.buttonTapCount += 1 + buttonTapCount += 1 } } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-01.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-01.swift index 55edda96b..7b45195b2 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-01.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-01.swift @@ -2,20 +2,34 @@ import SwiftUI import Puddles struct Root: Coordinator { - @StateObject var interface: HomeView.Interface = .init() + @StateObject var viewInterface: Interface = .init() + @State var buttonTapCount: Int = 0 + + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } var entryView: some View { - HomeView(interface: interface) + HomeView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { // Empty for now } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: Action) async { switch action { case .buttonTapped: - interface.buttonTapCount += 1 + buttonTapCount += 1 } } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-02.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-02.swift index 6c0c11341..eb5d6e612 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-02.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-02.swift @@ -2,25 +2,39 @@ import SwiftUI import Puddles struct Root: Coordinator { - @StateObject var interface: HomeView.Interface = .init() + @StateObject var viewInterface: Interface = .init() + @State var buttonTapCount: Int = 0 @State private var isShowingSheet: Bool = false + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } + var entryView: some View { - HomeView(interface: interface) + HomeView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { // Empty for now } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: Action) async { switch action { case .buttonTapped: - interface.buttonTapCount += 1 - if interface.buttonTapCount == 42 { + buttonTapCount += 1 + if buttonTapCount == 42 { isShowingSheet = true } } } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-03.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-03.swift index 63da347f1..3842f34cb 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-03.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-01-03.swift @@ -2,12 +2,19 @@ import SwiftUI import Puddles struct Root: Coordinator { - @StateObject var interface: HomeView.Interface = .init() + @StateObject var viewInterface: Interface = .init() + @State var buttonTapCount: Int = 0 @State private var isShowingSheet: Bool = false + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } + var entryView: some View { - HomeView(interface: interface) + HomeView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { @@ -17,13 +24,20 @@ struct Root: Coordinator { } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: Action) async { switch action { case .buttonTapped: - interface.buttonTapCount += 1 - if interface.buttonTapCount == 42 { + buttonTapCount += 1 + if buttonTapCount == 42 { isShowingSheet = true } } } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-01.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-01.swift index 63da347f1..3842f34cb 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-01.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-01.swift @@ -2,12 +2,19 @@ import SwiftUI import Puddles struct Root: Coordinator { - @StateObject var interface: HomeView.Interface = .init() + @StateObject var viewInterface: Interface = .init() + @State var buttonTapCount: Int = 0 @State private var isShowingSheet: Bool = false + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } + var entryView: some View { - HomeView(interface: interface) + HomeView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { @@ -17,13 +24,20 @@ struct Root: Coordinator { } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: Action) async { switch action { case .buttonTapped: - interface.buttonTapCount += 1 - if interface.buttonTapCount == 42 { + buttonTapCount += 1 + if buttonTapCount == 42 { isShowingSheet = true } } } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-02.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-02.swift index 9d69c9ad6..250127063 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-02.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-02.swift @@ -2,12 +2,19 @@ import SwiftUI import Puddles struct Root: Coordinator { - @StateObject var interface: HomeView.Interface = .init() + @StateObject var viewInterface: Interface = .init() + @State var buttonTapCount: Int = 0 @State private var isShowingPage: Bool = false + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } + var entryView: some View { - HomeView(interface: interface) + HomeView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { @@ -17,13 +24,20 @@ struct Root: Coordinator { } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: Action) async { switch action { case .buttonTapped: - interface.buttonTapCount += 1 - if interface.buttonTapCount == 42 { - isShowingPage = true + buttonTapCount += 1 + if buttonTapCount == 42 { + isShowingPage = true } } } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-03.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-03.swift index f03fadf9b..28c5ae800 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-03.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-03.swift @@ -2,16 +2,23 @@ import SwiftUI import Puddles struct Root: Coordinator { - @StateObject var interface: HomeView.Interface = .init() + @StateObject var viewInterface: Interface = .init() + @State var buttonTapCount: Int = 0 @State private var isShowingPage: Bool = false + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } + var entryView: some View { - HomeView(interface: interface) + HomeView(interface: viewInterface, state: viewState) } func modify(coordinator: CoordinatorContent) -> some View { - NavigationView { + NavigationStack { coordinator } } @@ -23,13 +30,20 @@ struct Root: Coordinator { } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: Action) async { switch action { case .buttonTapped: - interface.buttonTapCount += 1 - if interface.buttonTapCount == 42 { + buttonTapCount += 1 + if buttonTapCount == 42 { isShowingPage = true } } } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-04.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-04.swift index 6a3a35b07..3f0095436 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-04.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/03-BasicNavigation/03-BasicNavigation-02-04.swift @@ -2,18 +2,25 @@ import SwiftUI import Puddles struct Root: Coordinator { - @StateObject var interface: HomeView.Interface = .init() + @StateObject var viewInterface: Interface = .init() + @State var buttonTapCount: Int = 0 @State private var isShowingPage: Bool = false + var viewState: HomeView.ViewState { + .init( + buttonTapCount: buttonTapCount + ) + } + var entryView: some View { - HomeView(interface: interface) + HomeView(interface: viewInterface, state: viewState) .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) } func modify(coordinator: CoordinatorContent) -> some View { - NavigationView { + NavigationStack { coordinator } } @@ -26,13 +33,20 @@ struct Root: Coordinator { } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: Action) async { switch action { case .buttonTapped: - interface.buttonTapCount += 1 - if interface.buttonTapCount == 42 { + buttonTapCount += 1 + if buttonTapCount == 42 { isShowingPage = true } } } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-01.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-01.swift index acde8b2ca..ce6ce7715 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-01.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-01.swift @@ -1,20 +1,22 @@ import SwiftUI import Puddles -import Combine -struct MyView: View { - @ObservedObject var interface: Interface +struct QueryableDemoView: View { + @ObservedObject var interface: Interface + let state: ViewState var body: some View { Button("Delete") { interface.sendAction(.deleteButtonTapped) } } + } -extension MyView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() +extension QueryableDemoView { + + struct ViewState { + } enum Action { diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-02.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-02.swift index a739a1a53..90eb62354 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-02.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-02.swift @@ -1,18 +1,31 @@ import SwiftUI import Puddles -struct MyCoordinator: Coordinator { - @StateObject var interface: MyView.Interface = .init() +struct QueryableDemo: Coordinator { + @StateObject var viewInterface: Interface = .init() + + var viewState: QueryableDemoView.ViewState { + .init( + + ) + } var entryView: some View { - MyView(interface: interface) + QueryableDemoView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: QueryableDemoView.Action) { // Handle action } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-03.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-03.swift index c7ab2a3f8..d54f0cdb0 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-03.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-03.swift @@ -1,13 +1,19 @@ import SwiftUI import Puddles -struct MyCoordinator: Coordinator { - @StateObject var interface: MyView.Interface = .init() +struct QueryableDemo: Coordinator { + @StateObject var viewInterface: Interface = .init() @State private var isShowingConfirmationAlert: Bool = false + var viewState: QueryableDemoView.ViewState { + .init( + + ) + } + var entryView: some View { - MyView(interface: interface) + QueryableDemoView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { @@ -25,7 +31,14 @@ struct MyCoordinator: Coordinator { } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: QueryableDemoView.Action) { // Handle action } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-04.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-04.swift index 8d0c8cd20..ca528ed07 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-04.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-04.swift @@ -1,13 +1,19 @@ import SwiftUI import Puddles -struct MyCoordinator: Coordinator { - @StateObject var interface: MyView.Interface = .init() +struct QueryableDemo: Coordinator { + @StateObject var viewInterface: Interface = .init() @State private var isShowingConfirmationAlert: Bool = false + var viewState: QueryableDemoView.ViewState { + .init( + + ) + } + var entryView: some View { - MyView(interface: interface) + QueryableDemoView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { @@ -25,10 +31,17 @@ struct MyCoordinator: Coordinator { } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: QueryableDemoView.Action) { switch action { case .deleteButtonTapped: isShowingConfirmationAlert = true } - } + } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-05.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-05.swift index 92bd88f41..f0b88495b 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-05.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-01-05.swift @@ -1,13 +1,19 @@ import SwiftUI import Puddles -struct MyCoordinator: Coordinator { - @StateObject var interface: MyView.Interface = .init() +struct QueryableDemo: Coordinator { + @StateObject var viewInterface: Interface = .init() @State private var isShowingConfirmationAlert: Bool = false + var viewState: QueryableDemoView.ViewState { + .init( + + ) + } + var entryView: some View { - MyView(interface: interface) + QueryableDemoView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { @@ -15,7 +21,7 @@ struct MyCoordinator: Coordinator { title: "Do you want to delete this?", isPresented: $isShowingConfirmationAlert) { Button("Cancel", role: .cancel) { - // Do nothing + // Cancel } Button("OK") { delete() @@ -25,7 +31,13 @@ struct MyCoordinator: Coordinator { } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: QueryableDemoView.Action) { switch action { case .deleteButtonTapped: isShowingConfirmationAlert = true @@ -35,4 +47,5 @@ struct MyCoordinator: Coordinator { private func delete() { // Apply the deletion } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-01.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-01.swift index 92bd88f41..f0b88495b 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-01.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-01.swift @@ -1,13 +1,19 @@ import SwiftUI import Puddles -struct MyCoordinator: Coordinator { - @StateObject var interface: MyView.Interface = .init() +struct QueryableDemo: Coordinator { + @StateObject var viewInterface: Interface = .init() @State private var isShowingConfirmationAlert: Bool = false + var viewState: QueryableDemoView.ViewState { + .init( + + ) + } + var entryView: some View { - MyView(interface: interface) + QueryableDemoView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { @@ -15,7 +21,7 @@ struct MyCoordinator: Coordinator { title: "Do you want to delete this?", isPresented: $isShowingConfirmationAlert) { Button("Cancel", role: .cancel) { - // Do nothing + // Cancel } Button("OK") { delete() @@ -25,7 +31,13 @@ struct MyCoordinator: Coordinator { } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: QueryableDemoView.Action) { switch action { case .deleteButtonTapped: isShowingConfirmationAlert = true @@ -35,4 +47,5 @@ struct MyCoordinator: Coordinator { private func delete() { // Apply the deletion } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-02.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-02.swift index 48acdbb22..660f443fe 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-02.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-02.swift @@ -1,13 +1,19 @@ import SwiftUI import Puddles -struct MyCoordinator: Coordinator { - @StateObject var interface: MyView.Interface = .init() +struct QueryableDemo: Coordinator { + @StateObject var viewInterface: Interface = .init() - @State private var deletionConfirmation = Expectation.guaranteedCompletion() + @Queryable private var deletionConfirmation + + var viewState: QueryableDemoView.ViewState { + .init( + + ) + } var entryView: some View { - MyView(interface: interface) + QueryableDemoView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { @@ -15,7 +21,7 @@ struct MyCoordinator: Coordinator { title: "Do you want to delete this?", isPresented: $isShowingConfirmationAlert) { Button("Cancel", role: .cancel) { - // Do nothing + // Cancel } Button("OK") { delete() @@ -25,7 +31,13 @@ struct MyCoordinator: Coordinator { } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: QueryableDemoView.Action) { switch action { case .deleteButtonTapped: isShowingConfirmationAlert = true @@ -35,4 +47,5 @@ struct MyCoordinator: Coordinator { private func delete() { // Apply the deletion } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-03.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-03.swift index 6569bfcde..2d46d7c6a 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-03.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-03.swift @@ -1,33 +1,46 @@ import SwiftUI import Puddles -struct MyCoordinator: Coordinator { - @StateObject var interface: MyView.Interface = .init() +struct QueryableDemo: Coordinator { + @StateObject var viewInterface: Interface = .init() - @State private var deletionConfirmation = Expectation.guaranteedCompletion() + @Queryable private var deletionConfirmation + + var viewState: QueryableDemoView.ViewState { + .init( + + ) + } var entryView: some View { - MyView(interface: interface) + QueryableDemoView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { - Expecting($deletionConfirmation) { isActive, expectation in + QueryControlled(by: deletionConfirmation) { isActive, query in Alert( title: "Do you want to delete this?", - isPresented: isActive) { - Button("Cancel", role: .cancel) { - expectation.complete(with: false) - } - Button("OK") { - expectation.complete(with: true) - } - } message: { - Text("This cannot be reversed!") + isPresented: isActive + ) { + Button("Cancel", role: .cancel) { + query.answer(with: false) + } + Button("OK") { + query.answer(with: true) } + } message: { + Text("This cannot be reversed!") + } } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: QueryableDemoView.Action) { switch action { case .deleteButtonTapped: isShowingConfirmationAlert = true @@ -37,4 +50,5 @@ struct MyCoordinator: Coordinator { private func delete() { // Apply the deletion } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-04.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-04.swift index 3344e5fac..efd1fc3a7 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-04.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-Expectations/expectations-02-04.swift @@ -1,44 +1,62 @@ import SwiftUI import Puddles -struct MyCoordinator: Coordinator { - @StateObject var interface: MyView.Interface = .init() +struct QueryableDemo: Coordinator { + @StateObject var viewInterface: Interface = .init() - @State private var deletionConfirmation = Expectation.guaranteedCompletion() + @Queryable private var deletionConfirmation + + var viewState: QueryableDemoView.ViewState { + .init( + + ) + } var entryView: some View { - MyView(interface: interface) + QueryableDemoView(interface: viewInterface, state: viewState) } func navigation() -> some NavigationPattern { - Expecting($deletionConfirmation) { isActive, expectation in + QueryControlled(by: deletionConfirmation) { isActive, query in Alert( title: "Do you want to delete this?", - isPresented: isActive) { - Button("Cancel", role: .cancel) { - expectation.complete(with: false) - } - Button("OK") { - expectation.complete(with: true) - } - } message: { - Text("This cannot be reversed!") + isPresented: isActive + ) { + Button("Cancel", role: .cancel) { + query.answer(with: false) + } + Button("OK") { + query.answer(with: true) } + } message: { + Text("This cannot be reversed!") + } } } - func handleAction(_ action: Action) async { + func interfaces() -> some InterfaceObservation { + InterfaceObserver(viewInterface) { action in + handleViewAction(action) + } + } + + private func handleViewAction(_ action: QueryableDemoView.Action) { switch action { case .deleteButtonTapped: - deletionConfirmation.show() - if await deletionConfirmation.result { - delete() + Task { + do { + if try await deletionConfirmation.query() { + delete() + } + } catch { + // Error Handling + } } - // Alerts close automatically, so no need to do it manually } } private func delete() { // Apply the deletion } + } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-01.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-01.swift index 5e9eacf03..430929789 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-01.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-01.swift @@ -1,29 +1,36 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { - @ObservedObject var interface: Interface + @ObservedObject var interface: Interface + let state: ViewState var body: some View { VStack { - Text("Button tapped \(interface.buttonTapCount) times.") - Button("Tap Me") { - interface.sendAction(.buttonTapped) - } + Text("Button tapped \(state.buttonTapCount) times.") + Button("Tap Me") { + interface.sendAction(.buttonTaped) + } } } } extension HomeView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() - @Published var buttonTapCount: Int = 0 + struct ViewState { + var buttonTapCount: Int + + init(buttonTapCount: Int = 0) { + self.buttonTapCount = buttonTapCount + } + + static var mock: ViewState { + .init(buttonTapCount: 10) + } } enum Action { - case buttonTapped + case buttonTaped } } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-02.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-02.swift index 9545192bd..77d6e8569 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-02.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-02.swift @@ -1,35 +1,44 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { - @ObservedObject var interface: Interface + @ObservedObject var interface: Interface + let state: ViewState var body: some View { VStack { - Text("Button tapped \(interface.buttonTapCount) times.") - Button("Tap Me") { - interface.sendAction(.buttonTapped) - } + Text("Button tapped \(state.buttonTapCount) times.") + Button("Tap Me") { + interface.sendAction(.buttonTaped) + } } } } extension HomeView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() - @Published var buttonTapCount: Int = 0 + struct ViewState { + var buttonTapCount: Int + + init(buttonTapCount: Int = 0) { + self.buttonTapCount = buttonTapCount + } + + static var mock: ViewState { + .init(buttonTapCount: 10) + } } enum Action { - case buttonTapped + case buttonTaped } } struct HomeView_Previews: PreviewProvider { static var previews: some View { - HomeView(interface: .init()) + Preview(HomeView.init, state: .mock) { action, $state in + + } } } diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-03.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-03.swift index 8cf04e16a..faa8eaa0e 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-03.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-03.swift @@ -1,41 +1,47 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { - @ObservedObject var interface: Interface + @ObservedObject var interface: Interface + let state: ViewState var body: some View { VStack { - Text("Button tapped \(interface.buttonTapCount) times.") - Button("Tap Me") { - interface.sendAction(.buttonTapped) - } + Text("Button tapped \(state.buttonTapCount) times.") + Button("Tap Me") { + interface.sendAction(.buttonTaped) + } } } } extension HomeView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() - @Published var buttonTapCount: Int = 0 + struct ViewState { + var buttonTapCount: Int + + init(buttonTapCount: Int = 0) { + self.buttonTapCount = buttonTapCount + } + + static var mock: ViewState { + .init(buttonTapCount: 10) + } } enum Action { - case buttonTapped + case buttonTaped } } struct HomeView_Previews: PreviewProvider { static var previews: some View { - Preview(HomeView.init, interface: .init()) { action, interface in + Preview(HomeView.init, state: .mock) { action, $state in switch action { - case .buttonTapped: - interface.buttonTapCount += 1 + case .buttonTaped: + state.buttonTapCount += 1 } } } } - diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-04.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-04.swift index 2fb582d14..829856cee 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-04.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-04.swift @@ -1,45 +1,51 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { - @ObservedObject var interface: Interface + @ObservedObject var interface: Interface + let state: ViewState var body: some View { VStack { - Text("Button tapped \(interface.buttonTapCount) times.") - Button("Tap Me") { - interface.sendAction(.buttonTapped) - } + Text("Button tapped \(state.buttonTapCount) times.") + Button("Tap Me") { + interface.sendAction(.buttonTaped) + } } } } extension HomeView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() - @Published var buttonTapCount: Int = 0 + struct ViewState { + var buttonTapCount: Int + + init(buttonTapCount: Int = 0) { + self.buttonTapCount = buttonTapCount + } + + static var mock: ViewState { + .init(buttonTapCount: 10) + } } enum Action { - case buttonTapped + case buttonTaped } } struct HomeView_Previews: PreviewProvider { static var previews: some View { - Preview(HomeView.init, interface: .init()) { action, interface in + Preview(HomeView.init, state: .mock) { action, $state in switch action { - case .buttonTapped: - interface.buttonTapCount += 1 + case .buttonTaped: + state.buttonTapCount += 1 } } - .onStart { interface in - try? await Task.sleep(nanoseconds: 1_000_000_000) - interface.buttonTapCount = 40 + .onStart { $state in + try? await Task.sleep(nanoseconds: 2_000_000_000) + state.buttonTapCount = 40 } } } - diff --git a/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-05.swift b/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-05.swift index b777a3ec5..1a6070a8a 100644 --- a/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-05.swift +++ b/Sources/Puddles/Puddles.docc/Resources/code-files/05-PreviewSupport/PreviewSupport-01-05.swift @@ -1,50 +1,56 @@ import SwiftUI import Puddles -import Combine struct HomeView: View { - @ObservedObject var interface: Interface + @ObservedObject var interface: Interface + let state: ViewState var body: some View { VStack { - Text("Button tapped \(interface.buttonTapCount) times.") - Button("Tap Me") { - interface.sendAction(.buttonTapped) - } + Text("Button tapped \(state.buttonTapCount) times.") + Button("Tap Me") { + interface.sendAction(.buttonTaped) + } } } } extension HomeView { - @MainActor final class Interface: ViewInterface { - var actionPublisher: PassthroughSubject = .init() - @Published var buttonTapCount: Int = 0 + struct ViewState { + var buttonTapCount: Int + + init(buttonTapCount: Int = 0) { + self.buttonTapCount = buttonTapCount + } + + static var mock: ViewState { + .init(buttonTapCount: 10) + } } enum Action { - case buttonTapped + case buttonTaped } } struct HomeView_Previews: PreviewProvider { static var previews: some View { - Preview(HomeView.init, interface: .init()) { action, interface in + Preview(HomeView.init, state: .mock) { action, $state in switch action { - case .buttonTapped: - interface.buttonTapCount += 1 + case .buttonTaped: + state.buttonTapCount += 1 } } - .onStart { interface in - try? await Task.sleep(nanoseconds: 1_000_000_000) - interface.buttonTapCount = 40 + .onStart { $state in + try? await Task.sleep(nanoseconds: 2_000_000_000) + state.buttonTapCount = 40 } - .overlay(alignment: .bottom) { interface in + .overlay(alignment: .bottom) { $state in Button("Reset") { - interface.buttonTapCount = 0 + state.buttonTapCount = 0 } } } } - diff --git a/Sources/Puddles/Puddles.docc/Tutorials/Essentials/01-Introduction.tutorial b/Sources/Puddles/Puddles.docc/Tutorials/Essentials/01-Introduction.tutorial index 0e5093615..650d6aaae 100644 --- a/Sources/Puddles/Puddles.docc/Tutorials/Essentials/01-Introduction.tutorial +++ b/Sources/Puddles/Puddles.docc/Tutorials/Essentials/01-Introduction.tutorial @@ -16,9 +16,9 @@ } @Step { - Add the two required methods to `Root`. + Add the two required methods and one required property to `Root`. - We'll add navigation later. + We'll talk about interfaces and navigation later. @Code(name: "Root.swift", file: "essentials-01-02") } @@ -26,7 +26,7 @@ @Step { Set `Root` as the root view of the app and run it. - @Code(name: "YourApp.swift", file: "essentials-01-03") { + @Code(name: "FirstChapterApp.swift", file: "essentials-01-03") { @Image(source: "tutorial_essentials_01_02", alt: "A screenshot of an iPhone running the app. It shows the text 'Hello, World'.") } } @@ -44,12 +44,12 @@ @Code(name: "HomeView.swift", file: "essentials-02-01") } - Next, the view needs a place to hold public state that can be created by and managed through a `Coordinator`. This is done via a ``Puddles/ViewInterface``, which is a wrapper around `ObservableObject`. + Next, the view needs a place to hold public state that can be created by and managed through a `Coordinator`. @Step { - Add a view interface to `HomeView`. + Create and add a `ViewState` to `HomeView`. - The ``Puddles/ViewInterface`` protocol requires a property `actionPublisher` which will be discussed in . For now, simply add the property to the interface. + Notice that the view itself cannot manipulate the state. That is the job of a `Coordinator`. @Code(name: "HomeView.swift", file: "essentials-02-02") } @@ -62,12 +62,10 @@ } @Step { - For now, we connect the button action to the interface, so that the value increases with each tap. + Leave the button's action closure empty for now. - This approach is not encouraged, since all communication should go through a `Coordinator` in a unidirectional flow of data. In the next tutorial, we will replace the method inside the interface with something better. - @Code(name: "HomeView.swift", file: "essentials-02-04") { - @Image(source: "tutorial_01_essentials_02_04", alt: "A screenshot of an iPhone running the app. It shows the home view, now with a working button.") - } + In the next tutorial, we will look at a way to update the view's state. + @Code(name: "HomeView.swift", file: "essentials-02-04") } @Step { @@ -77,6 +75,8 @@ @Step { Add the newly created `HomeView` and set it as the coordinator's entry view. + + Notice that the `Coordinator` holds the actual `@State` for the view. This is important to keep responsibilities structured and away from the view. @Code(name: "Root.swift", file: "essentials-02-06") } diff --git a/Sources/Puddles/Puddles.docc/Tutorials/Essentials/02-ViewInteraction.tutorial b/Sources/Puddles/Puddles.docc/Tutorials/Essentials/02-ViewInteraction.tutorial index 3e1d9c45e..6a82738c5 100644 --- a/Sources/Puddles/Puddles.docc/Tutorials/Essentials/02-ViewInteraction.tutorial +++ b/Sources/Puddles/Puddles.docc/Tutorials/Essentials/02-ViewInteraction.tutorial @@ -6,12 +6,12 @@ @Section(title: "Expanding The View Interface") { - In the previous tutorial, we built a simple counter view that communicated with the interface to modify its own state. In the `Coordinator` pattern, this is discouraged. + In the previous tutorial, we built a simple view with a button that increases a counter value. However, since the view's state is immutable, the button cannot actually update that value itself. - Views should not modify their own public state. Instead, they define signals (called actions) that their `Coordinator` can intercept and act upon. - - This can be achieved through the ``Puddles/ViewInterface`` protocol. It exposes a property ``Puddles/ViewInterface/actionPublisher`` that consumes an `Action` (usually an `enum`). A `Coordinator` automatically subscribes to that publisher and calls a ``Puddles/Coordinator/handleAction(_:)-4le7d`` method that we can implement to react to any actions sent by the view. Inside that method, we would modify the view's state. + That's by design. Views should not modify their own public state. Instead, they should define signals (called actions) that their parent `Coordinator` can observe and act upon. + This can be achieved through the ``Puddles/Interface`` class. It exposes a property ``Puddles/Interface/actionPublisher`` that consumes an `Action` (usually an `enum` defined by a view). A `Coordinator` owns and observes such an interface and provides it to the view as an `@ObservedObject`. Whenever an action is sent, the `Coordinator` can detect it and make modifications to the state or start any necessary other work. + Adding this to the example app is easy. @Steps { @@ -24,15 +24,15 @@ } @Step { - Add an `Action` enum and set it as the type that the `actionPublisher` emits. + Add an `Action` enum and and an interface property. @Code(name: "HomeView.swift", file: "02-ViewInteractions-01-02") } @Step { - Replace the call to `buttonTapped()` with a call to send the action `.buttonTapped`. + Inside the button's action closure, use the interface to send the action `.buttonTapped`. - The view is now fully unaware of its context or consequences for its actions. This is great, because we can now place it anywhere in the app and let the responsible `Coordinator` decide what should happen. + What's important here is that the view is fully unaware of its context or consequences for any of its actions. This is great, because we can place it anywhere in the app and let the responsible `Coordinator` decide what should happen. @Code(name: "HomeView.swift", file: "02-ViewInteractions-01-03") } @@ -45,18 +45,16 @@ @Code(name: "Root.swift", file: "02-ViewInteractions-01-04") } - Setting the `Action` type in the view interface triggers a new protocol requirement in the `Coordinator`. We now need to provide a ``Puddles/Coordinator/handleAction(_:)-4le7d`` method to handle all incoming actions from the view. - @Step { - Add the ``Puddles/Coordinator/handleAction(_:)-38d52`` method. + Add the view's interface as a `@StateObject` and pass it to the view. @Code(name: "Root.swift", file: "02-ViewInteractions-01-05") } @Step { - We can now switch over all actions and update the view's state through its interface. This is similar to before, but now we do it from within the `Coordinator`. - - The result is identical to the one we built in the previous tutorial, but much cleaner and modular. + Inside the ``Puddles/Coordinator/interfaces()`` method, add an ``Puddles/InterfaceObserver`` and relay incoming actions to a new method `handleViewAction(_:)`. + + We can now switch over all actions and update the view's state through its interface. @Code(name: "Root.swift", file: "02-ViewInteractions-01-06") { @Image(source: "02-ViewInteractions-02-06_image", alt: "A screenshot of an iPhone running the app. It shows the home view.") diff --git a/Sources/Puddles/Puddles.docc/Tutorials/Essentials/04-PreviewSupport.tutorial b/Sources/Puddles/Puddles.docc/Tutorials/Essentials/04-PreviewSupport.tutorial index 0dfbd1654..18bc9d5dd 100644 --- a/Sources/Puddles/Puddles.docc/Tutorials/Essentials/04-PreviewSupport.tutorial +++ b/Sources/Puddles/Puddles.docc/Tutorials/Essentials/04-PreviewSupport.tutorial @@ -18,31 +18,30 @@ } @Step { - Add the default Previews object. + Add a preview using the ``Puddles/Preview`` view wrapper, which will hold the views state and connect to its interface. - The problem with this is that we cannot actually use the counter in the view. Pressing the button does nothing. + Tip: Prefixing the state closure parameter with a `$` gives you more ergonomic access to the binding. @Code(name: "HomeView.swift", file: "PreviewSupport-01-02") } - - Instead, we are going to use ``Puddles/Preview`` . + @Step { - Wrap the view with a ``Puddles/Preview`` view, passing in `HomeView` and an instance of its interface. + Inside the provided closure, you can switch over incoming view actions and modify the state as needed - `Preview` connects with the interface and calls the provided closure when an Action is sent. In that closure, you have access to the interface object and can manipulate the view's state. + `Preview` automatically connects with the view interface and calls the provided closure when an Action is sent. @Code(name: "HomeView.swift", file: "PreviewSupport-01-03") } @Step { - To load or set initial data, you can add the ``Puddles/Preview/onStart(perform:)`` modifier, which calls the provided closure when the view appears. + To load or set initial asynchronous data, you can add the ``Puddles/Preview/onStart(perform:)`` modifier, which calls the provided closure when the preview appears. Tip: See ``Puddles/Coordinator/start()-e3dl`` for more information on how the `start()` of a `Coordinator` is defined. @Code(name: "HomeView.swift", file: "PreviewSupport-01-04") } @Step { - If you want to add preview-specific debug functionality, you can use the ``Puddles/Preview/overlay(alignment:overlayContent:)`` view modifier overload which lets you return a view that is put on top of the preview, while also providing you with access to the view interface. + If you want to add preview-specific debug functionality, you can use the ``Puddles/Preview/overlay(alignment:maximizedPreviewFrame:overlayContent:)`` view modifier overload which lets you return a view that is put on top of the preview, while also providing you with access to a modifiable view state. @Code(name: "HomeView.swift", file: "PreviewSupport-01-05") } diff --git a/Sources/Puddles/Puddles.docc/Tutorials/Essentials/05-Expectations.tutorial b/Sources/Puddles/Puddles.docc/Tutorials/Essentials/05-Queryable.tutorial similarity index 51% rename from Sources/Puddles/Puddles.docc/Tutorials/Essentials/05-Expectations.tutorial rename to Sources/Puddles/Puddles.docc/Tutorials/Essentials/05-Queryable.tutorial index 3118a88eb..43d59e705 100644 --- a/Sources/Puddles/Puddles.docc/Tutorials/Essentials/05-Expectations.tutorial +++ b/Sources/Puddles/Puddles.docc/Tutorials/Essentials/05-Queryable.tutorial @@ -9,21 +9,21 @@ Sometimes an action triggers a flow that requires user input or some form of other delayed and disconnected data to complete. An example of this would be a confirmation dialog for a deletion process. - Such a dialog would typically be triggered from within a `Coordinator`'s ``Puddles/Coordinator/handleAction(_:)-4le7d`` method and finished within the dialog's completion handlers. This logically separates the deletion flow and makes it harder to reason about the code. Let's have a look at an example to see the solution to this problem. + Such a dialog would typically be triggered in reaction to an action from a view interface. However, the dialog view itself and its completion handlers are defined someplace else. This logically separates the deletion flow and makes it harder to reason about the code. Let's have a look at an example to see the solution to this problem. @Steps { @Step { - We start by adding a simple view containing a button that sends an action to its `Coordinator` via a ``Puddles/ViewInterface``. + We start by adding a simple view containing a button that sends an action to its `Coordinator` via an ``Puddles/Interface``. - The action is supposed to represent a delete intention. To make sure the button hasn't been accidentally tapped, the app should display a confirmation dialog before actually deleting something. + The action is supposed to represent a delete intention. To make sure the button been tapped intentionally, the app should display a confirmation dialog before actually deleting something. - @Code(name: "MyView.swift", file: "expectations-01-01") + @Code(name: "QueryableDemoView.swift", file: "expectations-01-01") } @Step { Next, we create a `Coordinator` for the view. - @Code(name: "MyCoordinator.swift", file: "expectations-01-02") + @Code(name: "QueryableDemo.swift", file: "expectations-01-02") } We need a state that's going to drive the confirmation dialog appearance, as well as the dialog view itself, which is an ``Puddles/Alert`` that we place inside the `Coordinator`'s ``Puddles/Coordinator/navigation()`` method. @@ -33,12 +33,12 @@ ``Puddles/Alert`` is only one of the available ``Puddles/NavigationPattern``s. Others include ``Puddles/Sheet``, ``Puddles/Push`` and more. - @Code(name: "MyCoordinator.swift", file: "expectations-01-03") + @Code(name: "QueryableDemo.swift", file: "expectations-01-03") } @Step { Then we have to trigger the state whenever a `deleteButtonTapped` action has been sent. - @Code(name: "MyCoordinator.swift", file: "expectations-01-04") + @Code(name: "QueryableDemo.swift", file: "expectations-01-04") } Finally, we need to add the actual deletion logic that should be triggered upon confirmation. @@ -48,7 +48,7 @@ In this case, the cancel button does not need an action, so we can leave it empty. - @Code(name: "MyCoordinator.swift", file: "expectations-01-05") + @Code(name: "QueryableDemo.swift", file: "expectations-01-05") } That's it! However, in Section 2, we will take a look at a difference approach. @@ -58,47 +58,47 @@ @Section(title: "A Better Approach") { The approach in Section 1 is fine, but it has a significant weakness: We ended up separating logically connected functionality. - The call to `delete()` is still an effect of the `deleteButtonTapped` action that we handle in `handleAction(_:)`. + The call to `delete()` is still an effect of the `.deleteButtonTapped` action that we handle in `handleViewAction(_:)`. The code, however, is split up, since the confirmation dialog with its completion handlers is defined someplace else. - That's why the `Coordinator` package introduces a concept called expectations. An ``Puddles/Expectation`` is a type that can be used to trigger the collection of some kind of data (like a `Bool` coming from a confirmation dialog) and asynchronously `await` its retrieval in-place, without ever leaving the scope. + That's why ``Puddles`` introduces the ``Puddles/Queryable`` property wrapper. A `Queryable` is a type that can be used to trigger the collection of some kind of data (like a `Bool` coming from a confirmation dialog) and asynchronously `await` its retrieval in-place, without ever leaving the scope. @Steps { Let's modify the example from Section 1 to use expectations. @Step { - Go back to `MyCoordinator.swift`. + Go back to `QueryableDemo.swift`. - @Code(name: "MyCoordinator.swift", file: "expectations-02-01") + @Code(name: "QueryableDemo.swift", file: "expectations-02-01") } @Step { - Replace `isShowingConfirmationAlert` with a new property of type `Expectation`, - since we expect to collect a boolean whenever we trigger the confirmation dialog. + Replace the `isShowingConfirmationAlert` state variable with a `@Queryable` property. - Because a confirmation dialog cannot be externally cancelled by the user (like a dismissible sheet, for instance), we initialize the expectation with the promise of a guaranteed result, calling ``Puddles/Expectation/guaranteedCompletion()``. When no such guarantee is possible or reasonable, there is another initializer ``Puddles/Expectation/userCancellable(resultOnUserCancel:)`` that let's us specify what should happen when the expectation gets cancelled by the user or through other means. - - @Code(name: "MyCoordinator.swift", file: "expectations-02-02") + @Code(name: "QueryableDemo.swift", file: "expectations-02-02") } @Step { - Next, wrap the ``Puddles/Alert`` in a new type called ``Puddles/Expecting``. It takes a binding to an expectation as its argument and provides us with a closure that we can use to display any kind of navigation pattern. + Next, wrap the ``Puddles/Alert`` in a new type called ``Puddles/QueryControlled``. It takes a binding to a `Queryable` as its argument and provides us with a closure that we can use to display any kind of navigation pattern. - The closure has two arguments. First, it gives us a binding to an `isActive` state that we can pass to the presentation. It also gives us an expectation resolver that we can use to complete (or fulfill) the expectation. + The closure has two arguments. First, it gives us a binding to an `isActive` state that we can pass to the presentation. It also gives us a query resolver that we can use to answer (i.e. complete) the query. - @Code(name: "MyCoordinator.swift", file: "expectations-02-03") + @Code(name: "QueryableDemo.swift", file: "expectations-02-03") } - Finally, we can modify the `handleAction(_:)` method to take advantage of the new expectation. + Finally, we can modify the `handleViewAction(_:)` method to take advantage of the new `Queryable` property. @Step { - To show the confirmation alert, add a call to ``Puddles/Expectation/show()`` on the expectation. Then we can simply `await` the result through its ``Puddles/Expectation/result`` property. Lastly, we call `delete()` if necessary. - The entire logical flow caused by the `deleteButtonTapped` action now takes place inside the same call to `handleAction(_:)`. + To trigger the collection of a deletion confirmation, simply call ``Puddles/Queryable/Wrapper/query()`` on the `Queryable` property. This will suspend the Task until the query has been answered (by the Alert, in our case). + + Calling ``Puddles/Queryable/Wrapper/query()`` causes the associated `isActive` state to be set to true, making the Alert appear. Cancelling the surrounding task will also cancel the ongoing `query`. For more information, see ``Puddles/Queryable``. - @Code(name: "MyCoordinator.swift", file: "expectations-02-04") + @Code(name: "QueryableDemo.swift", file: "expectations-02-04") } + + The entire logical flow caused by the `deleteButtonTapped` action now takes place inside the same call to `handleViewAction(_:)`. } } } diff --git a/Sources/Puddles/Puddles.docc/Tutorials/PuddlesTutorials.tutorial b/Sources/Puddles/Puddles.docc/Tutorials/PuddlesTutorials.tutorial index 473b463f7..811534fd1 100644 --- a/Sources/Puddles/Puddles.docc/Tutorials/PuddlesTutorials.tutorial +++ b/Sources/Puddles/Puddles.docc/Tutorials/PuddlesTutorials.tutorial @@ -1,4 +1,4 @@ -@Tutorials(name: "Coordinator") { +@Tutorials(name: "Puddles") { @Intro(title: "Meet Puddles") { An architectural pattern for apps built on the SwiftUI lifecycle. It helps separating navigation and logic from the views. @Image(source: "logo.png", alt: "An illustration of a sleeping sloth, hanging from a tree branch.") @@ -12,7 +12,7 @@ @TutorialReference(tutorial: "doc:02-ViewInteraction") @TutorialReference(tutorial: "doc:03-BasicNavigation") @TutorialReference(tutorial: "doc:04-PreviewSupport") - @TutorialReference(tutorial: "doc:05-Expectations") + @TutorialReference(tutorial: "doc:05-Queryable") @TutorialReference(tutorial: "doc:06-ProvidingData") } diff --git a/Sources/Puddles/Puddles.swift b/Sources/Puddles/Puddles.swift new file mode 100644 index 000000000..2cfa449bf --- /dev/null +++ b/Sources/Puddles/Puddles.swift @@ -0,0 +1,12 @@ +import OSLog + + +fileprivate(set) var logger: Logger = .init(OSLog.disabled) + +public struct Puddles { + + public static func configureLog(inSubsystem subsystem: String? = Bundle.main.bundleIdentifier) { + logger = .init(subsystem: subsystem ?? "Puddles", category: "Puddles") + } +} + diff --git a/Sources/Puddles/View Interface/Observations/Build Blocks/AsyncInterfaceObserver.swift b/Sources/Puddles/View Interface/Observations/Build Blocks/AsyncInterfaceObserver.swift deleted file mode 100644 index 9fc35e4f0..000000000 --- a/Sources/Puddles/View Interface/Observations/Build Blocks/AsyncInterfaceObserver.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI - -public struct AsyncInterfaceObserver>: InterfaceObservation { - - var interface: I - var actionHandler: @MainActor (_ action: Action) async -> Void - - public init( - _ interface: I, - actionHandler: @MainActor @escaping (_ action: Action) async -> Void - ) { - self.interface = interface - self.actionHandler = actionHandler - } -} - -public extension AsyncInterfaceObserver { - - @MainActor - var body: some View { - Color.clear - .onReceive(interface.actionPublisher) { action in - Task { - await actionHandler(action) - } - } - } -} diff --git a/Sources/Puddles/View Interface/Observations/Build Blocks/InterfaceStreamObserver.swift b/Sources/Puddles/View Interface/Observations/Build Blocks/InterfaceStreamObserver.swift deleted file mode 100644 index 9d34d480a..000000000 --- a/Sources/Puddles/View Interface/Observations/Build Blocks/InterfaceStreamObserver.swift +++ /dev/null @@ -1,29 +0,0 @@ -import SwiftUI -import Combine - -public struct InterfaceStreamObserver>: InterfaceObservation { - var interface: I - var actionHandler: @MainActor (_ stream: AsyncStream) async -> Void - - public init( - _ interface: I, - actionHandler: @MainActor @escaping (_ stream: AsyncStream) async -> Void - ) { - self.interface = interface - self.actionHandler = actionHandler - } -} - -public extension InterfaceStreamObserver { - - @MainActor - var body: some View { - Color.clear - .task { - // TODO: Keep an eye on this. It cancels and re-starts during navigation pushes. - // Should be fine in terms of use cases (when the view is down the navigation stack, no action should ever be sent anyways) - // Replace with ViewLifetimeHelper if needed - await actionHandler(interface.actionStream) - } - } -} diff --git a/Sources/Puddles/ViewLifetimeHelper.swift b/Sources/Puddles/ViewLifetimeHelper.swift index d8393d2e4..6b2c47d44 100644 --- a/Sources/Puddles/ViewLifetimeHelper.swift +++ b/Sources/Puddles/ViewLifetimeHelper.swift @@ -23,7 +23,7 @@ struct ViewLifetimeHelper: View { private final class LifetimeViewModel: ObservableObject { - private var task: Task + private let task: Task @MainActor var onInit: () async -> Void @MainActor var onDeinit: () -> Void