import ComposableArchitecture import SharedModels import Styleguide import SwiftUI @Reducer public struct EstimateSettingsForm { @CasePathable public enum InfoView { case budgets case updatedAirflow } @Reducer(state: .equatable) public enum Destination { case infoView(InfoViewFeature) } @ObservableState public struct State: Equatable { @Presents public var destination: Destination.State? public var budgets: BudgetedPercentEnvelope public let equipmentMeasurement: EquipmentMeasurement public var fanType: FanType public var focusedField: Field? = nil public var updatedAirflow: Double? public init( destination: Destination.State? = nil, equipmentMeasurement: EquipmentMeasurement, fanType: FanType = .constantSpeed, updatedAirflow: Double? = nil ) { self.destination = destination self.equipmentMeasurement = equipmentMeasurement self.budgets = .init( equipmentType: equipmentMeasurement.equipmentType, fanType: fanType ) self.fanType = fanType self.updatedAirflow = updatedAirflow } public var isValid: Bool { updatedAirflow != nil && Int(budgets.total.rawValue) == 100 } public enum Field: Hashable, CaseIterable, FocusableField { case coilBudget case filterBudget case returnPlenumBudget case supplyPlenumBudget case updatedAirflow } } public enum Action: BindableAction, ViewAction { case binding(BindingAction) case destination(PresentationAction) case view(View) @CasePathable public enum View { case infoButtonTapped(InfoView) case nextButtonTapped case resetButtonTapped case submitField } } public var body: some Reducer { BindingReducer() Reduce { state, action in switch action { case .binding(\.fanType): state.budgets = .init( equipmentType: state.equipmentMeasurement.equipmentType, fanType: state.fanType ) 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 .nextButtonTapped: #warning("Fix me.") return .none case .resetButtonTapped: state.budgets = .init( equipmentType: state.equipmentMeasurement.equipmentType, fanType: state.fanType ) state.updatedAirflow = nil return .none case .submitField: state.focusedField = state.focusedField?.next return .none } } } .ifLet(\.$destination, action: \.destination) } } @ViewAction(for: EstimateSettingsForm.self) public struct EstimateSettingsFormView: View { @FocusState private var focusedField: EstimateSettingsForm.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 { EmptyView() } header: { TextLabel("Fan Type") #if os(macOS) .font(.title2) #endif } footer: { FanTypePicker(selection: $store.fanType) .pickerStyle(.segmented) } Section { Grid(alignment: .leading, horizontalSpacing: 40) { if store.equipmentMeasurement.equipmentType == .furnaceAndCoil { GridRow { TextLabel("Coil") percentField("Coil Budget", value: $store.budgets.coilBudget.fraction) .focused($focusedField, equals: .coilBudget) .onSubmit { send(.submitField) } .numberPad() } } GridRow { TextLabel("Filter") percentField("Filter Budget", value: $store.budgets.filterBudget.fraction) .focused($focusedField, equals: .filterBudget) .onSubmit { send(.submitField) } .numberPad() } GridRow { TextLabel("Return") percentField("Return Plenum Budget", value: $store.budgets.returnPlenumBudget.fraction) .focused($focusedField, equals: .returnPlenumBudget) .onSubmit { send(.submitField) } .numberPad() } GridRow { TextLabel("Supply") percentField("Supply Plenum Budget", value: $store.budgets.supplyPlenumBudget.fraction) .focused($focusedField, equals: .supplyPlenumBudget) .onSubmit { send(.submitField) } .numberPad() } } .textLabelStyle(.boldSecondary) .textFieldStyle(.roundedBorder) } header: { VStack { HStack { Text("Budgets") Spacer() InfoButton { send(.infoButtonTapped(.budgets)) } } #if os(macOS) .font(.title2) .padding(.top, 20) #endif FlaggedView( flagged: Flagged(wrappedValue: store.budgets.total.rawValue, .percent()) ) .flaggedViewStyle(BudgetFlagViewStyle()) } } Section { TextField( "Airflow", value: $store.updatedAirflow, fractionLength: 0 ) } header: { HStack { Text("Updated Airflow") Spacer() InfoButton { send(.infoButtonTapped(.updatedAirflow)) } } } footer: { HStack { ResetButton { send(.resetButtonTapped) } Spacer() Button("Next") { send(.nextButtonTapped) } .disabled(!store.isValid) } .padding(.top) } } .labelsHidden() .bind($focusedField, to: $store.focusedField) .navigationTitle("Estimate Settings") .sheet( item: $store.scope( state: \.destination?.infoView, action: \.destination.infoView ) ) { store in NavigationStack { InfoView(store: store) } } } private func percentField( _ title: LocalizedStringKey, value: Binding ) -> some View { TextField(title, value: value, format: .percent, prompt: Text(title)) } } fileprivate struct BudgetFlagViewStyle: FlaggedViewStyle { func makeBody(configuration: Configuration) -> some View { HStack { configuration.flagged.flagImage Spacer() configuration.flagged.messageView } } } fileprivate extension InfoViewFeature.State { init(view: EstimateSettingsForm.InfoView) { switch view { case .budgets: self.init( title: "Budgets", body: """ Budgeted percentages for static pressure estimations, these generally are set to reasonable defaults, however you can change them if desired. Note: These must total up to 100%. """ ) case .updatedAirflow: self.init( title: "Updated Airflow", body: """ This is used to generated estimated static pressures at the updated airflow compared to the existing airflow of the system. """ ) } } } #Preview { NavigationStack { EstimateSettingsFormView( store: Store( initialState: EstimateSettingsForm.State(equipmentMeasurement: .furnaceAndCoil(.init())) ) { EstimateSettingsForm() } ) #if os(macOS) .frame(width: 400, height: 600) .padding() #endif } }