diff --git a/Sources/EstimatedPressureDependency/Key.swift b/Sources/EstimatedPressureDependency/Key.swift index 882e39d..c9746d4 100644 --- a/Sources/EstimatedPressureDependency/Key.swift +++ b/Sources/EstimatedPressureDependency/Key.swift @@ -80,66 +80,66 @@ extension EstimatedPressureDependency { } public func estimatedPressure( - for equipmentMeasurement: EquipmentMeasurement, - at upgradedAirflow: Double + equipmentMeasurement: EquipmentMeasurement, + airflow updatedAirflow: Double ) async throws -> EquipmentMeasurement { switch equipmentMeasurement { case let .airHandler(airHandler): - guard let airflow = airHandler.airflow else { + guard let existingAirflow = airHandler.airflow else { throw InvalidAirflow() } return try await .airHandler( .init( - airflow: upgradedAirflow, + airflow: updatedAirflow, returnPlenumPressure: self.estimatedPressure( existingPressure: airHandler.$returnPlenumPressure, - existingAirflow: airflow, - targetAirflow: upgradedAirflow + existingAirflow: existingAirflow, + targetAirflow: updatedAirflow ), postFilterPressure: self.estimatedPressure( existingPressure: airHandler.$postFilterPressure, - existingAirflow: airflow, - targetAirflow: upgradedAirflow + existingAirflow: existingAirflow, + targetAirflow: updatedAirflow ), postCoilPressure: self.estimatedPressure( existingPressure: airHandler.$postCoilPressure, - existingAirflow: airflow, - targetAirflow: upgradedAirflow + existingAirflow: existingAirflow, + targetAirflow: updatedAirflow ), supplyPlenumPressure: self.estimatedPressure( existingPressure: airHandler.$supplyPlenumPressure, - existingAirflow: airflow, - targetAirflow: upgradedAirflow + existingAirflow: existingAirflow, + targetAirflow: updatedAirflow ) ) ) case let .furnaceAndCoil(furnaceAndCoil): - guard let airflow = furnaceAndCoil.airflow else { + guard let existingAirflow = furnaceAndCoil.airflow else { throw InvalidAirflow() } return try await .furnaceAndCoil( .init( - airflow: upgradedAirflow, + airflow: updatedAirflow, returnPlenumPressure: self.estimatedPressure( existingPressure: furnaceAndCoil.$returnPlenumPressure, - existingAirflow: airflow, - targetAirflow: upgradedAirflow + existingAirflow: existingAirflow, + targetAirflow: updatedAirflow ), postFilterPressure: self.estimatedPressure( existingPressure: furnaceAndCoil.$postFilterPressure, - existingAirflow: airflow, - targetAirflow: upgradedAirflow + existingAirflow: existingAirflow, + targetAirflow: updatedAirflow ), preCoilPressure: self.estimatedPressure( existingPressure: furnaceAndCoil.$preCoilPressure, - existingAirflow: airflow, - targetAirflow: upgradedAirflow + existingAirflow: existingAirflow, + targetAirflow: updatedAirflow ), supplyPlenumPressure: self.estimatedPressure( existingPressure: furnaceAndCoil.$supplyPlenumPressure, - existingAirflow: airflow, - targetAirflow: upgradedAirflow + existingAirflow: existingAirflow, + targetAirflow: updatedAirflow ) ) ) @@ -148,13 +148,13 @@ extension EstimatedPressureDependency { } public func estimatedPressure( - for equipmentMeasurement: EquipmentMeasurement, - at upgradedAirflow: Double, - with filterPressureDrop: Positive + equipmentMeasurement: EquipmentMeasurement, + airflow updatedAirflow: Double, + filterPressureDrop: Positive ) async throws -> EquipmentMeasurement { let estimate = try await estimatedPressure( - for: equipmentMeasurement, - at: upgradedAirflow + equipmentMeasurement: equipmentMeasurement, + airflow: updatedAirflow ) switch estimate { @@ -167,8 +167,24 @@ extension EstimatedPressureDependency { furnaceAndCoil.postFilterPressure = furnaceAndCoil.returnPlenumPressure + filterPressureDrop.positiveValue return .furnaceAndCoil(furnaceAndCoil) } - - + } + + public func estimatedPressure( + equipmentMeasurement: EquipmentMeasurement, + airflow updatedAirflow: Double, + filterPressureDrop: Positive? + ) async throws -> EquipmentMeasurement { + guard let filterPressureDrop else { + return try await estimatedPressure( + equipmentMeasurement: equipmentMeasurement, + airflow: updatedAirflow + ) + } + return try await estimatedPressure( + equipmentMeasurement: equipmentMeasurement, + airflow: updatedAirflow, + filterPressureDrop: filterPressureDrop + ) } } diff --git a/Sources/PressureEstimationsFeature/EquipmentMeasurementForm.swift b/Sources/PressureEstimationsFeature/EquipmentMeasurementForm.swift index 6d497e9..a7f4ac5 100644 --- a/Sources/PressureEstimationsFeature/EquipmentMeasurementForm.swift +++ b/Sources/PressureEstimationsFeature/EquipmentMeasurementForm.swift @@ -3,6 +3,7 @@ import SharedModels import Styleguide import SwiftUI + @Reducer public struct EquipmentMeasurementForm { diff --git a/Sources/PressureEstimationsFeature/EquipmentSettingsForm.swift b/Sources/PressureEstimationsFeature/EquipmentSettingsForm.swift index 306c117..87ecd75 100644 --- a/Sources/PressureEstimationsFeature/EquipmentSettingsForm.swift +++ b/Sources/PressureEstimationsFeature/EquipmentSettingsForm.swift @@ -3,6 +3,7 @@ import SharedModels import Styleguide import SwiftUI + @Reducer public struct EquipmentSettingsForm { diff --git a/Sources/PressureEstimationsFeature/EstimationsForm.swift b/Sources/PressureEstimationsFeature/EstimationsForm.swift new file mode 100644 index 0000000..f563e0f --- /dev/null +++ b/Sources/PressureEstimationsFeature/EstimationsForm.swift @@ -0,0 +1,126 @@ +import ComposableArchitecture +import SharedModels +import Styleguide +import SwiftUI + +@Reducer +public struct EstimationForm { + public init() { } + + @ObservableState + public struct State: Equatable { + public var cfmPerTon: Int + public var coolingCapacity: CoolingCapacity + public var filterPressureDrop: Double? + public var name: String + + public init( + cfmPerTon: Int = 350, + coolingCapacity: CoolingCapacity = .default, + filterPressureDrop: Double? = nil, + name: String = "" + ) { + self.cfmPerTon = cfmPerTon + self.filterPressureDrop = filterPressureDrop + self.coolingCapacity = coolingCapacity + self.name = name + } + + public var airflow: Double { + Double(cfmPerTon) * coolingCapacity.rawValue + } + + public var isValid: Bool { !name.isEmpty } + } + + public enum Action: BindableAction { + case binding(BindingAction) + } + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + + case .binding: + return .none + + } + } + } +} + +public struct EstimationFormView: View { + @Bindable public var store: StoreOf + + public var body: some View { + Form { + Section("Estimation Name") { + HStack { + TextLabel("Name") + .padding(.trailing, 40) + TextField( + "Name", + text: $store.name, + prompt: Text("Required") + ) + } + } + Section("Airflow") { + Grid(alignment: .leading, horizontalSpacing: 40) { + GridRow { + HStack { + TextLabel("Capacity") + Spacer() + CoolingCapacityPicker( + selection: $store.coolingCapacity + ) + } + .gridCellColumns(2) + } + GridRow { + HStack { + TextLabel("CFM / Ton") + Spacer() + TextField( + "CFM / Ton", + value: $store.cfmPerTon, + format: .number, + prompt: Text("CFM") + ) + .frame(width: 100) + .numberPad() + } + .gridCellColumns(2) + } + } + } + + Section("Filter Pressure Drop") { + HStack { + TextLabel("Pressure Drop") + Spacer() + TextField( + "Filter Drop", + value: $store.filterPressureDrop, + fractionLength: 2, + prompt: Text("Optional") + ) + .frame(width: 100) + .decimalPad() + } + } + } + .labelsHidden() + .textLabelStyle(.boldSecondary) + .textFieldStyle(.roundedBorder) + } +} + +#Preview { + EstimationFormView( + store: Store(initialState: EstimationForm.State()) { + EstimationForm() + } + ) +} diff --git a/Sources/PressureEstimationsFeature/FlaggedMeasurementsList.swift b/Sources/PressureEstimationsFeature/FlaggedMeasurementsList.swift new file mode 100644 index 0000000..232166c --- /dev/null +++ b/Sources/PressureEstimationsFeature/FlaggedMeasurementsList.swift @@ -0,0 +1,307 @@ +import ComposableArchitecture +import DependenciesAdditions +import EstimatedPressureDependency +import SharedModels +import Styleguide +import SwiftUI +import TCAExtras + + +@Reducer +public struct FlaggedMeasurementsList { + + @Reducer(state: .equatable) + public enum Destination { + case estimationForm(EstimationForm) + } + + @ObservableState + public struct State: Equatable { + + @Presents public var destination: Destination.State? + @Shared var sharedSettings: SharedSettings + public var estimatedMeasurements: IdentifiedArrayOf + + init( + destination: Destination.State? = nil, + sharedSettings: Shared, + estimatedMeasurements: IdentifiedArrayOf = [] + ) { + self.destination = destination + self._sharedSettings = sharedSettings + self.estimatedMeasurements = estimatedMeasurements + } + + public struct FlaggedMeasurementContainer: Equatable, Identifiable { + public let id: UUID + public var flaggedMeasurement: FlaggedEquipmentMeasurement + public var name: String + + public init( + id: UUID, + name: String, + flaggedMeasurement: FlaggedEquipmentMeasurement + ) { + self.id = id + self.name = name + self.flaggedMeasurement = flaggedMeasurement + } + } + } + + public enum Action: ViewAction, ReceiveAction { + + case destination(PresentationAction) + case receive(TaskResult) + case view(View) + + @CasePathable + public enum ReceiveAction { + case existingFlaggedMeasurement(FlaggedEquipmentMeasurement) + case estimatedFlaggedMeasurement(name: String, measurement: FlaggedEquipmentMeasurement) + } + + @CasePathable + public enum View { + case addButtonTapped + case destination(DestinationAction) + case editButtonTapped(id: State.FlaggedMeasurementContainer.ID) + case onAppear + + @CasePathable + public enum DestinationAction { + case cancelButtonTapped + case doneButtonTapped + } + } + } + + @Dependency(\.estimatedPressuresClient) var estimatedPressuresClient + @Dependency(\.logger["\(Self.self)"]) var logger + @Dependency(\.uuid) var uuid + + public var body: some Reducer { + ReceiveReducer { state, action in + switch action { + case let .existingFlaggedMeasurement(measurement): + state.sharedSettings.flaggedEquipmentMeasurement = measurement + return .none + + case let .estimatedFlaggedMeasurement(name: name, measurement: measurement): + state.estimatedMeasurements.append( + .init( + id: uuid(), + name: name, + flaggedMeasurement: measurement + ) + ) + return .none + } + } + .onFailure(.log(logger: logger)) + + Reduce { state, action in + switch action { + + case .destination: + return .none + + case .receive: + return .none + + case let .view(action): + switch action { + + case .addButtonTapped: + state.destination = .estimationForm(.init( + coolingCapacity: state.sharedSettings.coolingCapacity + )) + return .none + + case let .destination(action): + switch action { + case .cancelButtonTapped: + state.destination = nil + return .none + + case .doneButtonTapped: + guard case let .estimationForm(form) = state.destination else { + return .fail( + """ + Received estimation form done button tapped action, but form + was not presented. + + This is considered an application logic error. + """, + logger: logger + ) + } + state.destination = nil + return handleEstimationForm(form: form, state: state) + } + + case .editButtonTapped(id: _): + return .none + + case .onAppear: + guard let equipmentMeasurement = state.sharedSettings.equipmentMeasurement, + let budgets = state.sharedSettings.budgets + else { + return .none + } + state.sharedSettings.flaggedEquipmentMeasurement = .init( + budgets: budgets, + measurement: equipmentMeasurement, + ratedPressures: state.sharedSettings.ratedStaticPressures, + tons: state.sharedSettings.coolingCapacity + ) + return .none + } + } + } + .ifLet(\.$destination, action: \.destination) + } + + private func handleEstimationForm(form: EstimationForm.State, state: State) -> Effect { + guard let equipmentMeasurement = state.sharedSettings.equipmentMeasurement, + let budgets = state.sharedSettings.budgets + else { + return .fail( + """ + Received estimation form done button tapped action, original + equipment measurement or budgets are not set on the shared state. + + This is considered an application logic error. + """, + logger: logger + ) + } + + return .receive(action: \.receive) { [ratedStaticPressures = state.sharedSettings.ratedStaticPressures] in + + let filterPressureDrop = form.filterPressureDrop != nil + ? Positive(wrappedValue: form.filterPressureDrop!) + : nil + + let measurement = try await estimatedPressuresClient.estimatedPressure( + equipmentMeasurement: equipmentMeasurement, + airflow: form.airflow, + filterPressureDrop: filterPressureDrop + ) + + let flaggedMeasurement = FlaggedEquipmentMeasurement( + budgets: budgets, + measurement: measurement, + ratedPressures: ratedStaticPressures, + tons: form.coolingCapacity + ) + + return .estimatedFlaggedMeasurement(name: form.name, measurement: flaggedMeasurement) + } + + } + +} + +@ViewAction(for: FlaggedMeasurementsList.self) +public struct FlaggedMeasurementListView: View { + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + List { + if let existingMeasurement = store.sharedSettings.flaggedEquipmentMeasurement { + Section { + FlaggedEquipmentMeasurementView(existingMeasurement) + } header: { + HStack { + Text("Existing Measurements") + Spacer() +// Button("Edit") { } + } + } + } + ForEach(store.estimatedMeasurements) { measurement in + Section { + FlaggedEquipmentMeasurementView(measurement.flaggedMeasurement) + } header: { + HStack { + Text(measurement.name) + Spacer() + Button("Edit") { send(.editButtonTapped(id: measurement.id)) } + } + } + } + } + .toolbar { + Button { send(.addButtonTapped) } label: { + Label("Add", systemImage: "plus") + } + .sheet( + item: $store.scope( + state: \.destination?.estimationForm, + action: \.destination.estimationForm + ) + ) { store in + NavigationStack { + EstimationFormView(store: store) + .navigationTitle("Estimation") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button{ send(.destination(.cancelButtonTapped)) } label: { + Text("Cancel") + .foregroundStyle(Color.red) + } + } + ToolbarItem(placement: .confirmationAction) { + DoneButton { send(.destination(.doneButtonTapped)) } + .disabled(!store.isValid) + } + } + } + } + } + .onAppear { send(.onAppear) } + } + } + +#if DEBUG +private let sharedSettings = SharedSettings( + budgets: .init(equipmentType: .airHandler, fanType: .constantSpeed), + equipmentMeasurement: .mock(type: .airHandler), + flaggedEquipmentMeasurement: nil +) + +private let flaggedMeasurements = IdentifiedArrayOf( + uniqueElements: [ + .init( + id: UUID(0), + name: "Existing", + flaggedMeasurement: .init( + budgets: sharedSettings.budgets!, + measurement: sharedSettings.equipmentMeasurement!, + ratedPressures: sharedSettings.ratedStaticPressures, + tons: sharedSettings.coolingCapacity + ) + ), + ] +) + + +#Preview { + NavigationStack { + FlaggedMeasurementListView( + store: Store( + initialState: FlaggedMeasurementsList.State( + sharedSettings: Shared(sharedSettings) + ) + ) { + FlaggedMeasurementsList() + } + ) + } +} +#endif diff --git a/Sources/PressureEstimationsFeature/PressureEstimations.swift b/Sources/PressureEstimationsFeature/PressureEstimations.swift new file mode 100644 index 0000000..83daaf0 --- /dev/null +++ b/Sources/PressureEstimationsFeature/PressureEstimations.swift @@ -0,0 +1,83 @@ +import ComposableArchitecture +import SharedModels +import Styleguide +import SwiftUI +import TCAExtras + +@Reducer +public struct PressureEstimationsFeature { + + public init() { } + + @Reducer(state: .equatable) + public enum Destination { + case equipmentMeasurements(EquipmentMeasurementForm) + } + + @ObservableState + public struct State: Equatable { + @Presents public var destination: Destination.State? + public var equipmentSettings: EquipmentSettingsForm.State + public var equipmentMeasurements: EquipmentMeasurementForm.State? + + public init( + destination: Destination.State? = nil, + equipmentSettings: EquipmentSettingsForm.State = .init(), + equipmentMeasurements: EquipmentMeasurementForm.State? = nil + ) { + self.destination = destination + self.equipmentSettings = equipmentSettings + self.equipmentMeasurements = equipmentMeasurements + } + } + + public enum Action: ViewAction { + case destination(PresentationAction) + case equipmentSettings(EquipmentSettingsForm.Action) + case view(View) + + @CasePathable + public enum View { + case nextButtonTapped + } + } + + public var body: some Reducer { + Scope(state: \.equipmentSettings, action: \.equipmentSettings) { + EquipmentSettingsForm() + } + Reduce { state, action in + switch action { + case .destination: + return .none + + case .equipmentSettings: + return .none + + case let .view(action): + switch action { + case .nextButtonTapped: + return .none + } + } + } + .ifLet(\.$destination, action: \.destination) + } + +} + +@ViewAction(for: PressureEstimationsFeature.self) +public struct PressureEstimationsView: View { + + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + EquipmentSettingsFormView( + store: store.scope(state: \.equipmentSettings, action: \.equipmentSettings) + ) + } +} diff --git a/Sources/PressureEstimationsFeature/SharedSettings.swift b/Sources/PressureEstimationsFeature/SharedSettings.swift new file mode 100644 index 0000000..95ab0ba --- /dev/null +++ b/Sources/PressureEstimationsFeature/SharedSettings.swift @@ -0,0 +1,37 @@ +import ComposableArchitecture +import SharedModels + + +struct SharedSettings: Equatable { + var budgets: BudgetedPercentEnvelope? + var coolingCapacity: CoolingCapacity + var equipmentMeasurement: EquipmentMeasurement? + var fanType: FanType + var flaggedEquipmentMeasurement: FlaggedEquipmentMeasurement? + var heatingCapacity: Double? + var ratedStaticPressures: RatedStaticPressures + + init( + budgets: BudgetedPercentEnvelope? = nil, + coolingCapacity: CoolingCapacity = .default, + equipmentMeasurement: EquipmentMeasurement? = nil, + fanType: FanType = .constantSpeed, + flaggedEquipmentMeasurement: FlaggedEquipmentMeasurement? = nil, + heatingCapacity: Double? = nil, + ratedStaticPressures: RatedStaticPressures = .init() + ) { + self.budgets = budgets + self.coolingCapacity = coolingCapacity + self.equipmentMeasurement = equipmentMeasurement + self.fanType = fanType + self.flaggedEquipmentMeasurement = flaggedEquipmentMeasurement + self.heatingCapacity = heatingCapacity + self.ratedStaticPressures = ratedStaticPressures + } +} + +extension PersistenceReaderKey where Self == InMemoryKey { + static var sharedSettings: Self { + .inMemory("sharedSettings") + } +} diff --git a/Sources/PressureEstimationsFeature/StaticPressureView.swift b/Sources/PressureEstimationsFeature/StaticPressureView.swift deleted file mode 100644 index ca1711b..0000000 --- a/Sources/PressureEstimationsFeature/StaticPressureView.swift +++ /dev/null @@ -1,4 +0,0 @@ -import ComposableArchitecture -import SharedModels -import SwiftUI -import TCAExtras diff --git a/Sources/Styleguide/Pickers/CoolingCapacityPicker.swift b/Sources/Styleguide/Pickers/CoolingCapacityPicker.swift new file mode 100644 index 0000000..466e943 --- /dev/null +++ b/Sources/Styleguide/Pickers/CoolingCapacityPicker.swift @@ -0,0 +1,19 @@ +import SharedModels +import SwiftUI + +public struct CoolingCapacityPicker: View { + @Binding var selection: CoolingCapacity + + public init(selection: Binding) { + self._selection = selection + } + + public var body: some View { + Picker("Cooling Capacity", selection: $selection) { + ForEach(CoolingCapacity.allCases) { + Text($0.description) + .tag($0) + } + } + } +} diff --git a/Sources/Styleguide/EquipmentTypePicker.swift b/Sources/Styleguide/Pickers/EquipmentTypePicker.swift similarity index 100% rename from Sources/Styleguide/EquipmentTypePicker.swift rename to Sources/Styleguide/Pickers/EquipmentTypePicker.swift diff --git a/Sources/Styleguide/FanTypePicker.swift b/Sources/Styleguide/Pickers/FanTypePicker.swift similarity index 100% rename from Sources/Styleguide/FanTypePicker.swift rename to Sources/Styleguide/Pickers/FanTypePicker.swift diff --git a/Sources/Styleguide/Styles/FlaggedViewStyle.swift b/Sources/Styleguide/Styles/FlaggedViewStyle.swift index e27da3b..3118039 100644 --- a/Sources/Styleguide/Styles/FlaggedViewStyle.swift +++ b/Sources/Styleguide/Styles/FlaggedViewStyle.swift @@ -172,7 +172,9 @@ extension Flagged { .foregroundStyle(flagColor) } - public var messageView: some View { flaggedMessageView(flagged: self) } + public var messageView: some View { + flaggedMessageView(flagged: self) + } } @ViewBuilder