Feat: Consilidates calculated at views.
This commit is contained in:
401
Sources/CalculateAtFeature/CalculateAtView.swift
Normal file
401
Sources/CalculateAtFeature/CalculateAtView.swift
Normal file
@@ -0,0 +1,401 @@
|
||||
import ComposableArchitecture
|
||||
import DependenciesAdditions
|
||||
import EstimatedPressureDependency
|
||||
import SharedModels
|
||||
import Styleguide
|
||||
import SwiftUI
|
||||
import TCAExtras
|
||||
|
||||
@Reducer
|
||||
public struct CalculateAtFeature {
|
||||
|
||||
@ObservableState
|
||||
@dynamicMemberLookup
|
||||
public struct State: Equatable {
|
||||
public var focus: Focus? = nil
|
||||
public var isPresentingInfoView: Bool = false
|
||||
public var values: ValueContainer
|
||||
public var calculationType: CalculationType
|
||||
|
||||
public init(
|
||||
values: ValueContainer = .init(),
|
||||
calculationType: CalculationType = .airflowAtNewPressure
|
||||
) {
|
||||
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 {
|
||||
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 {
|
||||
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 receive(TaskResult<ReceiveAction>)
|
||||
case view(View)
|
||||
|
||||
@CasePathable
|
||||
public enum ReceiveAction {
|
||||
case calculatedValue(Positive<Double>?)
|
||||
}
|
||||
|
||||
@CasePathable
|
||||
public enum View {
|
||||
case dismissInfoViewButtonTapped
|
||||
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 let .receive(.success(.calculatedValue(calculatedAirflow))):
|
||||
state.calculatedValue = calculatedAirflow?.positiveValue
|
||||
return .none
|
||||
|
||||
case .receive:
|
||||
return .none
|
||||
|
||||
case let .view(action):
|
||||
switch action {
|
||||
case .dismissInfoViewButtonTapped:
|
||||
state.isPresentingInfoView = false
|
||||
return .none
|
||||
|
||||
case .infoButtonTapped:
|
||||
state.isPresentingInfoView = true
|
||||
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))
|
||||
}
|
||||
|
||||
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?
|
||||
|
||||
@Perception.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) }
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
} footer: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Reset") { send(.resetButtonTapped) }
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.bind($focus, to: $store.focus)
|
||||
.onAppear { send(.onAppear) }
|
||||
.sheet(isPresented: $store.isPresentingInfoView) {
|
||||
NavigationStack {
|
||||
InfoView(calculationType: store.calculationType)
|
||||
.toolbar {
|
||||
Button("Done") { send(.dismissInfoViewButtonTapped) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension InfoView {
|
||||
|
||||
init(calculationType: CalculateAtFeature.State.CalculationType) {
|
||||
switch calculationType {
|
||||
case .airflowAtNewPressure:
|
||||
self.init(
|
||||
heading: """
|
||||
Calculate the airflow at the target pressure from the existing airflow and existing pressure.
|
||||
""",
|
||||
body: """
|
||||
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(
|
||||
heading: """
|
||||
Calculate the pressure at the target airflow from the existing airflow and existing pressure.
|
||||
""",
|
||||
body: """
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user