From 466f87e3934949713077cba073a7fa1c74ef84cf Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Mon, 27 Nov 2023 19:24:09 -0800 Subject: [PATCH 1/9] Add view state mapper --- .../ViewState/OperationState.swift | 12 ++++++ .../{ => ViewState}/ViewStateAlert.swift | 0 .../ViewState/ViewStateMapper.swift | 40 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift rename Sources/SpeziViews/ViewModifier/{ => ViewState}/ViewStateAlert.swift (100%) create mode 100644 Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift diff --git a/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift new file mode 100644 index 0000000..d0d950e --- /dev/null +++ b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift @@ -0,0 +1,12 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +public protocol OperationState { + var viewState: ViewState { get } +} diff --git a/Sources/SpeziViews/ViewModifier/ViewStateAlert.swift b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateAlert.swift similarity index 100% rename from Sources/SpeziViews/ViewModifier/ViewStateAlert.swift rename to Sources/SpeziViews/ViewModifier/ViewState/ViewStateAlert.swift diff --git a/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift new file mode 100644 index 0000000..7c21c4d --- /dev/null +++ b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift @@ -0,0 +1,40 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +private struct ViewStateMapper: ViewModifier { + private let operationState: T + @Binding private var viewState: ViewState + + + init(operationState: T, viewState: Binding) { + self.operationState = operationState + self._viewState = viewState + } + + + func body(content: Content) -> some View { + content + .onChange(of: operationState.viewState) { + viewState = operationState.viewState + } + } +} + +extension View { + /// Maps a state conforming to the ``OperationState`` protocol to a SpeziViews ``ViewState``. + /// - Parameters: + /// - operationState: The source ``OperationState`` that should be mapped to the SpeziViews ``ViewState``. + /// - viewState: A `Binding` to the to-be-written-to ``ViewState``. + public func map(state operationState: T, to viewState: Binding) -> some View { + self + .modifier(ViewStateMapper(operationState: operationState, viewState: viewState)) + } +} From 6bc5d903f08764fd040a067e7fd1f22deaefbaf1 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Tue, 28 Nov 2023 00:07:56 -0800 Subject: [PATCH 2/9] Add docs --- .../SpeziViews/SpeziViews.docc/SpeziViews.md | 1 + .../ViewState/OperationState.swift | 50 +++++++++++++++++++ .../ViewState/ViewStateMapper.swift | 25 ++++++++++ 3 files changed, 76 insertions(+) diff --git a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md index 371f059..81e8a51 100644 --- a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md +++ b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md @@ -19,6 +19,7 @@ SPDX-License-Identifier: MIT - ``ViewState`` - ``SwiftUI/View/viewStateAlert(state:)`` +- ``SwiftUI/View/map(state:to:)`` - ``SwiftUI/View/processingOverlay(isProcessing:overlay:)-5xplv`` - ``SwiftUI/View/processingOverlay(isProcessing:overlay:)-3df8d`` - ``SwiftUI/EnvironmentValues/defaultErrorDescription`` diff --git a/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift index d0d950e..f4ebab1 100644 --- a/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift +++ b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift @@ -7,6 +7,56 @@ // +/// The ``OperationState`` protocol is used to map the state of an operation, for example a specific action or a more complex task being performed, +/// to a SpeziView ``ViewState``, describing the current state of a SwiftUI `View`. +/// +/// Combined with the ``SwiftUI/View/map(state:to:)`` view modifier, the ``OperationState`` provides an easy-to-use +/// bridging mechanism between the state of an operation and a SpeziViews ``ViewState``. One should highlight that this bridging is only done +/// in one direction, so the data flow from the ``OperationState`` towards the ``ViewState``, not the other way around, the reason being that +/// the state of a SwiftUI `View` doesn't influence the state of some arbitrary operation. +/// +/// # Usage +/// +/// ```swift +/// public enum DownloadState { +/// case ready +/// case downloading(progress: Double) +/// case error(LocalizedError) +/// ... +/// } +/// +/// // Map the `DownloadState` to an `ViewState` +/// extension DownloadState: OperationState { +/// public var viewState: ViewState { +/// switch self { +/// case .ready: +/// .idle +/// case .downloading: +/// .processing +/// case .error(let error): +/// .error(error) +/// ... +/// } +/// } +/// } +/// +/// struct StateTestView: View { +/// @State private var downloadState: DownloadState = .ready +/// @State private var viewState: ViewState = .idle +/// +/// var body: some View { +/// EmptyView() +/// // Map the `DownloadState` to the `ViewState` +/// .map(state: downloadState, to: $viewState) +/// // Show alerts based on the `ViewState` +/// .viewStateAlert(state: $viewState) +/// .task { +/// // Changes to the `DownloadState` +/// ... +/// } +/// } +/// } +/// ``` public protocol OperationState { var viewState: ViewState { get } } diff --git a/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift index 7c21c4d..48b0f16 100644 --- a/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift +++ b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift @@ -28,8 +28,33 @@ private struct ViewStateMapper: ViewModifier { } } + extension View { /// Maps a state conforming to the ``OperationState`` protocol to a SpeziViews ``ViewState``. + /// + /// # Usage + /// ```swift + /// struct StateTestView: View { + /// @State private var downloadState: DownloadState = .ready // Conforms to the ``OperationState`` protocol + /// @State private var viewState: ViewState = .idle + /// + /// var body: some View { + /// EmptyView() + /// // Map the `DownloadState` to the `ViewState` + /// .map(state: downloadState, to: $viewState) + /// // Show alerts based on the `ViewState` + /// .viewStateAlert(state: $viewState) + /// .task { + /// // Changes to the `DownloadState` + /// ... + /// } + /// } + /// } + /// ``` + /// + /// # Note + /// The ``OperationState`` documentation contains a complete example using the ``SwiftUI/View/map(state:to:)`` view modifier. + /// /// - Parameters: /// - operationState: The source ``OperationState`` that should be mapped to the SpeziViews ``ViewState``. /// - viewState: A `Binding` to the to-be-written-to ``ViewState``. From e23dc48373ff05aa1e9dad07dc40b622f52ff3ac Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Tue, 28 Nov 2023 08:08:54 -0800 Subject: [PATCH 3/9] Add missing docs --- Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift index f4ebab1..2ca0b35 100644 --- a/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift +++ b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift @@ -58,5 +58,6 @@ /// } /// ``` public protocol OperationState { + /// Requires the implementation of mapping logic from the ``OperationState`` to the ``ViewState``. var viewState: ViewState { get } } From 135d7dd9766260f429d57bf5c738aacb89c65696 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Tue, 28 Nov 2023 14:07:33 -0800 Subject: [PATCH 4/9] Improve docs + Write tests --- .../SpeziViews/SpeziViews.docc/SpeziViews.md | 1 + .../ViewState/OperationState.swift | 51 +++++++------ .../ViewState/ViewStateMapper.swift | 28 ++++--- Tests/UITests/TestApp/Localizable.xcstrings | 3 + .../TestApp/ViewsTests/SpeziViewsTests.swift | 8 ++ .../ViewsTests/ViewStateMapperView.swift | 76 +++++++++++++++++++ .../SpeziViews/ModelTests.swift | 26 ++++++- .../UITests/UITests.xcodeproj/project.pbxproj | 4 + 8 files changed, 157 insertions(+), 40 deletions(-) create mode 100644 Tests/UITests/TestApp/ViewsTests/ViewStateMapperView.swift diff --git a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md index 81e8a51..2f5c5be 100644 --- a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md +++ b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md @@ -18,6 +18,7 @@ SPDX-License-Identifier: MIT ### Managing State - ``ViewState`` +- ``OperationState`` - ``SwiftUI/View/viewStateAlert(state:)`` - ``SwiftUI/View/map(state:to:)`` - ``SwiftUI/View/processingOverlay(isProcessing:overlay:)-5xplv`` diff --git a/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift index 2ca0b35..b749d87 100644 --- a/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift +++ b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift @@ -7,27 +7,34 @@ // -/// The ``OperationState`` protocol is used to map the state of an operation, for example a specific action or a more complex task being performed, -/// to a SpeziView ``ViewState``, describing the current state of a SwiftUI `View`. +/// The ``OperationState`` protocol provides a broad meta model for the current state of a specific action or task conducted within the Spezi ecosystem. +/// An ``OperationState`` is based upon a state of a typical finite automata which has a well-defined start and end state, such as an error state or a result state. /// -/// Combined with the ``SwiftUI/View/map(state:to:)`` view modifier, the ``OperationState`` provides an easy-to-use -/// bridging mechanism between the state of an operation and a SpeziViews ``ViewState``. One should highlight that this bridging is only done -/// in one direction, so the data flow from the ``OperationState`` towards the ``ViewState``, not the other way around, the reason being that -/// the state of a SwiftUI `View` doesn't influence the state of some arbitrary operation. -/// -/// # Usage +/// An example conformance to the ``OperationState`` protocol is showcased in the code snippet below which presents the state of a download task. /// /// ```swift -/// public enum DownloadState { +/// public enum DownloadState: OperationState { /// case ready /// case downloading(progress: Double) /// case error(LocalizedError) /// ... /// } +/// ``` +/// +/// ### Representation as a ViewState /// -/// // Map the `DownloadState` to an `ViewState` -/// extension DownloadState: OperationState { -/// public var viewState: ViewState { +/// The ``OperationState`` encapsulates the core state of an application's behavior, which directly impacts the user interface and interaction. +/// To effectively manage the UI's state in the Spezi framework, the ``OperationState`` can be represented as a ``ViewState``. +/// This bridging mechanism allows Spezi to monitor and respond to changes in the view's state, for example via the ``SwiftUI/View/viewStateAlert(state:)`` view modifier. +/// +/// - Note: It's important to note that this conversion is a lossy process, where a potentially intricate ``OperationState`` is +/// distilled into a simpler ``ViewState``. +/// One should highlight that this bridging is only done in one direction, so from the ``OperationState`` towards the ``ViewState``, +/// the reason being that the state of a SwiftUI `View` doesn't influence the state of some arbitrary operation. +/// +/// ```swift +/// extension DownloadState { +/// public var representation: ViewState { /// switch self { /// case .ready: /// .idle @@ -35,29 +42,27 @@ /// .processing /// case .error(let error): /// .error(error) -/// ... +/// // ... /// } /// } /// } /// -/// struct StateTestView: View { +/// struct OperationStateTestView: View { /// @State private var downloadState: DownloadState = .ready /// @State private var viewState: ViewState = .idle /// /// var body: some View { -/// EmptyView() -/// // Map the `DownloadState` to the `ViewState` -/// .map(state: downloadState, to: $viewState) -/// // Show alerts based on the `ViewState` -/// .viewStateAlert(state: $viewState) +/// Text("Operation State: \(String(describing: operationState))") +/// .map(state: downloadState, to: $viewState) // Map the `DownloadState` to the `ViewState` +/// .viewStateAlert(state: $viewState) // Show alerts based on the derived `ViewState` /// .task { -/// // Changes to the `DownloadState` -/// ... +/// // Changes to the `DownloadState` which are automatically mapped to the `ViewState` +/// // ... /// } /// } /// } /// ``` public protocol OperationState { - /// Requires the implementation of mapping logic from the ``OperationState`` to the ``ViewState``. - var viewState: ViewState { get } + /// Defines the lossy abstraction logic from the possibly complex ``OperationState`` to the simple ``ViewState``. + var representation: ViewState { get } } diff --git a/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift index 48b0f16..e3b9b35 100644 --- a/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift +++ b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift @@ -22,38 +22,36 @@ private struct ViewStateMapper: ViewModifier { func body(content: Content) -> some View { content - .onChange(of: operationState.viewState) { - viewState = operationState.viewState + .onChange(of: operationState.representation) { + viewState = operationState.representation } } } extension View { - /// Maps a state conforming to the ``OperationState`` protocol to a SpeziViews ``ViewState``. + /// Continuously maps a state conforming to the ``OperationState`` protocol to a ``ViewState``. + /// Used to propagate the ``ViewState`` representation of the ``OperationState`` to a ``ViewState`` that lives within a SwiftUI `View`. /// - /// # Usage + /// ### Usage /// ```swift - /// struct StateTestView: View { - /// @State private var downloadState: DownloadState = .ready // Conforms to the ``OperationState`` protocol + /// struct OperationStateTestView: View { + /// @State private var downloadState: DownloadState = .ready // `DownloadState` conforms to `OperationState` /// @State private var viewState: ViewState = .idle /// /// var body: some View { - /// EmptyView() - /// // Map the `DownloadState` to the `ViewState` - /// .map(state: downloadState, to: $viewState) - /// // Show alerts based on the `ViewState` - /// .viewStateAlert(state: $viewState) + /// Text("Operation State: \(String(describing: operationState))") + /// .map(state: downloadState, to: $viewState) // Map the `DownloadState` to the `ViewState` + /// .viewStateAlert(state: $viewState) // Show alerts based on the derived `ViewState` /// .task { - /// // Changes to the `DownloadState` - /// ... + /// // Changes to the `DownloadState` which are automatically mapped to the `ViewState` + /// // ... /// } /// } /// } /// ``` /// - /// # Note - /// The ``OperationState`` documentation contains a complete example using the ``SwiftUI/View/map(state:to:)`` view modifier. + /// - Note: The ``OperationState`` documentation contains a complete example using the ``SwiftUI/View/map(state:to:)`` view modifier. /// /// - Parameters: /// - operationState: The source ``OperationState`` that should be mapped to the SpeziViews ``ViewState``. diff --git a/Tests/UITests/TestApp/Localizable.xcstrings b/Tests/UITests/TestApp/Localizable.xcstrings index f5d5de8..419227b 100644 --- a/Tests/UITests/TestApp/Localizable.xcstrings +++ b/Tests/UITests/TestApp/Localizable.xcstrings @@ -59,6 +59,9 @@ } } } + }, + "Operation State: %@" : { + }, "Reset" : { diff --git a/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift b/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift index 4cea617..1b5c2b6 100644 --- a/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift +++ b/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift @@ -18,6 +18,7 @@ enum SpeziViewsTests: String, TestAppTests { case lazyText = "Lazy Text" case markdownView = "Markdown View" case viewState = "View State" + case viewStateMapper = "View State Mapper" case defaultErrorOnly = "Default Error Only" case defaultErrorDescription = "Default Error Description" case asyncButton = "Async Button" @@ -82,6 +83,11 @@ enum SpeziViewsTests: String, TestAppTests { private var defaultErrorOnly: some View { ViewStateTestView(testError: .init(errorDescription: "Some error occurred!")) } + + @ViewBuilder + private var viewStateMapper: some View { + ViewStateMapperTestView() + } @ViewBuilder private var defaultErrorDescription: some View { @@ -108,6 +114,8 @@ enum SpeziViewsTests: String, TestAppTests { markdownView case .viewState: viewState + case .viewStateMapper: + viewStateMapper case .defaultErrorOnly: defaultErrorOnly case .defaultErrorDescription: diff --git a/Tests/UITests/TestApp/ViewsTests/ViewStateMapperView.swift b/Tests/UITests/TestApp/ViewsTests/ViewStateMapperView.swift new file mode 100644 index 0000000..96dcf4a --- /dev/null +++ b/Tests/UITests/TestApp/ViewsTests/ViewStateMapperView.swift @@ -0,0 +1,76 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +struct ViewStateMapperTestView: View { + enum OperationStateTest: OperationState { + case ready + case someOperationStep + case error(LocalizedError) + + + var representation: ViewState { + switch self { + case .ready: + .idle + case .someOperationStep: + .processing + case .error(let localizedError): + .error(localizedError) + } + } + } + + struct TestError: LocalizedError { + var errorDescription: String? + var failureReason: String? + var helpAnchor: String? + var recoverySuggestion: String? + } + + var testError = TestError( + errorDescription: nil, + failureReason: "Failure Reason", + helpAnchor: "Help Anchor", + recoverySuggestion: "Recovery Suggestion" + ) + + @State var operationState: OperationStateTest = .ready + @State var viewState: ViewState = .idle + + + var body: some View { + VStack { + Text("View State: \(String(describing: viewState))") + .padding(.bottom, 12) + + Text("Operation State: \(String(describing: operationState))") + .accessibilityIdentifier("operationState") + } + .task { + operationState = .someOperationStep + try? await Task.sleep(for: .seconds(10)) + operationState = .error( + AnyLocalizedError( + error: testError, + defaultErrorDescription: "Error Description" + ) + ) + } + .map(state: operationState, to: $viewState) + .viewStateAlert(state: $viewState) + } +} + + +#Preview { + ViewStateMapperTestView() +} diff --git a/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift b/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift index 5ef5988..3f2b8a4 100644 --- a/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift @@ -38,7 +38,30 @@ final class ModelTests: XCTestCase { alert.buttons["OK"].tap() XCTAssert(app.staticTexts["View State: idle"].waitForExistence(timeout: 2)) - app.staticTexts["View State: idle"].tap() + } + + func testViewStateMapper() throws { + let app = XCUIApplication() + + XCTAssert(app.collectionViews.buttons["View State Mapper"].waitForExistence(timeout: 2)) + app.collectionViews.buttons["View State Mapper"].tap() + + XCTAssert(app.staticTexts["View State: processing"].waitForExistence(timeout: 2)) + XCTAssert(app.staticTexts["Operation State: someOperationStep"].waitForExistence(timeout: 2)) + + sleep(12) + + let alert = app.alerts.firstMatch.scrollViews.otherElements + XCTAssert(alert.staticTexts["Error Description"].exists) + XCTAssert(alert.staticTexts["Failure Reason\n\nHelp Anchor\n\nRecovery Suggestion"].exists) + alert.buttons["OK"].tap() + + sleep(2) + + XCTAssert(app.staticTexts["View State: idle"].waitForExistence(timeout: 2)) + // Operation state must stay in the old state as it is not influenced by the dismissal + // of the error alert (which moves the ViewState back to idle) + XCTAssert(app.staticTexts["operationState"].label.contains("Operation State: error")) } func testDefaultErrorDescription() throws { @@ -57,6 +80,5 @@ final class ModelTests: XCTestCase { alert.buttons["OK"].tap() XCTAssert(app.staticTexts["View State: idle"].waitForExistence(timeout: 2)) - app.staticTexts["View State: idle"].tap() } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 34ea96d..da741f8 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 2FA9486D29DE91130081C086 /* ViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA9486C29DE91130081C086 /* ViewsTests.swift */; }; 2FA9486F29DE91A30081C086 /* SpeziViews in Frameworks */ = {isa = PBXBuildFile; productRef = 2FA9486E29DE91A30081C086 /* SpeziViews */; }; 2FB099B82A8AD25300B20952 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */; }; + 9731B58F2B167053007676C0 /* ViewStateMapperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9731B58E2B167053007676C0 /* ViewStateMapperView.swift */; }; 977CF55C2AD2B92C006D9B54 /* XCTestApp in Frameworks */ = {isa = PBXBuildFile; productRef = 977CF55B2AD2B92C006D9B54 /* XCTestApp */; }; A95B6E652AF4298500919504 /* SpeziPersonalInfo in Frameworks */ = {isa = PBXBuildFile; productRef = A95B6E642AF4298500919504 /* SpeziPersonalInfo */; }; A963ACAC2AF4683A00D745F2 /* SpeziValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A963ACAB2AF4683A00D745F2 /* SpeziValidationTests.swift */; }; @@ -61,6 +62,7 @@ 2FA9486C29DE91130081C086 /* ViewsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewsTests.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 9731B58E2B167053007676C0 /* ViewStateMapperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewStateMapperView.swift; sourceTree = ""; }; A963ACAB2AF4683A00D745F2 /* SpeziValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeziValidationTests.swift; sourceTree = ""; }; A963ACB12AF4709400D745F2 /* XCUIApplication+Targets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Targets.swift"; sourceTree = ""; }; A97880962A4C4E6500150B2F /* ModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelTests.swift; sourceTree = ""; }; @@ -156,6 +158,7 @@ children = ( 2F2D338629DE52EA00081B1D /* SpeziViewsTests.swift */, 2FA9485E29DE90720081C086 /* ViewStateTestView.swift */, + 9731B58E2B167053007676C0 /* ViewStateMapperView.swift */, 2FA9485F29DE90720081C086 /* MarkdownViewTestView.swift */, 2FA9486029DE90720081C086 /* CanvasTestView.swift */, 2FA9486329DE90720081C086 /* GeometryReaderTestView.swift */, @@ -327,6 +330,7 @@ A9FBAE952AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift in Sources */, 2FA9486929DE90720081C086 /* NameFieldsTestView.swift in Sources */, A99A65122AF57CA200E63582 /* FocusedValidationTests.swift in Sources */, + 9731B58F2B167053007676C0 /* ViewStateMapperView.swift in Sources */, A998A94F2A609A9E0030624D /* AsyncButtonTestView.swift in Sources */, 2FA9486A29DE90720081C086 /* GeometryReaderTestView.swift in Sources */, 2FA9486729DE90720081C086 /* CanvasTestView.swift in Sources */, From eee5cb7b3b21d655873394c633b66bbbee4b9911 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Tue, 28 Nov 2023 15:39:54 -0800 Subject: [PATCH 5/9] Better docs --- .../ViewModifier/ViewState/OperationState.swift | 9 +++++++-- .../ViewModifier/ViewState/ViewStateMapper.swift | 5 +++++ .../UITests/TestApp/ViewsTests/ViewStateMapperView.swift | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift index b749d87..e66707f 100644 --- a/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift +++ b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift @@ -23,14 +23,14 @@ /// /// ### Representation as a ViewState /// -/// The ``OperationState`` encapsulates the core state of an application's behavior, which directly impacts the user interface and interaction. +/// The ``OperationState`` encapsulates the core state of an application's behaviour, which directly impacts the user interface and interaction. /// To effectively manage the UI's state in the Spezi framework, the ``OperationState`` can be represented as a ``ViewState``. /// This bridging mechanism allows Spezi to monitor and respond to changes in the view's state, for example via the ``SwiftUI/View/viewStateAlert(state:)`` view modifier. /// /// - Note: It's important to note that this conversion is a lossy process, where a potentially intricate ``OperationState`` is /// distilled into a simpler ``ViewState``. /// One should highlight that this bridging is only done in one direction, so from the ``OperationState`` towards the ``ViewState``, -/// the reason being that the state of a SwiftUI `View` doesn't influence the state of some arbitrary operation. +/// the reason being that the state of a SwiftUI `View` shouldn't influence the state of some arbitrary operation. /// /// ```swift /// extension DownloadState { @@ -62,6 +62,11 @@ /// } /// } /// ``` +/// +/// > Tip: +/// > In the case that no SwiftUI `Binding` to the ``ViewState`` of the ``OperationState`` (so ``OperationState/representation``) +/// > is required (e.g., no use of the ``SwiftUI/View/viewStateAlert(state:)`` view modifier), one is able to omit the separately defined ``ViewState`` +/// > within a SwiftUI `View` and directly access the ``OperationState/representation`` property. public protocol OperationState { /// Defines the lossy abstraction logic from the possibly complex ``OperationState`` to the simple ``ViewState``. var representation: ViewState { get } diff --git a/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift index e3b9b35..b04792e 100644 --- a/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift +++ b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift @@ -53,6 +53,11 @@ extension View { /// /// - Note: The ``OperationState`` documentation contains a complete example using the ``SwiftUI/View/map(state:to:)`` view modifier. /// + /// > Tip: + /// > In the case that no SwiftUI `Binding` to the ``ViewState`` of the ``OperationState`` (so ``OperationState/representation``) + /// > is required (e.g., no use of the ``SwiftUI/View/viewStateAlert(state:)`` view modifier), one is able to omit the separately defined ``ViewState`` + /// > within a SwiftUI `View` and directly access the ``OperationState/representation`` property. + /// /// - Parameters: /// - operationState: The source ``OperationState`` that should be mapped to the SpeziViews ``ViewState``. /// - viewState: A `Binding` to the to-be-written-to ``ViewState``. diff --git a/Tests/UITests/TestApp/ViewsTests/ViewStateMapperView.swift b/Tests/UITests/TestApp/ViewsTests/ViewStateMapperView.swift index 96dcf4a..22d67b3 100644 --- a/Tests/UITests/TestApp/ViewsTests/ViewStateMapperView.swift +++ b/Tests/UITests/TestApp/ViewsTests/ViewStateMapperView.swift @@ -36,7 +36,7 @@ struct ViewStateMapperTestView: View { var recoverySuggestion: String? } - var testError = TestError( + let testError = TestError( errorDescription: nil, failureReason: "Failure Reason", helpAnchor: "Help Anchor", From b2ae8b6502ea457d9d4b9821f551b3490fa4aa45 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Tue, 28 Nov 2023 15:41:44 -0800 Subject: [PATCH 6/9] Improve docs --- .../SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift index b04792e..4e5b345 100644 --- a/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift +++ b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift @@ -30,8 +30,8 @@ private struct ViewStateMapper: ViewModifier { extension View { - /// Continuously maps a state conforming to the ``OperationState`` protocol to a ``ViewState``. - /// Used to propagate the ``ViewState`` representation of the ``OperationState`` to a ``ViewState`` that lives within a SwiftUI `View`. + /// Continuously maps a state conforming to the ``OperationState`` protocol to a separately stored ``ViewState``. + /// Used to propagate the ``ViewState`` representation of the ``OperationState`` (so ``OperationState/representation``) to a ``ViewState`` that lives within a SwiftUI `View`. /// /// ### Usage /// ```swift From 23c6022816d6b0377c71268f79151394e26479c5 Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Tue, 28 Nov 2023 18:32:41 -0800 Subject: [PATCH 7/9] Add OperationStateAlert and tests --- .../ViewState/OperationStateAlert.swift | 37 ++++++++++ .../ViewsTests/OperationStateTestView.swift | 70 +++++++++++++++++++ .../TestApp/ViewsTests/SpeziViewsTests.swift | 14 +++- .../SpeziViews/ModelTests.swift | 20 ++++++ .../UITests/UITests.xcodeproj/project.pbxproj | 4 ++ 5 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 Sources/SpeziViews/ViewModifier/ViewState/OperationStateAlert.swift create mode 100644 Tests/UITests/TestApp/ViewsTests/OperationStateTestView.swift diff --git a/Sources/SpeziViews/ViewModifier/ViewState/OperationStateAlert.swift b/Sources/SpeziViews/ViewModifier/ViewState/OperationStateAlert.swift new file mode 100644 index 0000000..f8f64eb --- /dev/null +++ b/Sources/SpeziViews/ViewModifier/ViewState/OperationStateAlert.swift @@ -0,0 +1,37 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +private struct OperationStateAlert: ViewModifier { + private let operationState: T + @State private var viewState: ViewState + + init(operationState: T) { + self.operationState = operationState + self._viewState = State(wrappedValue: operationState.representation) + } + + + func body(content: Content) -> some View { + content + .map(state: operationState, to: $viewState) + .viewStateAlert(state: $viewState) + } +} + + +extension View { + /// Automatically displays an alert using the localized error descriptions based on an ``ViewState`` derived from a ``OperationState``. + /// - Parameter state: The ``OperationState`` from which the ``ViewState`` is derived. + public func viewStateAlert(state: T) -> some View { + self + .modifier(OperationStateAlert(operationState: state)) + } +} diff --git a/Tests/UITests/TestApp/ViewsTests/OperationStateTestView.swift b/Tests/UITests/TestApp/ViewsTests/OperationStateTestView.swift new file mode 100644 index 0000000..448c91f --- /dev/null +++ b/Tests/UITests/TestApp/ViewsTests/OperationStateTestView.swift @@ -0,0 +1,70 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +struct OperationStateTestView: View { + enum OperationStateTest: OperationState { + case ready + case someOperationStep + case error(LocalizedError) + + + var representation: ViewState { + switch self { + case .ready: + .idle + case .someOperationStep: + .processing + case .error(let localizedError): + .error(localizedError) + } + } + } + + struct TestError: LocalizedError { + var errorDescription: String? + var failureReason: String? + var helpAnchor: String? + var recoverySuggestion: String? + } + + let testError = TestError( + errorDescription: nil, + failureReason: "Failure Reason", + helpAnchor: "Help Anchor", + recoverySuggestion: "Recovery Suggestion" + ) + + @State var operationState: OperationStateTest = .ready + + var body: some View { + VStack { + Text("Operation State: \(String(describing: operationState))") + .accessibilityIdentifier("operationState") + } + .task { + operationState = .someOperationStep + try? await Task.sleep(for: .seconds(10)) + operationState = .error( + AnyLocalizedError( + error: testError, + defaultErrorDescription: "Error Description" + ) + ) + } + .viewStateAlert(state: operationState) + } +} + + +#Preview { + OperationStateTestView() +} diff --git a/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift b/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift index 1b5c2b6..53712fd 100644 --- a/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift +++ b/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift @@ -18,6 +18,7 @@ enum SpeziViewsTests: String, TestAppTests { case lazyText = "Lazy Text" case markdownView = "Markdown View" case viewState = "View State" + case operationState = "Operation State" case viewStateMapper = "View State Mapper" case defaultErrorOnly = "Default Error Only" case defaultErrorDescription = "Default Error Description" @@ -78,10 +79,10 @@ enum SpeziViewsTests: String, TestAppTests { private var viewState: some View { ViewStateTestView() } - + @ViewBuilder - private var defaultErrorOnly: some View { - ViewStateTestView(testError: .init(errorDescription: "Some error occurred!")) + private var operationState: some View { + OperationStateTestView() } @ViewBuilder @@ -89,6 +90,11 @@ enum SpeziViewsTests: String, TestAppTests { ViewStateMapperTestView() } + @ViewBuilder + private var defaultErrorOnly: some View { + ViewStateTestView(testError: .init(errorDescription: "Some error occurred!")) + } + @ViewBuilder private var defaultErrorDescription: some View { DefaultErrorDescriptionTestView() @@ -114,6 +120,8 @@ enum SpeziViewsTests: String, TestAppTests { markdownView case .viewState: viewState + case .operationState: + operationState case .viewStateMapper: viewStateMapper case .defaultErrorOnly: diff --git a/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift b/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift index 3f2b8a4..c9907ab 100644 --- a/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift @@ -40,6 +40,26 @@ final class ModelTests: XCTestCase { XCTAssert(app.staticTexts["View State: idle"].waitForExistence(timeout: 2)) } + func testOperationState() throws { + let app = XCUIApplication() + + XCTAssert(app.collectionViews.buttons["Operation State"].waitForExistence(timeout: 2)) + app.collectionViews.buttons["Operation State"].tap() + + XCTAssert(app.staticTexts["Operation State: someOperationStep"].waitForExistence(timeout: 2)) + + sleep(12) + + let alert = app.alerts.firstMatch.scrollViews.otherElements + XCTAssert(alert.staticTexts["Error Description"].exists) + XCTAssert(alert.staticTexts["Failure Reason\n\nHelp Anchor\n\nRecovery Suggestion"].exists) + alert.buttons["OK"].tap() + + sleep(2) + + XCTAssert(app.staticTexts["operationState"].label.contains("Operation State: error")) + } + func testViewStateMapper() throws { let app = XCUIApplication() diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index da741f8..bdabc0a 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 2FB099B82A8AD25300B20952 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */; }; 9731B58F2B167053007676C0 /* ViewStateMapperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9731B58E2B167053007676C0 /* ViewStateMapperView.swift */; }; 977CF55C2AD2B92C006D9B54 /* XCTestApp in Frameworks */ = {isa = PBXBuildFile; productRef = 977CF55B2AD2B92C006D9B54 /* XCTestApp */; }; + 97EE16AC2B16D5AB004D25A3 /* OperationStateTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97EE16AB2B16D5AB004D25A3 /* OperationStateTestView.swift */; }; A95B6E652AF4298500919504 /* SpeziPersonalInfo in Frameworks */ = {isa = PBXBuildFile; productRef = A95B6E642AF4298500919504 /* SpeziPersonalInfo */; }; A963ACAC2AF4683A00D745F2 /* SpeziValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A963ACAB2AF4683A00D745F2 /* SpeziValidationTests.swift */; }; A963ACB02AF4692500D745F2 /* SpeziValidation in Frameworks */ = {isa = PBXBuildFile; productRef = A963ACAF2AF4692500D745F2 /* SpeziValidation */; }; @@ -63,6 +64,7 @@ 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 9731B58E2B167053007676C0 /* ViewStateMapperView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewStateMapperView.swift; sourceTree = ""; }; + 97EE16AB2B16D5AB004D25A3 /* OperationStateTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationStateTestView.swift; sourceTree = ""; }; A963ACAB2AF4683A00D745F2 /* SpeziValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeziValidationTests.swift; sourceTree = ""; }; A963ACB12AF4709400D745F2 /* XCUIApplication+Targets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Targets.swift"; sourceTree = ""; }; A97880962A4C4E6500150B2F /* ModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelTests.swift; sourceTree = ""; }; @@ -159,6 +161,7 @@ 2F2D338629DE52EA00081B1D /* SpeziViewsTests.swift */, 2FA9485E29DE90720081C086 /* ViewStateTestView.swift */, 9731B58E2B167053007676C0 /* ViewStateMapperView.swift */, + 97EE16AB2B16D5AB004D25A3 /* OperationStateTestView.swift */, 2FA9485F29DE90720081C086 /* MarkdownViewTestView.swift */, 2FA9486029DE90720081C086 /* CanvasTestView.swift */, 2FA9486329DE90720081C086 /* GeometryReaderTestView.swift */, @@ -330,6 +333,7 @@ A9FBAE952AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift in Sources */, 2FA9486929DE90720081C086 /* NameFieldsTestView.swift in Sources */, A99A65122AF57CA200E63582 /* FocusedValidationTests.swift in Sources */, + 97EE16AC2B16D5AB004D25A3 /* OperationStateTestView.swift in Sources */, 9731B58F2B167053007676C0 /* ViewStateMapperView.swift in Sources */, A998A94F2A609A9E0030624D /* AsyncButtonTestView.swift in Sources */, 2FA9486A29DE90720081C086 /* GeometryReaderTestView.swift in Sources */, From 8698dce7a8531502f78fa687186d04a6eb8b567f Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Tue, 28 Nov 2023 18:34:32 -0800 Subject: [PATCH 8/9] Add missing docc --- Sources/SpeziViews/SpeziViews.docc/SpeziViews.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md index 2f5c5be..894e846 100644 --- a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md +++ b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md @@ -19,7 +19,8 @@ SPDX-License-Identifier: MIT - ``ViewState`` - ``OperationState`` -- ``SwiftUI/View/viewStateAlert(state:)`` +- ``SwiftUI/View/viewStateAlert(state:)-4wzs4`` +- ``SwiftUI/View/viewStateAlert(state:)-27a86`` - ``SwiftUI/View/map(state:to:)`` - ``SwiftUI/View/processingOverlay(isProcessing:overlay:)-5xplv`` - ``SwiftUI/View/processingOverlay(isProcessing:overlay:)-3df8d`` From b3e7f2c05bd39015b45bff05d18d84e1c813075c Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Tue, 28 Nov 2023 18:35:38 -0800 Subject: [PATCH 9/9] Fix swiftlint --- Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift b/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift index 53712fd..e580811 100644 --- a/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift +++ b/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift @@ -106,7 +106,7 @@ enum SpeziViewsTests: String, TestAppTests { } - func view(withNavigationPath path: Binding) -> some View { + func view(withNavigationPath path: Binding) -> some View { // swiftlint:disable:this cyclomatic_complexity switch self { case .canvas: canvas