diff --git a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md index 371f059..894e846 100644 --- a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md +++ b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md @@ -18,7 +18,10 @@ SPDX-License-Identifier: MIT ### Managing State - ``ViewState`` -- ``SwiftUI/View/viewStateAlert(state:)`` +- ``OperationState`` +- ``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`` - ``SwiftUI/EnvironmentValues/defaultErrorDescription`` diff --git a/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift new file mode 100644 index 0000000..e66707f --- /dev/null +++ b/Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift @@ -0,0 +1,73 @@ +// +// 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 +// + + +/// 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. +/// +/// 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: OperationState { +/// case ready +/// case downloading(progress: Double) +/// case error(LocalizedError) +/// ... +/// } +/// ``` +/// +/// ### Representation as a ViewState +/// +/// 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` shouldn't influence the state of some arbitrary operation. +/// +/// ```swift +/// extension DownloadState { +/// public var representation: ViewState { +/// switch self { +/// case .ready: +/// .idle +/// case .downloading: +/// .processing +/// case .error(let error): +/// .error(error) +/// // ... +/// } +/// } +/// } +/// +/// struct OperationStateTestView: View { +/// @State private var downloadState: DownloadState = .ready +/// @State private var viewState: ViewState = .idle +/// +/// var body: some View { +/// 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` which are automatically mapped to the `ViewState` +/// // ... +/// } +/// } +/// } +/// ``` +/// +/// > 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/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/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..4e5b345 --- /dev/null +++ b/Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift @@ -0,0 +1,68 @@ +// +// 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.representation) { + viewState = operationState.representation + } + } +} + + +extension 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 + /// struct OperationStateTestView: View { + /// @State private var downloadState: DownloadState = .ready // `DownloadState` conforms to `OperationState` + /// @State private var viewState: ViewState = .idle + /// + /// var body: some View { + /// 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` which are automatically mapped to the `ViewState` + /// // ... + /// } + /// } + /// } + /// ``` + /// + /// - 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``. + public func map(state operationState: T, to viewState: Binding) -> some View { + self + .modifier(ViewStateMapper(operationState: operationState, viewState: 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/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 4cea617..e580811 100644 --- a/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift +++ b/Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift @@ -18,6 +18,8 @@ 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" case asyncButton = "Async Button" @@ -77,6 +79,16 @@ enum SpeziViewsTests: String, TestAppTests { private var viewState: some View { ViewStateTestView() } + + @ViewBuilder + private var operationState: some View { + OperationStateTestView() + } + + @ViewBuilder + private var viewStateMapper: some View { + ViewStateMapperTestView() + } @ViewBuilder private var defaultErrorOnly: some View { @@ -94,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 @@ -108,6 +120,10 @@ enum SpeziViewsTests: String, TestAppTests { markdownView case .viewState: viewState + case .operationState: + operationState + 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..22d67b3 --- /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? + } + + let 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..c9907ab 100644 --- a/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift +++ b/Tests/UITests/TestAppUITests/SpeziViews/ModelTests.swift @@ -38,7 +38,50 @@ final class ModelTests: XCTestCase { alert.buttons["OK"].tap() XCTAssert(app.staticTexts["View State: idle"].waitForExistence(timeout: 2)) - app.staticTexts["View State: idle"].tap() + } + + 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() + + 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 +100,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..bdabc0a 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -18,7 +18,9 @@ 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 */; }; + 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 */; }; @@ -61,6 +63,8 @@ 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 = ""; }; + 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 = ""; }; @@ -156,6 +160,8 @@ children = ( 2F2D338629DE52EA00081B1D /* SpeziViewsTests.swift */, 2FA9485E29DE90720081C086 /* ViewStateTestView.swift */, + 9731B58E2B167053007676C0 /* ViewStateMapperView.swift */, + 97EE16AB2B16D5AB004D25A3 /* OperationStateTestView.swift */, 2FA9485F29DE90720081C086 /* MarkdownViewTestView.swift */, 2FA9486029DE90720081C086 /* CanvasTestView.swift */, 2FA9486329DE90720081C086 /* GeometryReaderTestView.swift */, @@ -327,6 +333,8 @@ 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 */, 2FA9486729DE90720081C086 /* CanvasTestView.swift in Sources */,