Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Map Operation States to View States #24

Merged
merged 9 commits into from
Nov 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Sources/SpeziViews/SpeziViews.docc/SpeziViews.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:)``
philippzagar marked this conversation as resolved.
Show resolved Hide resolved
- ``SwiftUI/View/processingOverlay(isProcessing:overlay:)-5xplv``
- ``SwiftUI/View/processingOverlay(isProcessing:overlay:)-3df8d``
- ``SwiftUI/EnvironmentValues/defaultErrorDescription``
Expand Down
73 changes: 73 additions & 0 deletions Sources/SpeziViews/ViewModifier/ViewState/OperationState.swift
philippzagar marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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 }
}
Original file line number Diff line number Diff line change
@@ -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<T: OperationState>: 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<T: OperationState>(state: T) -> some View {
philippzagar marked this conversation as resolved.
Show resolved Hide resolved
self
.modifier(OperationStateAlert(operationState: state))
}
}
68 changes: 68 additions & 0 deletions Sources/SpeziViews/ViewModifier/ViewState/ViewStateMapper.swift
Original file line number Diff line number Diff line change
@@ -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<T: OperationState>: ViewModifier {
private let operationState: T
@Binding private var viewState: ViewState


init(operationState: T, viewState: Binding<ViewState>) {
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<T: OperationState>(state operationState: T, to viewState: Binding<ViewState>) -> some View {
self
.modifier(ViewStateMapper(operationState: operationState, viewState: viewState))
}
}
3 changes: 3 additions & 0 deletions Tests/UITests/TestApp/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
}
}
}
},
"Operation State: %@" : {

},
"Reset" : {

Expand Down
70 changes: 70 additions & 0 deletions Tests/UITests/TestApp/ViewsTests/OperationStateTestView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
18 changes: 17 additions & 1 deletion Tests/UITests/TestApp/ViewsTests/SpeziViewsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -94,7 +106,7 @@ enum SpeziViewsTests: String, TestAppTests {
}


func view(withNavigationPath path: Binding<NavigationPath>) -> some View {
func view(withNavigationPath path: Binding<NavigationPath>) -> some View { // swiftlint:disable:this cyclomatic_complexity
switch self {
case .canvas:
canvas
Expand All @@ -108,6 +120,10 @@ enum SpeziViewsTests: String, TestAppTests {
markdownView
case .viewState:
viewState
case .operationState:
operationState
case .viewStateMapper:
viewStateMapper
case .defaultErrorOnly:
defaultErrorOnly
case .defaultErrorDescription:
Expand Down
76 changes: 76 additions & 0 deletions Tests/UITests/TestApp/ViewsTests/ViewStateMapperView.swift
Original file line number Diff line number Diff line change
@@ -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()
}
Loading