Files
swift-estimated-pressures-core/Sources/CalculateAtFeature/CalculateAtView.swift

412 lines
11 KiB
Swift

import ComposableArchitecture
import DependenciesAdditions
import EstimatedPressureDependency
import InfoViewFeature
import SharedModels
import Styleguide
import SwiftUI
import TCAExtras
@Reducer
public struct CalculateAtFeature: Sendable {
@Reducer(state: .equatable)
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<T>(dynamicMember keyPath: KeyPath<CalculationType, T>) -> T {
calculationType[keyPath: keyPath]
}
public subscript<T>(dynamicMember keyPath: WritableKeyPath<ValueContainer, T>) -> T {
get { values[keyPath: keyPath] }
set { values[keyPath: keyPath] = newValue }
}
public enum Focus: Hashable {
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<Double> {
.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<State>)
case destination(PresentationAction<Destination.Action>)
case receive(TaskResult<ReceiveAction>)
case view(View)
@CasePathable
public enum ReceiveAction: Sendable {
case calculatedValue(Positive<Double>?)
}
@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<State, Action> {
BindingReducer()
Reduce<State, Action> { 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<Action> {
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<CalculateAtFeature>
let allowChangingCalculationType: Bool
public init(
allowChangingCalculationType: Bool = true,
store: StoreOf<CalculateAtFeature>
) {
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
}