diff --git a/Package.swift b/Package.swift index f6a5332..63eaedc 100644 --- a/Package.swift +++ b/Package.swift @@ -6,15 +6,14 @@ let package = Package( name: "swift-estimated-pressures-core", platforms: [ .iOS(.v16), - .macOS(.v11), + .macOS(.v13), .tvOS(.v14), .watchOS(.v7), ], products: [ - .library(name: "AirflowAtPressureFeature", targets: ["AirflowAtPressureFeature"]), + .library(name: "CalculateAtFeature", targets: ["CalculateAtFeature"]), .library(name: "EstimatedPressureDependency", targets: ["EstimatedPressureDependency"]), .library(name: "SharedModels", targets: ["SharedModels"]), - .library(name: "TCAHelpers", targets: ["TCAHelpers"]), ], dependencies: [ .package( @@ -29,17 +28,21 @@ let package = Package( url: "https://github.com/tgrapperon/swift-dependencies-additions.git", from: "1.0.1" ), + .package( + url: "https://github.com/m-housh/swift-tca-extras.git", + from: "0.1.0" + ), ], targets: [ .target( - name: "AirflowAtPressureFeature", + name: "CalculateAtFeature", dependencies: [ "EstimatedPressureDependency", "SharedModels", "Styleguide", - "TCAHelpers", .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - .product(name: "DependenciesAdditions", package: "swift-dependencies-additions") + .product(name: "DependenciesAdditions", package: "swift-dependencies-additions"), + .product(name: "TCAExtras", package: "swift-tca-extras") ] ), .target( @@ -52,12 +55,6 @@ let package = Package( ), .target(name: "SharedModels"), .target(name: "Styleguide"), - .target( - name: "TCAHelpers", - dependencies: [ - .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), - ] - ), .testTarget( name: "EstimatedPressureTests", dependencies: [ diff --git a/Sources/AirflowAtPressureFeature/AirflowAtPressure.swift b/Sources/AirflowAtPressureFeature/AirflowAtPressure.swift deleted file mode 100644 index 1a81368..0000000 --- a/Sources/AirflowAtPressureFeature/AirflowAtPressure.swift +++ /dev/null @@ -1,189 +0,0 @@ -import ComposableArchitecture -import DependenciesAdditions -import EstimatedPressureDependency -import SharedModels -import Styleguide -import SwiftUI -import TCAHelpers - -@Reducer -public struct AirflowAtPressureFeature { - - @ObservableState - public struct State: Equatable { - public var isPresentingInfoView: Bool = false - public var existingAirflow: Double? - public var existingPressure: Double? - public var targetPressure: Double? - public var calculatedAirflow: Positive? - - public init( - existingAirflow: Double? = nil, - existingPressure: Double? = nil, - targetPressure: Double? = 0.5, - calculatedAirflow: Positive? = nil - ) { - self.existingAirflow = existingAirflow - self.existingPressure = existingPressure - self.targetPressure = targetPressure - self.calculatedAirflow = calculatedAirflow - } - - public var isValid: Bool { - existingAirflow != nil - && existingPressure != nil - && targetPressure != nil - } - } - - public enum Action: BindableAction, ReceiveAction, ViewAction { - case binding(BindingAction) - case receive(TaskResult) - case view(View) - - @CasePathable - public enum ReceiveAction { - case calculatedAirflow(Positive?) - } - - @CasePathable - public enum View { - case dismissInfoViewButtonTapped - case infoButtonTapped - 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: - return .none - - case let .receive(.failure(error)): - return .fail(error: error, logger: logger) - - case let .receive(.success(action)): - switch action { - case let .calculatedAirflow(airflow): - state.calculatedAirflow = airflow - return .none - } - - case let .view(action): - switch action { - case .dismissInfoViewButtonTapped: - state.isPresentingInfoView = false - return .none - - case .infoButtonTapped: - state.isPresentingInfoView = true - return .none - - case .resetButtonTapped: - state = .init() - return .none - - case .submit: - guard state.isValid else { return .none } - return .receive(\.calculatedAirflow) { [state] in - try await estimatedPressuresClient.estimatedAirflow(.init( - existingAirflow: state.existingAirflow ?? 0, - existingPressure: state.existingPressure ?? 0, - targetPressure: state.targetPressure ?? 0 - )) - } - } - } - } - } -} - -@ViewAction(for: AirflowAtPressureFeature.self) -public struct AirflowAtPressureView: View { - - @Perception.Bindable - public var store: StoreOf - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - Form { - Section { - TextField( - "Existing Airflow", - value: $store.existingAirflow, - format: .number, - prompt: Text("Airflow") - ) - .onSubmit { send(.submit) } - .numberPad() - - TextField( - "Existing Pressure", - value: $store.existingPressure, - format: .number, - prompt: Text("Existing Pressure") - ) - .onSubmit { send(.submit) } - .decimalPad() - - TextField( - "Target Pressure", - value: $store.targetPressure, - format: .number, - prompt: Text("Target Pressure") - ) - .onSubmit { send(.submit) } - .decimalPad() - - } header: { - HStack { - Text("Inputs") - Spacer() - InfoButton { send(.infoButtonTapped) } - } - .labelStyle(.iconOnly) - } footer: { - HStack { - Spacer() - Button("Reset") { send(.resetButtonTapped) } - Spacer() - } - .padding(.top) - .buttonStyle(.borderedProminent) - } - - Section { - if let airflow = store.calculatedAirflow { - Text(airflow.positiveValue, format: .number.precision(.fractionLength(0))) - } - } header: { - Text("Calculated Airflow") - } - } - .sheet(isPresented: $store.isPresentingInfoView) { - NavigationStack { - AirflowAtPressureInfoView() - .toolbar { - Button("Done") { send(.dismissInfoViewButtonTapped) } - } - } - } - } -} - -#Preview { - AirflowAtPressureView( - store: Store(initialState: AirflowAtPressureFeature.State()) { - AirflowAtPressureFeature() - } - ) -} diff --git a/Sources/CalculateAtFeature/CalculateAtView.swift b/Sources/CalculateAtFeature/CalculateAtView.swift new file mode 100644 index 0000000..1420221 --- /dev/null +++ b/Sources/CalculateAtFeature/CalculateAtView.swift @@ -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(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 { + 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 { + .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 receive(TaskResult) + case view(View) + + @CasePathable + public enum ReceiveAction { + case calculatedValue(Positive?) + } + + @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 { + BindingReducer() + Reduce { 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 { + 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 + + 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) } + } + .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 +} diff --git a/Sources/AirflowAtPressureFeature/InfoView.swift b/Sources/Styleguide/InfoView.swift similarity index 58% rename from Sources/AirflowAtPressureFeature/InfoView.swift rename to Sources/Styleguide/InfoView.swift index 299da45..3e2865d 100644 --- a/Sources/AirflowAtPressureFeature/InfoView.swift +++ b/Sources/Styleguide/InfoView.swift @@ -1,20 +1,20 @@ import SwiftUI -struct AirflowAtPressureInfoView: View { +public struct InfoView: View { + let headingText: String + let bodyText: String - static let heading = """ - Calculate the airflow at the target pressure from the existing airflow and existing pressure. - """ - static let 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). - """ + public init(heading headingText: String, body bodyText: String) { + self.headingText = headingText + self.bodyText = bodyText + } - var body: some View { + public var body: some View { VStack(spacing: 40) { ZStack { - Text(Self.heading) + Text(headingText) .font(.headline) .bold() .foregroundStyle(Color.white) @@ -27,7 +27,7 @@ struct AirflowAtPressureInfoView: View { ZStack { - Text(Self.body) + Text(bodyText) .font(.callout) .padding() } @@ -42,3 +42,7 @@ struct AirflowAtPressureInfoView: View { .padding(.horizontal) } } + +#Preview { + InfoView(heading: "Generic Info View", body: "Explain what this view does here...") +} diff --git a/Sources/TCAHelpers/Effect+fail.swift b/Sources/TCAHelpers/Effect+fail.swift deleted file mode 100644 index 9a9ef10..0000000 --- a/Sources/TCAHelpers/Effect+fail.swift +++ /dev/null @@ -1,25 +0,0 @@ -import ComposableArchitecture -import OSLog - -extension Effect { - public static func fail( - prefix: String = "Failed error:", - error: Error, - logger: Logger? = nil - ) -> Self { - let message = "\(prefix) \(error)" - XCTFail("\(message)") - logger?.error("\(message)") - return .none - } - - public static func fail( - _ message: String, - logger: Logger? = nil - ) -> Self { - XCTFail("\(message)") - logger?.error("\(message)") - return .none - } -} - diff --git a/Sources/TCAHelpers/Effect+receive.swift b/Sources/TCAHelpers/Effect+receive.swift deleted file mode 100644 index d7de3bf..0000000 --- a/Sources/TCAHelpers/Effect+receive.swift +++ /dev/null @@ -1,40 +0,0 @@ -import ComposableArchitecture - -public protocol ReceiveAction: CasePathable { - associatedtype ReceiveAction: CasePathable - static func receive(_ result: TaskResult) -> Self -} - -extension Effect where Action: ReceiveAction { - - public static func receive( - _ operation: @escaping () async throws -> Action.ReceiveAction - ) -> Self { - .run { send in - await send(.receive( - TaskResult { try await operation() } - )) - } - } - - public static func receive( - _ operation: @escaping () async throws -> T, - transform: @escaping (T) -> Action.ReceiveAction - ) -> Self { - .run { send in - await send(.receive( - TaskResult { try await operation() } - .map(transform) - )) - } - } - - public static func receive( - _ toReceiveAction: CaseKeyPath, - _ operation: @escaping () async throws -> T - ) -> Self { - return .receive(operation) { - AnyCasePath(toReceiveAction).embed($0) - } - } -}