import ComposableArchitecture import DependenciesAdditions import EstimatedPressureDependency import InfoViewFeature import SharedModels import Styleguide import SwiftUI import TCAExtras @Reducer public struct CalculateAtFeature: Sendable { @Reducer(state: .equatable, .sendable) public enum Destination { case infoView(InfoViewFeature) } @ObservableState @dynamicMemberLookup public struct State: Equatable, Sendable { @Presents public var destination: Destination.State? public var focus: Focus? = nil public var values: ValueContainer public var calculationType: CalculationType public init( destination: Destination.State? = nil, values: ValueContainer = .init(), calculationType: CalculationType = .airflowAtNewPressure ) { self.destination = destination self.values = values self.calculationType = calculationType } public var isValid: Bool { values.existingAirflow != nil && values.existingPressure != nil && values.targetValue != nil } public subscript(dynamicMember keyPath: KeyPath) -> T { calculationType[keyPath: keyPath] } public subscript(dynamicMember keyPath: WritableKeyPath) -> T { get { values[keyPath: keyPath] } set { values[keyPath: keyPath] = newValue } } public enum Focus: Hashable, Sendable { case existingAirflow case existingPressure case targetValue } public struct ValueContainer: Equatable, Sendable { public var existingAirflow: Double? public var existingPressure: Double? public var targetValue: Double? public var calculatedValue: Double? public init( existingAirflow: Double? = nil, existingPressure: Double? = nil, targetValue: Double? = nil, calculatedValue: Double? = nil ) { self.existingAirflow = existingAirflow self.existingPressure = existingPressure self.targetValue = targetValue self.calculatedValue = calculatedValue } } public enum CalculationType: Hashable, CaseIterable, Identifiable, Sendable { case airflowAtNewPressure case pressureAtNewAirflow public var id: Self { self } private var precision: Int { switch self { case .airflowAtNewPressure: return 0 case .pressureAtNewAirflow: return 2 } } var format: FloatingPointFormatStyle { .number.precision(.fractionLength(precision)) } var targetLabel: String { switch self { case .airflowAtNewPressure: return "Pressure" case .pressureAtNewAirflow: return "Airflow" } } var calculationLabel: String { switch self { case .airflowAtNewPressure: return "Airflow" case .pressureAtNewAirflow: return "Pressure" } } var calculationLabelSpacing: CGFloat { switch self { case .airflowAtNewPressure: return 5 case .pressureAtNewAirflow: return 0 } } var calculationPostfix: String { switch self { case .airflowAtNewPressure: return "CFM" case .pressureAtNewAirflow: return "\" w.c." } } var label: String { switch self { case .airflowAtNewPressure: return "Airflow at Pressure" case .pressureAtNewAirflow: return "Pressure at Airflow" } } } } public enum Action: BindableAction, ReceiveAction, ViewAction { case binding(BindingAction) case destination(PresentationAction) case receive(TaskResult) case view(View) @CasePathable public enum ReceiveAction: Sendable { case calculatedValue(Positive?) } @CasePathable public enum View { case infoButtonTapped case onAppear case resetButtonTapped case submit } } @Dependency(\.estimatedPressuresClient) var estimatedPressuresClient @Dependency(\.logger["\(Self.self)"]) var logger public var body: some Reducer { BindingReducer() Reduce { state, action in switch action { case .binding(\.calculationType): state.targetValue = nil state.calculatedValue = nil return .none case .binding: return .none case .destination: return .none case let .receive(.success(.calculatedValue(calculatedAirflow))): state.calculatedValue = calculatedAirflow?.positiveValue return .none case .receive: return .none case let .view(action): switch action { case .infoButtonTapped: state.destination = .infoView(.init(calculationType: state.calculationType)) return .none case .onAppear: state.focus = .existingAirflow return .none case .resetButtonTapped: state.values = .init() return .none case .submit: guard state.isValid else { return .none } return calculate(state: state) } } } .onFailure(.log(logger: logger)) .ifLet(\.$destination, action: \.destination) } func calculate(state: State) -> Effect { return .receive(\.calculatedValue) { [state] in switch state.calculationType { case .airflowAtNewPressure: return try await estimatedPressuresClient.estimatedAirflow( .init( existingAirflow: state.existingAirflow ?? 0, existingPressure: state.existingPressure ?? 0, targetPressure: state.targetValue ?? 0 ) ) case .pressureAtNewAirflow: return try await estimatedPressuresClient.estimatedPressure( existingPressure: state.existingPressure ?? 0, existingAirflow: state.existingAirflow ?? 0, targetAirflow: state.targetValue ?? 0 ) } } } } @ViewAction(for: CalculateAtFeature.self) public struct CalculateAtView: View { @FocusState private var focus: CalculateAtFeature.State.Focus? @Bindable public var store: StoreOf let allowChangingCalculationType: Bool public init( allowChangingCalculationType: Bool = true, store: StoreOf ) { self.allowChangingCalculationType = allowChangingCalculationType self.store = store self.focus = store.focus } public var body: some View { VStack { if allowChangingCalculationType { Picker("Calculation Type", selection: $store.calculationType) { ForEach(CalculateAtFeature.State.CalculationType.allCases) { Text($0.label) .tag($0.id) } } .pickerStyle(.segmented) .labelsHidden() .padding(.bottom) } Form { Section { TextField( "Existing Airflow", value: $store.existingAirflow, format: .number, prompt: Text("Existing Airflow") ) .focused($focus, equals: .existingAirflow) .onSubmit { send(.submit) } .numberPad() TextField( "Existing Pressure", value: $store.existingPressure, format: .number, prompt: Text("Existing Pressure") ) .focused($focus, equals: .existingPressure) .onSubmit { send(.submit) } .decimalPad() TextField( "Target \(store.targetLabel)", value: $store.targetValue, format: .number, prompt: Text("Target \(store.targetLabel)") ) .focused($focus, equals: .existingPressure) .onSubmit { send(.submit) } .keyboard(for: store.calculationType) if let airflow = store.calculatedValue { HStack { Text("Calculated \(store.calculationLabel)") .foregroundStyle(Color.secondary) .font(.callout.bold()) Spacer() ZStack { HStack(spacing: store.calculationLabelSpacing) { Text(airflow, format: store.format) Text(store.calculationPostfix) } .foregroundStyle(Color.white) .font(.callout) .bold() .padding(.horizontal, 10) .padding(.vertical, 5) } .background { RoundedRectangle(cornerRadius: 10) .foregroundStyle(Color.green) .opacity(0.6) } } .padding(.top) } } header: { HStack { Text("Inputs") Spacer() InfoButton { send(.infoButtonTapped) } .foregroundStyle(Color.accentColor) .buttonStyle(.plain) } .labelStyle(.iconOnly) } footer: { HStack { Spacer() Button("Reset") { send(.resetButtonTapped) } Spacer() } .padding(.top) .buttonStyle(.borderedProminent) } .labelsHidden() } Spacer() } .bind($focus, to: $store.focus) .onAppear { focus = .existingAirflow } .sheet( item: $store.scope(state: \.destination?.infoView, action: \.destination.infoView) ) { store in NavigationStack { InfoView(store: store) } } } } fileprivate extension InfoViewFeature.State { init(calculationType: CalculateAtFeature.State.CalculationType) { switch calculationType { case .airflowAtNewPressure: self.init( title: "Airflow at Pressure", body: """ Calculate the airflow at the target pressure from the existing airflow and existing pressure. This can be useful to determine the effect of a different blower speed on a specific measurement location (generally the supply or return plenum). """ ) case .pressureAtNewAirflow: self.init( title: "Pressure at Airflow", body: """ Calculate the pressure at the target airflow from the existing airflow and existing pressure. This can be useful to determine the effect of a different blower speed on a specific measurement location (generally the supply or return plenum). """ ) } } } fileprivate extension View { @ViewBuilder func keyboard(for calculationType: CalculateAtFeature.State.CalculationType) -> some View { switch calculationType { case .airflowAtNewPressure: self.decimalPad() case .pressureAtNewAirflow: self.numberPad() } } } #Preview { CalculateAtView( store: Store(initialState: CalculateAtFeature.State()) { CalculateAtFeature()._printChanges() } ) #if os(macOS) .frame(width: 400, height: 400) .padding() #endif }