import ComposableArchitecture import DependenciesAdditions import InfoViewFeature 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: SharedPressureEstimationState 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, 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) case destination(PresentationAction) 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 { BindingReducer() Reduce { 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) } } fileprivate 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 "CFM" } } 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 public init(store: StoreOf) { 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, content: gridRow(for:)) } } header: { HStack { Text("Static Measurements") Spacer() InfoButton { send(.infoButtonTapped) } } } Section { Grid(alignment: .leading, horizontalSpacing: 80) { gridRow(for: .airflow) } } header: { HStack { Spacer() Text(store.sharedSettings.equipmentMetadata.coolingCapacity.description) } } footer: { HStack { Spacer() ResetButton { send(.resetButtonTapped) } .padding(.top) Spacer() } } } .bind($focusedField, to: $store.focusedField) .labeledContentStyle(.gridRow) .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 gridRow(for field: EquipmentMeasurementForm.State.Field) -> some View { TextLabeledContent(store.label(field: field)) { textField(for: field) } } private func textField( for field: EquipmentMeasurementForm.State.Field ) -> some View { let value: Binding 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, 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(SharedPressureEstimationState())) ) { EquipmentMeasurementForm() } ) } }