Feat: Consilidates calculated at views.

This commit is contained in:
2024-06-03 12:38:42 -04:00
parent b7edf38e72
commit 4311ae9e22
6 changed files with 424 additions and 276 deletions

View File

@@ -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: [

View File

@@ -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<Double>?
public init(
existingAirflow: Double? = nil,
existingPressure: Double? = nil,
targetPressure: Double? = 0.5,
calculatedAirflow: Positive<Double>? = 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<State>)
case receive(TaskResult<ReceiveAction>)
case view(View)
@CasePathable
public enum ReceiveAction {
case calculatedAirflow(Positive<Double>?)
}
@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<State, Action> {
BindingReducer()
Reduce<State, Action> { 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<AirflowAtPressureFeature>
public init(store: StoreOf<AirflowAtPressureFeature>) {
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()
}
)
}

View 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
}

View File

@@ -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...")
}

View File

@@ -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
}
}

View File

@@ -1,40 +0,0 @@
import ComposableArchitecture
public protocol ReceiveAction<ReceiveAction>: CasePathable {
associatedtype ReceiveAction: CasePathable
static func receive(_ result: TaskResult<ReceiveAction>) -> 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<T>(
_ 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<T>(
_ toReceiveAction: CaseKeyPath<Action.ReceiveAction, T>,
_ operation: @escaping () async throws -> T
) -> Self {
return .receive(operation) {
AnyCasePath(toReceiveAction).embed($0)
}
}
}