402 lines
10 KiB
Swift
402 lines
10 KiB
Swift
import ComposableArchitecture
|
|
import DependenciesAdditions
|
|
import EstimatedPressureDependency
|
|
import SharedModels
|
|
import Styleguide
|
|
import SwiftUI
|
|
import TCAExtras
|
|
|
|
@Reducer
|
|
public struct CalculateAtFeature {
|
|
|
|
@ObservableState
|
|
@dynamicMemberLookup
|
|
public struct State: Equatable {
|
|
public var focus: Focus? = nil
|
|
public var isPresentingInfoView: Bool = false
|
|
public var values: ValueContainer
|
|
public var calculationType: CalculationType
|
|
|
|
public init(
|
|
values: ValueContainer = .init(),
|
|
calculationType: CalculationType = .airflowAtNewPressure
|
|
) {
|
|
self.values = values
|
|
self.calculationType = calculationType
|
|
}
|
|
|
|
public var isValid: Bool {
|
|
values.existingAirflow != nil
|
|
&& values.existingPressure != nil
|
|
&& values.targetValue != nil
|
|
}
|
|
|
|
public subscript<T>(dynamicMember keyPath: KeyPath<CalculationType, T>) -> T {
|
|
calculationType[keyPath: keyPath]
|
|
}
|
|
|
|
public subscript<T>(dynamicMember keyPath: WritableKeyPath<ValueContainer, T>) -> T {
|
|
get { values[keyPath: keyPath] }
|
|
set { values[keyPath: keyPath] = newValue }
|
|
}
|
|
|
|
public enum Focus: Hashable {
|
|
case existingAirflow
|
|
case existingPressure
|
|
case targetValue
|
|
}
|
|
|
|
public struct ValueContainer: Equatable {
|
|
public var existingAirflow: Double?
|
|
public var existingPressure: Double?
|
|
public var targetValue: Double?
|
|
public var calculatedValue: Double?
|
|
|
|
public init(
|
|
existingAirflow: Double? = nil,
|
|
existingPressure: Double? = nil,
|
|
targetValue: Double? = nil,
|
|
calculatedValue: Double? = nil
|
|
) {
|
|
self.existingAirflow = existingAirflow
|
|
self.existingPressure = existingPressure
|
|
self.targetValue = targetValue
|
|
self.calculatedValue = calculatedValue
|
|
}
|
|
}
|
|
|
|
public enum CalculationType: Hashable, CaseIterable, Identifiable {
|
|
case airflowAtNewPressure
|
|
case pressureAtNewAirflow
|
|
|
|
public var id: Self { self }
|
|
|
|
private var precision: Int {
|
|
switch self {
|
|
case .airflowAtNewPressure:
|
|
return 0
|
|
case .pressureAtNewAirflow:
|
|
return 2
|
|
}
|
|
}
|
|
|
|
var format: FloatingPointFormatStyle<Double> {
|
|
.number.precision(.fractionLength(precision))
|
|
}
|
|
|
|
var targetLabel: String {
|
|
switch self {
|
|
case .airflowAtNewPressure:
|
|
return "Pressure"
|
|
case .pressureAtNewAirflow:
|
|
return "Airflow"
|
|
}
|
|
}
|
|
|
|
var calculationLabel: String {
|
|
switch self {
|
|
case .airflowAtNewPressure:
|
|
return "Airflow"
|
|
case .pressureAtNewAirflow:
|
|
return "Pressure"
|
|
}
|
|
}
|
|
|
|
var calculationLabelSpacing: CGFloat {
|
|
switch self {
|
|
case .airflowAtNewPressure:
|
|
return 5
|
|
case .pressureAtNewAirflow:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
var calculationPostfix: String {
|
|
switch self {
|
|
case .airflowAtNewPressure:
|
|
return "CFM"
|
|
case .pressureAtNewAirflow:
|
|
return "\" w.c."
|
|
}
|
|
}
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .airflowAtNewPressure:
|
|
return "Airflow at Pressure"
|
|
case .pressureAtNewAirflow:
|
|
return "Pressure at Airflow"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum Action: BindableAction, ReceiveAction, ViewAction {
|
|
case binding(BindingAction<State>)
|
|
case receive(TaskResult<ReceiveAction>)
|
|
case view(View)
|
|
|
|
@CasePathable
|
|
public enum ReceiveAction {
|
|
case calculatedValue(Positive<Double>?)
|
|
}
|
|
|
|
@CasePathable
|
|
public enum View {
|
|
case dismissInfoViewButtonTapped
|
|
case infoButtonTapped
|
|
case onAppear
|
|
case resetButtonTapped
|
|
case submit
|
|
}
|
|
}
|
|
|
|
@Dependency(\.estimatedPressuresClient) var estimatedPressuresClient
|
|
@Dependency(\.logger["\(Self.self)"]) var logger
|
|
|
|
public var body: some Reducer<State, Action> {
|
|
BindingReducer()
|
|
Reduce<State, Action> { state, action in
|
|
switch action {
|
|
case .binding(\.calculationType):
|
|
state.targetValue = nil
|
|
state.calculatedValue = nil
|
|
return .none
|
|
|
|
case .binding:
|
|
return .none
|
|
|
|
case let .receive(.success(.calculatedValue(calculatedAirflow))):
|
|
state.calculatedValue = calculatedAirflow?.positiveValue
|
|
return .none
|
|
|
|
case .receive:
|
|
return .none
|
|
|
|
case let .view(action):
|
|
switch action {
|
|
case .dismissInfoViewButtonTapped:
|
|
state.isPresentingInfoView = false
|
|
return .none
|
|
|
|
case .infoButtonTapped:
|
|
state.isPresentingInfoView = true
|
|
return .none
|
|
|
|
case .onAppear:
|
|
state.focus = .existingAirflow
|
|
return .none
|
|
|
|
case .resetButtonTapped:
|
|
state.values = .init()
|
|
return .none
|
|
|
|
case .submit:
|
|
guard state.isValid else {
|
|
return .none
|
|
}
|
|
return calculate(state: state)
|
|
}
|
|
}
|
|
}
|
|
.onFailure(.log(logger: logger))
|
|
}
|
|
|
|
func calculate(state: State) -> Effect<Action> {
|
|
return .receive(\.calculatedValue) { [state] in
|
|
switch state.calculationType {
|
|
case .airflowAtNewPressure:
|
|
return try await estimatedPressuresClient.estimatedAirflow(
|
|
.init(
|
|
existingAirflow: state.existingAirflow ?? 0,
|
|
existingPressure: state.existingPressure ?? 0,
|
|
targetPressure: state.targetValue ?? 0
|
|
)
|
|
)
|
|
case .pressureAtNewAirflow:
|
|
return try await estimatedPressuresClient.estimatedPressure(
|
|
existingPressure: state.existingPressure ?? 0,
|
|
existingAirflow: state.existingAirflow ?? 0,
|
|
targetAirflow: state.targetValue ?? 0
|
|
)
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
@ViewAction(for: CalculateAtFeature.self)
|
|
public struct CalculateAtView: View {
|
|
|
|
@FocusState private var focus: CalculateAtFeature.State.Focus?
|
|
|
|
@Perception.Bindable
|
|
public var store: StoreOf<CalculateAtFeature>
|
|
|
|
let allowChangingCalculationType: Bool
|
|
|
|
public init(
|
|
allowChangingCalculationType: Bool = true,
|
|
store: StoreOf<CalculateAtFeature>
|
|
) {
|
|
self.allowChangingCalculationType = allowChangingCalculationType
|
|
self.store = store
|
|
self.focus = store.focus
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack {
|
|
if allowChangingCalculationType {
|
|
Picker("Calculation Type", selection: $store.calculationType) {
|
|
ForEach(CalculateAtFeature.State.CalculationType.allCases) {
|
|
Text($0.label)
|
|
.tag($0.id)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
.padding(.bottom)
|
|
}
|
|
|
|
Form {
|
|
Section {
|
|
TextField(
|
|
"Existing Airflow",
|
|
value: $store.existingAirflow,
|
|
format: .number,
|
|
prompt: Text("Existing Airflow")
|
|
)
|
|
.focused($focus, equals: .existingAirflow)
|
|
.onSubmit { send(.submit) }
|
|
.numberPad()
|
|
|
|
TextField(
|
|
"Existing Pressure",
|
|
value: $store.existingPressure,
|
|
format: .number,
|
|
prompt: Text("Existing Pressure")
|
|
)
|
|
.focused($focus, equals: .existingPressure)
|
|
.onSubmit { send(.submit) }
|
|
.decimalPad()
|
|
|
|
TextField(
|
|
"Target \(store.targetLabel)",
|
|
value: $store.targetValue,
|
|
format: .number,
|
|
prompt: Text("Target \(store.targetLabel)")
|
|
)
|
|
.focused($focus, equals: .existingPressure)
|
|
.onSubmit { send(.submit) }
|
|
.keyboard(for: store.calculationType)
|
|
|
|
if let airflow = store.calculatedValue {
|
|
HStack {
|
|
Text("Calculated \(store.calculationLabel)")
|
|
.foregroundStyle(Color.secondary)
|
|
.font(.callout.bold())
|
|
Spacer()
|
|
ZStack {
|
|
HStack(spacing: store.calculationLabelSpacing) {
|
|
Text(airflow, format: store.format)
|
|
Text(store.calculationPostfix)
|
|
}
|
|
.foregroundStyle(Color.white)
|
|
.font(.callout)
|
|
.bold()
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
}
|
|
.background {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.foregroundStyle(Color.green)
|
|
.opacity(0.6)
|
|
}
|
|
}
|
|
.padding(.top)
|
|
}
|
|
|
|
} header: {
|
|
HStack {
|
|
Text("Inputs")
|
|
Spacer()
|
|
InfoButton { send(.infoButtonTapped) }
|
|
}
|
|
.labelStyle(.iconOnly)
|
|
} footer: {
|
|
HStack {
|
|
Spacer()
|
|
Button("Reset") { send(.resetButtonTapped) }
|
|
Spacer()
|
|
}
|
|
.padding(.top)
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.labelsHidden()
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.bind($focus, to: $store.focus)
|
|
.onAppear { send(.onAppear) }
|
|
.sheet(isPresented: $store.isPresentingInfoView) {
|
|
NavigationStack {
|
|
InfoView(calculationType: store.calculationType)
|
|
.toolbar {
|
|
Button("Done") { send(.dismissInfoViewButtonTapped) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate extension InfoView {
|
|
|
|
init(calculationType: CalculateAtFeature.State.CalculationType) {
|
|
switch calculationType {
|
|
case .airflowAtNewPressure:
|
|
self.init(
|
|
heading: """
|
|
Calculate the airflow at the target pressure from the existing airflow and existing pressure.
|
|
""",
|
|
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).
|
|
"""
|
|
)
|
|
case .pressureAtNewAirflow:
|
|
self.init(
|
|
heading: """
|
|
Calculate the pressure at the target airflow from the existing airflow and existing pressure.
|
|
""",
|
|
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).
|
|
"""
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate extension View {
|
|
@ViewBuilder
|
|
func keyboard(for calculationType: CalculateAtFeature.State.CalculationType) -> some View {
|
|
switch calculationType {
|
|
case .airflowAtNewPressure:
|
|
self.decimalPad()
|
|
case .pressureAtNewAirflow:
|
|
self.numberPad()
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
CalculateAtView(
|
|
store: Store(initialState: CalculateAtFeature.State()) {
|
|
CalculateAtFeature()._printChanges()
|
|
}
|
|
)
|
|
#if os(macOS)
|
|
.frame(width: 400, height: 400)
|
|
.padding()
|
|
#endif
|
|
}
|