import ComposableArchitecture import FlaggedViews import InfoViewFeature import SharedModels import Styleguide import SwiftUI @Reducer public struct EquipmentSettingsForm { @CasePathable public enum InfoView { case capacities case manufacturersIncludedFilterPressureDrop case ratedStaticPressures } @Reducer(state: .equatable) public enum Destination { case infoView(InfoViewFeature) } @ObservableState public struct State: Equatable { @Presents public var destination: Destination.State? public var includesFilterDrop: Bool public var equipmentType: EquipmentMeasurement.EquipmentType public var focusedField: Field? = nil @Shared public var sharedSettings: SharedPressureEstimationState public init( destination: Destination.State? = nil, includesFilterDrop: Bool = false, equipmentType: EquipmentMeasurement.EquipmentType = .airHandler, sharedSettings: Shared ) { self.destination = destination self.includesFilterDrop = includesFilterDrop self.equipmentType = equipmentType self._sharedSettings = sharedSettings } public var isValid: Bool { guard equipmentType == .furnaceAndCoil else { return true } return sharedSettings.heatingCapacity != nil } // Note: These need to be in display order. public enum Field: Hashable, CaseIterable, FocusableField, Identifiable { case heatingCapacity case minimumStaticPressure case maximumStaticPressure case ratedStaticPressure case manufacturersIncludedFilterPressureDrop public var id: Self { self } } } public enum Action: BindableAction, ViewAction { case binding(BindingAction) case destination(PresentationAction) case view(View) @CasePathable public enum View { case infoButtonTapped(InfoView) case resetButtonTapped case submitField } } public var body: some Reducer { BindingReducer() Reduce { state, action in switch action { case .binding(\.includesFilterDrop): guard state.includesFilterDrop else { state.sharedSettings.manufacturersIncludedFilterPressureDrop = nil return .none } guard state.sharedSettings.manufacturersIncludedFilterPressureDrop != nil else { return .none } state.sharedSettings.manufacturersIncludedFilterPressureDrop = 0.1 return .none case .binding: return .none case .destination(.dismiss): state.destination = nil return .none case .destination: return .none case let .view(action): switch action { case let .infoButtonTapped(infoView): state.destination = .infoView(.init(view: infoView)) return .none case .resetButtonTapped: state.sharedSettings.heatingCapacity = nil return .none case .submitField: state.focusedField = state.focusedField?.next return .none } } } .ifLet(\.$destination, action: \.destination) } } @ViewAction(for: EquipmentSettingsForm.self) public struct EquipmentSettingsFormView: View { @FocusState private var focusedField: EquipmentSettingsForm.State.Field? @Bindable public var store: StoreOf public init(store: StoreOf) { self.store = store self.focusedField = store.focusedField } public var body: some View { Form { Section { EquipmentTypePicker(selection: $store.equipmentType) .pickerStyle(.segmented) EmptyView() } header: { Text("Equipment Type") } .listRowBackground(Color.clear) Section { FanTypePicker(selection: $store.sharedSettings.fanType) .pickerStyle(.segmented) } header: { Text("Fan Type") } .listRowBackground(Color.clear) Section { Grid(alignment: .leading, horizontalSpacing: 40) { GridRow { TextLabel( store.equipmentType == .furnaceAndCoil ? "Cooling" : "Capacity" ) Spacer() CoolingCapacityPicker( selection: $store.sharedSettings.coolingCapacity ) } if store.equipmentType == .furnaceAndCoil { GridRow { TextLabel("Heating") textField( "Heating Capacity", value: $store.sharedSettings.heatingCapacity, fractionLength: 0 ) .focused($focusedField, equals: .heatingCapacity) .numberPad() .gridCellColumns(2) } } } } header: { if store.equipmentType == .airHandler { EmptyView() } else { Text("Capacities") } } Section { Grid(alignment: .leading, horizontalSpacing: 40) { ForEach(RatingsField.allCases, content: ratingsRow(for:)) } .labeledContentStyle(.gridRow) } header: { header("Rated Static Pressure", infoView: .ratedStaticPressures) } Section { VStack(alignment: .leading) { HStack { TextLabel("Includes Filter Drop") Spacer() Toggle("Includes Filter Drop", isOn: $store.includesFilterDrop) } if store.includesFilterDrop { HStack { TextLabel("Filter Drop") Spacer() textField( "Filter Drop", value: $store.sharedSettings.manufacturersIncludedFilterPressureDrop ) .focused($focusedField, equals: .manufacturersIncludedFilterPressureDrop) .decimalPad() .padding(.leading, 40) } } } } header: { header(infoView: .manufacturersIncludedFilterPressureDrop) { VStack(alignment: .leading) { Text("Manufacturer's") Text("Filter Pressure Drop") } } } } .labelsHidden() .bind($focusedField, to: $store.focusedField) .textLabelStyle(.boldSecondary) .textFieldStyle(.roundedBorder) .sheet( item: $store.scope( state: \.destination?.infoView, action: \.destination.infoView ) ) { store in NavigationStack { InfoView(store: store) } } } private func ratingsRow(for ratingsField: RatingsField) -> some View { func binding(for ratingsField: RatingsField) -> Binding { switch ratingsField { case .maximum: return $store.sharedSettings.ratedStaticPressures.maximum case .minimum: return $store.sharedSettings.ratedStaticPressures.minimum case .rated: return $store.sharedSettings.ratedStaticPressures.rated } } return TextLabeledContent(ratingsField.label) { TextField(ratingsField.prompt, value: binding(for: ratingsField), fractionLength: 2) .decimalPad() .focused($focusedField, equals: ratingsField.field) .onSubmit { send(.submitField) } } } private func header( infoView: EquipmentSettingsForm.InfoView, label: @escaping () -> Label ) -> some View { HStack { label() Spacer() InfoButton { send(.infoButtonTapped(infoView)) } } } private func header( _ title: String, infoView: EquipmentSettingsForm.InfoView ) -> some View { header(infoView: infoView) { Text(title) } } private func textField( _ title: String, value: Binding, fractionLength: Int = 2 ) -> some View { TextField(title, value: value, fractionLength: fractionLength, prompt: Text(title)) .onSubmit { send(.submitField) } } } fileprivate struct BudgetFlagViewStyle: FlaggedViewStyle { func makeBody(configuration: Configuration) -> some View { HStack { configuration.flagged.flagImage Spacer() configuration.flagged.messageView } } } fileprivate extension InfoViewFeature.State { init(view: EquipmentSettingsForm.InfoView) { switch view { case .capacities: self.init( title: "Capacities", body: """ Record the cooling and heating capacities of the system. """ ) case .manufacturersIncludedFilterPressureDrop: self.init( title: "Filter Pressure Drop", body: """ Record the filter pressure drop that the manufacturer includes in their blower performance table, if applicable. Sometimes this information is not listed, therefore it may be reasonable to use a sensible default value of '0.1'. Note: The value that is set get's deducted from the filter pressure drop when determining the external static pressure of a system. """ ) case .ratedStaticPressures: self.init( title: "Rated Static", body: """ Record the rated static pressures of the system. These are generally found on the nameplate of the equipment or in the installation manual. The defaults are generally acceptable for most unitary equipment. """ ) } } } fileprivate enum RatingsField: String, Hashable, CaseIterable, Identifiable { case maximum case minimum case rated var id: Self { self } var field: EquipmentSettingsForm.State.Field { switch self { case .maximum: return .maximumStaticPressure case .minimum: return .minimumStaticPressure case .rated: return .ratedStaticPressure } } var label: String { rawValue.capitalized } var prompt: String { "\(label) Pressure" } } #Preview { NavigationStack { EquipmentSettingsFormView( store: Store( initialState: EquipmentSettingsForm.State( sharedSettings: Shared(SharedPressureEstimationState()) ) ) { EquipmentSettingsForm()._printChanges() } ) #if os(macOS) .frame(width: 400, height: 600) .padding() #endif } }