From 187a682a6318386eac0b9712930ad9d9b17cd09e Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Tue, 28 May 2024 10:09:59 -0400 Subject: [PATCH] feat: Adds airflow at pressure feature. --- Package.swift | 24 ++- .../AirflowAtPressure.swift | 189 ++++++++++++++++++ .../AirflowAtPressureFeature/InfoView.swift | 44 ++++ Sources/SharedModels/Flagged.swift | 124 ++++-------- Sources/Styleguide/Buttons.swift | 15 ++ Sources/Styleguide/KeyboardStyles.swift | 20 ++ 6 files changed, 323 insertions(+), 93 deletions(-) create mode 100644 Sources/AirflowAtPressureFeature/AirflowAtPressure.swift create mode 100644 Sources/AirflowAtPressureFeature/InfoView.swift create mode 100644 Sources/Styleguide/Buttons.swift create mode 100644 Sources/Styleguide/KeyboardStyles.swift diff --git a/Package.swift b/Package.swift index 653ce0f..b10082d 100644 --- a/Package.swift +++ b/Package.swift @@ -5,23 +5,36 @@ import PackageDescription let package = Package( name: "swift-estimated-pressures-core", platforms: [ - .iOS(.v13), - .macOS(.v10_15), - .tvOS(.v13), - .watchOS(.v6), + .iOS(.v16), + .macOS(.v11), + .tvOS(.v14), + .watchOS(.v7), ], products: [ + .library(name: "AirflowAtPressureFeature", targets: ["AirflowAtPressureFeature"]), .library(name: "EstimatedPressureDependency", targets: ["EstimatedPressureDependency"]), .library(name: "SharedModels", targets: ["SharedModels"]), - ], dependencies: [ .package( url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.3.0" ), + .package( + url:"https://github.com/pointfreeco/swift-composable-architecture.git", + from: "1.10.0" + ), ], targets: [ + .target( + name: "AirflowAtPressureFeature", + dependencies: [ + "EstimatedPressureDependency", + "SharedModels", + "Styleguide", + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + ] + ), .target( name: "EstimatedPressureDependency", dependencies: [ @@ -31,6 +44,7 @@ let package = Package( ] ), .target(name: "SharedModels"), + .target(name: "Styleguide"), .testTarget( name: "EstimatedPressureTests", dependencies: [ diff --git a/Sources/AirflowAtPressureFeature/AirflowAtPressure.swift b/Sources/AirflowAtPressureFeature/AirflowAtPressure.swift new file mode 100644 index 0000000..efc7838 --- /dev/null +++ b/Sources/AirflowAtPressureFeature/AirflowAtPressure.swift @@ -0,0 +1,189 @@ +import ComposableArchitecture +import EstimatedPressureDependency +import SharedModels +import Styleguide +import SwiftUI + +@Reducer +public struct AirflowAtPressureFeature { + + @ObservableState + public struct State: Equatable { + public var isPresentingInfoView: Bool = false + public var existingAirflow: Double? + public var existingPressure: Double? + public var targetPressure: Double? + public var calculatedAirflow: Positive? + + public init( + existingAirflow: Double? = nil, + existingPressure: Double? = nil, + targetPressure: Double? = 0.5, + calculatedAirflow: Positive? = nil + ) { + self.existingAirflow = existingAirflow + self.existingPressure = existingPressure + self.targetPressure = targetPressure + self.calculatedAirflow = calculatedAirflow + } + + public var isValid: Bool { + existingAirflow != nil + && existingPressure != nil + && targetPressure != nil + } + } + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case receive(ReceiveAction) + case view(View) + + @CasePathable + public enum ReceiveAction { + case calculatedAirflow(Positive?) + } + + @CasePathable + public enum View { + case dismissInfoViewButtonTapped + case infoButtonTapped + case resetButtonTapped + case submit + } + } + + + @Dependency(\.estimatedPressuresClient) var estimatedPressuresClient + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .receive(action): + switch action { + case let .calculatedAirflow(airflow): + state.calculatedAirflow = airflow + return .none + } + + case let .view(action): + switch action { + case .dismissInfoViewButtonTapped: + state.isPresentingInfoView = false + return .none + + case .infoButtonTapped: + state.isPresentingInfoView = true + return .none + + case .resetButtonTapped: + state = .init() + return .none + + case .submit: + guard state.isValid else { return .none } + return .run { [state] send in + await send( + .receive(.calculatedAirflow( + try? await estimatedPressuresClient.estimatedAirflow(.init( + existingAirflow: state.existingAirflow ?? 0, + existingPressure: state.existingPressure ?? 0, + targetPressure: state.targetPressure ?? 0 + )) + )) + ) + } + + } + } + } + } +} + +@ViewAction(for: AirflowAtPressureFeature.self) +public struct AirflowAtPressureView: View { + + @Perception.Bindable + public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + Form { + Section { + TextField( + "Existing Airflow", + value: $store.existingAirflow, + format: .number, + prompt: Text("Airflow") + ) + .onSubmit { send(.submit) } + .numberPad() + + TextField( + "Existing Pressure", + value: $store.existingPressure, + format: .number, + prompt: Text("Existing Pressure") + ) + .onSubmit { send(.submit) } + .decimalPad() + + TextField( + "Target Pressure", + value: $store.targetPressure, + format: .number, + prompt: Text("Target Pressure") + ) + .onSubmit { send(.submit) } + .decimalPad() + + } header: { + HStack { + Text("Inputs") + Spacer() + InfoButton { send(.infoButtonTapped) } + } + .labelStyle(.iconOnly) + } footer: { + HStack { + Spacer() + Button("Reset") { send(.resetButtonTapped) } + Spacer() + } + .padding(.top) + .buttonStyle(.borderedProminent) + } + + Section { + if let airflow = store.calculatedAirflow { + Text(airflow.positiveValue, format: .number.precision(.fractionLength(0))) + } + } header: { + Text("Calculated Airflow") + } + } + .sheet(isPresented: $store.isPresentingInfoView) { + NavigationStack { + AirflowAtPressureInfoView() + .toolbar { + Button("Done") { send(.dismissInfoViewButtonTapped) } + } + } + } + } +} + +#Preview { + AirflowAtPressureView( + store: Store(initialState: AirflowAtPressureFeature.State()) { + AirflowAtPressureFeature() + } + ) +} diff --git a/Sources/AirflowAtPressureFeature/InfoView.swift b/Sources/AirflowAtPressureFeature/InfoView.swift new file mode 100644 index 0000000..299da45 --- /dev/null +++ b/Sources/AirflowAtPressureFeature/InfoView.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct AirflowAtPressureInfoView: View { + + static let heading = """ + Calculate the airflow at the target pressure from the existing airflow and existing pressure. + """ + + static let body = """ + This can be useful to determine the effect of a different blower speed on a specific measurement location (generally the supply or return plenum). + """ + + var body: some View { + VStack(spacing: 40) { + + ZStack { + Text(Self.heading) + .font(.headline) + .bold() + .foregroundStyle(Color.white) + .padding() + } + .background { + RoundedRectangle(cornerRadius: 10) + .foregroundStyle(Color.orange.opacity(0.6)) + } + + + ZStack { + Text(Self.body) + .font(.callout) + .padding() + } + .background { + RoundedRectangle(cornerRadius: 10) + .foregroundStyle(Color.secondary.opacity(0.3)) + } + .padding(.horizontal, 10) + + Spacer() + } + .padding(.horizontal) + } +} diff --git a/Sources/SharedModels/Flagged.swift b/Sources/SharedModels/Flagged.swift index cc1eabb..fab4ca2 100644 --- a/Sources/SharedModels/Flagged.swift +++ b/Sources/SharedModels/Flagged.swift @@ -1,65 +1,5 @@ import Foundation -public enum FlaggedValue { - case good(Value, String?) - case warning(Value, String?) - case error(Value, String?) - - public init(_ value: Value, key: Key, message: String? = nil) { - switch key { - case .good: - self = .good(value, message) - case .warning: - self = .warning(value, message) - case .error: - self = .error(value, message) - } - } - - public var key: Key { - switch self { - case .good: - return .good - case .warning: - return .warning - case .error: - return .error - } - } - - public var value: Value { - switch self { - case let .good(value, _): - return value - case let .warning(value, _): - return value - case let .error(value, _): - return value - } - } - - public var message: String? { - switch self { - case let .good(_, message): - return message - case let .warning(_, message): - return message - case let .error(_, message): - return message - } - } - - public enum Key: String, Equatable, CaseIterable { - case good, warning, error - - public var title: String { - self.rawValue.capitalized - } - } -} - -extension FlaggedValue: Equatable where Value: Equatable { } - @dynamicMemberLookup public struct Flagged: Equatable { @@ -88,32 +28,8 @@ public struct Flagged: Equatable { } } - public var projectedValue: FlaggedValue { - let checkedResult = checkValue(wrappedValue) - let key = checkedResult.key - let message: String? - - switch checkedResult { - - case let .aboveMaximum(max): - message = "Above maximum: \(doubleString(max))" - - case let .belowMinimum(min): - message = "Below minimum: \(doubleString(min))" - - case .betweenRange(minimum: let minimum, maximum: let maximum): - message = "Between: \(minimum) and \(maximum)" - - case .betweenRatedAndMaximum(rated: let rated, maximum: let maximum): - message = "Between rated: \(doubleString(rated)) and maximum: \(doubleString(maximum))" - case let .good(goodMessage): - message = goodMessage - - case let .error(errorMessage): - message = errorMessage - } - - return .init(wrappedValue, key: key, message: message) + public var projectedValue: CheckResult { + checkValue(wrappedValue) } public struct GoodMessageHandler { @@ -154,7 +70,7 @@ public struct Flagged: Equatable { case good(String? = nil) case error(String) - var key: FlaggedValue.Key { + public var key: Key { switch self { case .aboveMaximum(_): return .error @@ -170,13 +86,45 @@ public struct Flagged: Equatable { return .error } } + + public var message: String? { + switch self { + case let .aboveMaximum(max): + return "Above maximum: \(doubleString(max))" + + case let .belowMinimum(min): + return "Below minimum: \(doubleString(min))" + + case .betweenRange(minimum: let minimum, maximum: let maximum): + return "Between: \(minimum) and \(maximum)" + + case .betweenRatedAndMaximum(rated: let rated, maximum: let maximum): + return "Between rated: \(doubleString(rated)) and maximum: \(doubleString(maximum))" + + case let .good(goodMessage): + return goodMessage + + case let .error(errorMessage): + return errorMessage + + } + } + + public enum Key: String, Equatable, CaseIterable { + case good, warning, error + + public var title: String { + self.rawValue.capitalized + } + } + } public static func == (lhs: Flagged, rhs: Flagged) -> Bool { lhs.wrappedValue == rhs.wrappedValue } - public subscript(dynamicMember keyPath: KeyPath, T>) -> T { + public subscript(dynamicMember keyPath: KeyPath) -> T { self.projectedValue[keyPath: keyPath] } } diff --git a/Sources/Styleguide/Buttons.swift b/Sources/Styleguide/Buttons.swift new file mode 100644 index 0000000..bab6790 --- /dev/null +++ b/Sources/Styleguide/Buttons.swift @@ -0,0 +1,15 @@ +import SwiftUI + +public struct InfoButton: View { + let action: () -> Void + + public init(action: @escaping () -> Void) { + self.action = action + } + + public var body: some View { + Button(action: action) { + Label("Info", systemImage: "info.circle") + } + } +} diff --git a/Sources/Styleguide/KeyboardStyles.swift b/Sources/Styleguide/KeyboardStyles.swift new file mode 100644 index 0000000..5fa4a72 --- /dev/null +++ b/Sources/Styleguide/KeyboardStyles.swift @@ -0,0 +1,20 @@ +import SwiftUI + +extension View { + + public func numberPad() -> some View { + #if os(iOS) + self.keyboardType(.numberPad) + #else + self + #endif + } + + public func decimalPad() -> some View { + #if os(iOS) + self.keyboardType(.decimalPad) + #else + self + #endif + } +}