433 lines
12 KiB
Swift
433 lines
12 KiB
Swift
import ComposableArchitecture
|
|
import DependenciesAdditions
|
|
import SharedModels
|
|
import Styleguide
|
|
import SwiftUI
|
|
|
|
@Reducer
|
|
public struct EquipmentMeasurementForm {
|
|
|
|
public init() { }
|
|
|
|
@Reducer(state: .equatable, .sendable, action: .sendable)
|
|
public enum Destination {
|
|
case flaggedMeasurementsList(FlaggedMeasurementsList)
|
|
case infoView(InfoViewFeature)
|
|
}
|
|
|
|
@ObservableState
|
|
public struct State: Equatable, Sendable {
|
|
@Presents public var destination: Destination.State?
|
|
@Shared public var sharedSettings: SharedPressureEstimationSettings
|
|
public var allowEquipmentTypeSelection: Bool
|
|
public var equipmentType: EquipmentMeasurement.EquipmentType
|
|
public var focusedField: Field?
|
|
public var measurements: Measurements
|
|
|
|
public init(
|
|
allowEquipmentTypeSelection: Bool = true,
|
|
destination: Destination.State? = nil,
|
|
sharedSettings: Shared<SharedPressureEstimationSettings>,
|
|
equipmentType: EquipmentMeasurement.EquipmentType = .airHandler,
|
|
focusedField: Field? = nil,
|
|
measurements: Measurements = .init()
|
|
) {
|
|
self.allowEquipmentTypeSelection = allowEquipmentTypeSelection
|
|
self.destination = destination
|
|
self._sharedSettings = sharedSettings
|
|
self.equipmentType = equipmentType
|
|
self.focusedField = focusedField
|
|
self.measurements = measurements
|
|
}
|
|
|
|
public var equipmentMeasurement: EquipmentMeasurement {
|
|
measurements.equipmentMeasurement(type: equipmentType)
|
|
}
|
|
|
|
private var baseValidations: Bool {
|
|
return measurements.airflow != nil
|
|
&& measurements.returnPlenumPressure != nil
|
|
&& measurements.supplyPlenumPressure != nil
|
|
}
|
|
|
|
private var furnaceAndCoilValidations: Bool {
|
|
return measurements.postFilterPressure != nil
|
|
&& measurements.coilPressure != nil
|
|
&& baseValidations
|
|
}
|
|
|
|
public var isValid: Bool {
|
|
switch equipmentType {
|
|
case .airHandler:
|
|
return baseValidations
|
|
case .furnaceAndCoil:
|
|
return furnaceAndCoilValidations
|
|
}
|
|
}
|
|
|
|
public struct Measurements: Equatable, Sendable {
|
|
public var airflow: Double?
|
|
public var returnPlenumPressure: Double?
|
|
public var postFilterPressure: Double?
|
|
public var coilPressure: Double?
|
|
public var supplyPlenumPressure: Double?
|
|
|
|
public init(
|
|
airflow: Double? = nil,
|
|
returnPlenumPressure: Double? = nil,
|
|
postFilterPressure: Double? = nil,
|
|
coilPressure: Double? = nil,
|
|
supplyPlenumPressure: Double? = nil
|
|
) {
|
|
self.airflow = airflow
|
|
self.returnPlenumPressure = returnPlenumPressure
|
|
self.postFilterPressure = postFilterPressure
|
|
self.coilPressure = coilPressure
|
|
self.supplyPlenumPressure = supplyPlenumPressure
|
|
}
|
|
|
|
public init(
|
|
equipmentMeasurement: EquipmentMeasurement
|
|
) {
|
|
switch equipmentMeasurement {
|
|
case let .airHandler(equipment):
|
|
self.init(
|
|
airflow: equipment.airflow,
|
|
returnPlenumPressure: equipment.returnPlenumPressure,
|
|
postFilterPressure: equipment.postFilterPressure,
|
|
coilPressure: equipment.postCoilPressure,
|
|
supplyPlenumPressure: equipment.supplyPlenumPressure
|
|
)
|
|
case let .furnaceAndCoil(equipment):
|
|
self.init(
|
|
airflow: equipment.airflow,
|
|
returnPlenumPressure: equipment.returnPlenumPressure,
|
|
postFilterPressure: equipment.postFilterPressure,
|
|
coilPressure: equipment.preCoilPressure,
|
|
supplyPlenumPressure: equipment.supplyPlenumPressure
|
|
)
|
|
}
|
|
}
|
|
|
|
public func equipmentMeasurement(
|
|
type: EquipmentMeasurement.EquipmentType
|
|
) -> EquipmentMeasurement {
|
|
switch type {
|
|
case .airHandler:
|
|
return .airHandler(.init(
|
|
airflow: airflow ?? 0,
|
|
manufacturersIncludedFilterPressureDrop: 0.1,
|
|
returnPlenumPressure: returnPlenumPressure ?? 0,
|
|
postFilterPressure: postFilterPressure ?? 0,
|
|
postCoilPressure: coilPressure ?? 0,
|
|
supplyPlenumPressure: supplyPlenumPressure ?? 0
|
|
))
|
|
|
|
case .furnaceAndCoil:
|
|
return .furnaceAndCoil(.init(
|
|
airflow: airflow ?? 0,
|
|
manufacturersIncludedFilterPressureDrop: 0,
|
|
returnPlenumPressure: returnPlenumPressure ?? 0,
|
|
postFilterPressure: postFilterPressure ?? 0,
|
|
preCoilPressure: coilPressure ?? 0,
|
|
supplyPlenumPressure: supplyPlenumPressure ?? 0
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum Field: CaseIterable, FocusableField, Hashable, Identifiable, Sendable {
|
|
case returnPlenumPressure
|
|
case postFilterPressure
|
|
case coilPressure
|
|
case supplyPlenumPressure
|
|
case airflow
|
|
|
|
public var id: Self { self }
|
|
|
|
}
|
|
}
|
|
|
|
public enum Action: BindableAction, ViewAction, Sendable {
|
|
case binding(BindingAction<State>)
|
|
case destination(PresentationAction<Destination.Action>)
|
|
case view(View)
|
|
|
|
@CasePathable
|
|
public enum View: Sendable {
|
|
case infoButtonTapped
|
|
case nextButtonTapped
|
|
case onAppear
|
|
case resetButtonTapped
|
|
case submitField
|
|
}
|
|
}
|
|
|
|
@Dependency(\.logger["\(Self.self)"]) var logger
|
|
|
|
public var body: some Reducer<State, Action> {
|
|
BindingReducer()
|
|
Reduce<State, Action> { state, action in
|
|
switch action {
|
|
|
|
case .binding:
|
|
return .none
|
|
|
|
case .destination:
|
|
return .none
|
|
|
|
case let .view(action):
|
|
switch action {
|
|
|
|
case .infoButtonTapped:
|
|
state.destination = .infoView(.init())
|
|
return .none
|
|
|
|
case .nextButtonTapped:
|
|
guard state.isValid else {
|
|
return .fail(
|
|
"""
|
|
Received next button tapped action, but the state is not valid.
|
|
|
|
This is considered an application logic error.
|
|
""",
|
|
logger: logger
|
|
)
|
|
}
|
|
state.sharedSettings.equipmentMeasurement = state.equipmentMeasurement
|
|
state.destination = .flaggedMeasurementsList(.init(sharedSettings: state.$sharedSettings))
|
|
return .none
|
|
|
|
case .onAppear:
|
|
guard let measurement = state.sharedSettings.equipmentMeasurement else {
|
|
return .none
|
|
}
|
|
state.measurements = .init(equipmentMeasurement: measurement)
|
|
return .none
|
|
|
|
case .resetButtonTapped:
|
|
state.measurements = .init()
|
|
return .none
|
|
|
|
case .submitField:
|
|
if state.focusedField == .airflow {
|
|
return .send(.view(.nextButtonTapped))
|
|
}
|
|
state.focusedField = state.focusedField?.next
|
|
return .none
|
|
|
|
}
|
|
}
|
|
}
|
|
.ifLet(\.$destination, action: \.destination)
|
|
}
|
|
}
|
|
|
|
extension Store where State == EquipmentMeasurementForm.State {
|
|
|
|
func prompt(
|
|
field: EquipmentMeasurementForm.State.Field
|
|
) -> String {
|
|
let label = label(field: field)
|
|
guard field != .airflow else { return label }
|
|
if field == .coilPressure && state.equipmentType == .airHandler {
|
|
return "\(label) (Optional)"
|
|
}
|
|
if field == .postFilterPressure && state.equipmentType == .airHandler {
|
|
return "\(label) (Optional)"
|
|
}
|
|
return "\(label) Pressure"
|
|
}
|
|
|
|
func label(
|
|
field: EquipmentMeasurementForm.State.Field
|
|
) -> String {
|
|
switch field {
|
|
case .returnPlenumPressure:
|
|
return "Return"
|
|
case .postFilterPressure:
|
|
return "Post-Filter"
|
|
case .coilPressure:
|
|
switch state.equipmentType {
|
|
case .airHandler:
|
|
return "Post-Coil"
|
|
case .furnaceAndCoil:
|
|
return "Pre-Coil"
|
|
}
|
|
case .supplyPlenumPressure:
|
|
return "Supply"
|
|
case .airflow:
|
|
return "Airflow"
|
|
}
|
|
}
|
|
|
|
var pressureFields: [EquipmentMeasurementForm.State.Field] {
|
|
EquipmentMeasurementForm.State.Field.allCases.filter {
|
|
$0 != .airflow
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewAction(for: EquipmentMeasurementForm.self)
|
|
public struct EquipmentMeasurementFormView: View {
|
|
|
|
@FocusState private var focusedField: EquipmentMeasurementForm.State.Field?
|
|
@Bindable public var store: StoreOf<EquipmentMeasurementForm>
|
|
|
|
public init(store: StoreOf<EquipmentMeasurementForm>) {
|
|
self.store = store
|
|
}
|
|
|
|
public var body: some View {
|
|
Form {
|
|
if store.allowEquipmentTypeSelection {
|
|
Section {
|
|
} header: {
|
|
Text("Equipment Type")
|
|
} footer: {
|
|
EquipmentTypePicker(selection: $store.equipmentType)
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
}
|
|
}
|
|
Section {
|
|
Grid(alignment: .leading, horizontalSpacing: 40) {
|
|
ForEach(store.pressureFields) { field in
|
|
GridRow {
|
|
TextLabel(store.label(field: field))
|
|
textField(for: field)
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
HStack {
|
|
Text("Static Measurements")
|
|
Spacer()
|
|
InfoButton { send(.infoButtonTapped) }
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Grid(alignment: .leading, horizontalSpacing: 60) {
|
|
GridRow {
|
|
TextLabel(store.label(field: .airflow))
|
|
textField(for: .airflow)
|
|
}
|
|
}
|
|
} footer: {
|
|
HStack {
|
|
Spacer()
|
|
ResetButton { send(.resetButtonTapped) }
|
|
.padding(.top)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
.bind($focusedField, to: $store.focusedField)
|
|
.onAppear { send(.onAppear) }
|
|
.textLabelStyle(.boldSecondary)
|
|
.textFieldStyle(.roundedBorder)
|
|
.toolbar {
|
|
NextButton { send(.nextButtonTapped) }
|
|
.nextButtonStyle(.toolbar)
|
|
.disabled(!store.isValid)
|
|
}
|
|
.sheet(
|
|
item: $store.scope(state: \.destination?.infoView, action: \.destination.infoView)
|
|
){ store in
|
|
NavigationStack {
|
|
InfoView(store: store)
|
|
}
|
|
}
|
|
.navigationDestination(
|
|
item: $store.scope(
|
|
state: \.destination?.flaggedMeasurementsList,
|
|
action: \.destination.flaggedMeasurementsList
|
|
)
|
|
) { store in
|
|
FlaggedMeasurementListView(store: store)
|
|
}
|
|
}
|
|
|
|
private func textField(
|
|
for field: EquipmentMeasurementForm.State.Field
|
|
) -> some View {
|
|
let value: Binding<Double?>
|
|
var fractionLength: Int = 2
|
|
|
|
switch field {
|
|
case .returnPlenumPressure:
|
|
value = $store.measurements.returnPlenumPressure
|
|
case .postFilterPressure:
|
|
value = $store.measurements.postFilterPressure
|
|
case .coilPressure:
|
|
value = $store.measurements.coilPressure
|
|
case .supplyPlenumPressure:
|
|
value = $store.measurements.supplyPlenumPressure
|
|
case .airflow:
|
|
value = $store.measurements.airflow
|
|
fractionLength = 0
|
|
}
|
|
|
|
return textField(
|
|
title: store.prompt(field: field),
|
|
value: value,
|
|
fractionLength: fractionLength,
|
|
numberPad: field == .airflow
|
|
)
|
|
.focused($focusedField, equals: field)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func textField(
|
|
title: String,
|
|
value: Binding<Double?>,
|
|
fractionLength: Int,
|
|
numberPad: Bool
|
|
) -> some View {
|
|
if numberPad {
|
|
TextField(
|
|
title,
|
|
value: value,
|
|
fractionLength: fractionLength,
|
|
prompt: Text(title)
|
|
)
|
|
.numberPad()
|
|
.onSubmit { send(.submitField) }
|
|
} else {
|
|
TextField(
|
|
title,
|
|
value: value,
|
|
fractionLength: fractionLength,
|
|
prompt: Text(title)
|
|
)
|
|
.decimalPad()
|
|
.onSubmit { send(.submitField) }
|
|
}
|
|
}
|
|
}
|
|
|
|
fileprivate extension InfoViewFeature.State {
|
|
|
|
init() {
|
|
self.init(
|
|
title: "Existing Measurements",
|
|
body: """
|
|
Record the current static pressure and airflow measurements of the existing system.
|
|
"""
|
|
)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
EquipmentMeasurementFormView(
|
|
store: Store(initialState: EquipmentMeasurementForm.State(
|
|
sharedSettings: Shared(SharedPressureEstimationSettings()))
|
|
) {
|
|
EquipmentMeasurementForm()
|
|
}
|
|
)
|
|
}
|
|
}
|