Files
swift-estimated-pressures-core/Sources/PressureEstimationsFeature/EquipmentSettingsForm.swift

352 lines
9.7 KiB
Swift

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 var ratedStaticPressures: RatedStaticPressuresSection.State
public init(
destination: Destination.State? = nil,
includesFilterDrop: Bool = false,
equipmentType: EquipmentMeasurement.EquipmentType = .airHandler,
sharedSettings: Shared<SharedPressureEstimationState>
) {
self.destination = destination
self.includesFilterDrop = includesFilterDrop
self.equipmentType = equipmentType
self._sharedSettings = sharedSettings
self.ratedStaticPressures = .init(staticPressures: sharedSettings.equipmentMetadata.ratedStaticPressures)
}
public var isValid: Bool {
guard equipmentType == .furnaceAndCoil
else { return ratedStaticPressures.isValid }
return ratedStaticPressures.isValid && sharedSettings.heatingCapacity != nil
}
// Note: These need to be in display order.
public enum Field: Hashable, CaseIterable, FocusableField, Identifiable {
case heatingCapacity
case ratedStaticPressure(RatedStaticPressuresSection.State.FocusedField)
case manufacturersIncludedFilterPressureDrop
public static var allCases: [EquipmentSettingsForm.State.Field] {
[
.heatingCapacity,
.ratedStaticPressure(.maximum),
.ratedStaticPressure(.minimum),
.ratedStaticPressure(.rated),
.manufacturersIncludedFilterPressureDrop
]
}
public var id: Self { self }
}
}
public enum Action: BindableAction, ViewAction {
case binding(BindingAction<State>)
case destination(PresentationAction<Destination.Action>)
case ratedStaticPressures(RatedStaticPressuresSection.Action)
case view(View)
@CasePathable
public enum View {
case infoButtonTapped(InfoView)
case resetButtonTapped
case submitField
}
}
public var body: some Reducer<State, Action> {
BindingReducer()
Scope(state: \.ratedStaticPressures, action: \.ratedStaticPressures) {
RatedStaticPressuresSection()
}
Reduce<State, Action> { 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 .ratedStaticPressures(.delegate(.infoButtonTapped)):
state.destination = .infoView(.init(view: .ratedStaticPressures))
return .none
case .ratedStaticPressures:
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<EquipmentSettingsForm>
public init(store: StoreOf<EquipmentSettingsForm>) {
self.store = store
self.focusedField = store.focusedField
}
public var body: some View {
Form {
Section {
EquipmentTypePicker(selection: $store.equipmentType)
.dynamicBottomPadding()
.pickerStyle(.segmented)
} header: {
SectionHeaderLabel("Equipment Type")
}
.listRowBackground(Color.clear)
Section {
FanTypePicker(selection: $store.sharedSettings.fanType)
.dynamicBottomPadding()
.pickerStyle(.segmented)
} header: {
SectionHeaderLabel("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)
}
}
}
.dynamicBottomPadding()
} header: {
if store.equipmentType == .airHandler {
EmptyView()
} else {
SectionHeaderLabel("Capacities")
}
}
RatedStaticPressuresSectionView(
store: store.scope(state: \.ratedStaticPressures, action: \.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")
}
}
}
}
.applyFormStyle()
.bind($focusedField, to: $store.focusedField)
.sheet(
item: $store.scope(
state: \.destination?.infoView,
action: \.destination.infoView
)
) { store in
NavigationStack {
InfoView(store: store)
}
}
}
private func header<Label: View>(
infoView: EquipmentSettingsForm.InfoView,
label: @escaping () -> Label
) -> some View {
HStack {
SectionHeaderLabel { 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<Double?>,
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.
"""
)
}
}
}
#Preview {
NavigationStack {
EquipmentSettingsFormView(
store: Store(
initialState: EquipmentSettingsForm.State(
sharedSettings: Shared(SharedPressureEstimationState())
)
) {
EquipmentSettingsForm()._printChanges()
}
)
#if os(macOS)
.frame(width: 400, height: 600)
.padding()
#endif
}
}