feat: Adds airflow at pressure feature.
This commit is contained in:
@@ -5,23 +5,36 @@ import PackageDescription
|
|||||||
let package = Package(
|
let package = Package(
|
||||||
name: "swift-estimated-pressures-core",
|
name: "swift-estimated-pressures-core",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v13),
|
.iOS(.v16),
|
||||||
.macOS(.v10_15),
|
.macOS(.v11),
|
||||||
.tvOS(.v13),
|
.tvOS(.v14),
|
||||||
.watchOS(.v6),
|
.watchOS(.v7),
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
|
.library(name: "AirflowAtPressureFeature", targets: ["AirflowAtPressureFeature"]),
|
||||||
.library(name: "EstimatedPressureDependency", targets: ["EstimatedPressureDependency"]),
|
.library(name: "EstimatedPressureDependency", targets: ["EstimatedPressureDependency"]),
|
||||||
.library(name: "SharedModels", targets: ["SharedModels"]),
|
.library(name: "SharedModels", targets: ["SharedModels"]),
|
||||||
|
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(
|
.package(
|
||||||
url: "https://github.com/pointfreeco/swift-dependencies.git",
|
url: "https://github.com/pointfreeco/swift-dependencies.git",
|
||||||
from: "1.3.0"
|
from: "1.3.0"
|
||||||
),
|
),
|
||||||
|
.package(
|
||||||
|
url:"https://github.com/pointfreeco/swift-composable-architecture.git",
|
||||||
|
from: "1.10.0"
|
||||||
|
),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "AirflowAtPressureFeature",
|
||||||
|
dependencies: [
|
||||||
|
"EstimatedPressureDependency",
|
||||||
|
"SharedModels",
|
||||||
|
"Styleguide",
|
||||||
|
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
|
||||||
|
]
|
||||||
|
),
|
||||||
.target(
|
.target(
|
||||||
name: "EstimatedPressureDependency",
|
name: "EstimatedPressureDependency",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
@@ -31,6 +44,7 @@ let package = Package(
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(name: "SharedModels"),
|
.target(name: "SharedModels"),
|
||||||
|
.target(name: "Styleguide"),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "EstimatedPressureTests",
|
name: "EstimatedPressureTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
|||||||
189
Sources/AirflowAtPressureFeature/AirflowAtPressure.swift
Normal file
189
Sources/AirflowAtPressureFeature/AirflowAtPressure.swift
Normal file
@@ -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<Double>?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
existingAirflow: Double? = nil,
|
||||||
|
existingPressure: Double? = nil,
|
||||||
|
targetPressure: Double? = 0.5,
|
||||||
|
calculatedAirflow: Positive<Double>? = 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<State>)
|
||||||
|
case receive(ReceiveAction)
|
||||||
|
case view(View)
|
||||||
|
|
||||||
|
@CasePathable
|
||||||
|
public enum ReceiveAction {
|
||||||
|
case calculatedAirflow(Positive<Double>?)
|
||||||
|
}
|
||||||
|
|
||||||
|
@CasePathable
|
||||||
|
public enum View {
|
||||||
|
case dismissInfoViewButtonTapped
|
||||||
|
case infoButtonTapped
|
||||||
|
case resetButtonTapped
|
||||||
|
case submit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Dependency(\.estimatedPressuresClient) var estimatedPressuresClient
|
||||||
|
|
||||||
|
public var body: some Reducer<State, Action> {
|
||||||
|
BindingReducer()
|
||||||
|
Reduce<State, Action> { 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<AirflowAtPressureFeature>
|
||||||
|
|
||||||
|
public init(store: StoreOf<AirflowAtPressureFeature>) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
44
Sources/AirflowAtPressureFeature/InfoView.swift
Normal file
44
Sources/AirflowAtPressureFeature/InfoView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,65 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum FlaggedValue<Value> {
|
|
||||||
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
|
@dynamicMemberLookup
|
||||||
public struct Flagged: Equatable {
|
public struct Flagged: Equatable {
|
||||||
|
|
||||||
@@ -88,32 +28,8 @@ public struct Flagged: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var projectedValue: FlaggedValue<Double> {
|
public var projectedValue: CheckResult {
|
||||||
let checkedResult = checkValue(wrappedValue)
|
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 struct GoodMessageHandler {
|
public struct GoodMessageHandler {
|
||||||
@@ -154,7 +70,7 @@ public struct Flagged: Equatable {
|
|||||||
case good(String? = nil)
|
case good(String? = nil)
|
||||||
case error(String)
|
case error(String)
|
||||||
|
|
||||||
var key: FlaggedValue<Double>.Key {
|
public var key: Key {
|
||||||
switch self {
|
switch self {
|
||||||
case .aboveMaximum(_):
|
case .aboveMaximum(_):
|
||||||
return .error
|
return .error
|
||||||
@@ -170,13 +86,45 @@ public struct Flagged: Equatable {
|
|||||||
return .error
|
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 {
|
public static func == (lhs: Flagged, rhs: Flagged) -> Bool {
|
||||||
lhs.wrappedValue == rhs.wrappedValue
|
lhs.wrappedValue == rhs.wrappedValue
|
||||||
}
|
}
|
||||||
|
|
||||||
public subscript<T>(dynamicMember keyPath: KeyPath<FlaggedValue<Double>, T>) -> T {
|
public subscript<T>(dynamicMember keyPath: KeyPath<CheckResult, T>) -> T {
|
||||||
self.projectedValue[keyPath: keyPath]
|
self.projectedValue[keyPath: keyPath]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
Sources/Styleguide/Buttons.swift
Normal file
15
Sources/Styleguide/Buttons.swift
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Sources/Styleguide/KeyboardStyles.swift
Normal file
20
Sources/Styleguide/KeyboardStyles.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user