Files
swift-estimated-pressures-core/Sources/PressureEstimationsFeature/FlaggedMeasurementsList.swift

448 lines
14 KiB
Swift

import ComposableArchitecture
import DependenciesAdditions
import EstimatedPressureDependency
import FlaggedViews
import SharedModels
import Styleguide
import SwiftUI
import TCAExtras
@Reducer
public struct FlaggedMeasurementsList: Sendable {
@Reducer(state: .equatable)
public enum Destination {
case estimationForm(EstimationForm)
}
@ObservableState
@dynamicMemberLookup
public struct State: Equatable, Sendable {
@Presents public var destination: Destination.State?
@Shared var sharedSettings: SharedPressureEstimationState
public init(
destination: Destination.State? = nil,
sharedSettings: Shared<SharedPressureEstimationState>
) {
self.destination = destination
self._sharedSettings = sharedSettings
}
var ignoreIfZeroFields: [EquipmentMeasurement.FlaggedMeasurement.FieldKey] {
guard let measurement = sharedSettings.equipmentMeasurement else { return [] }
guard measurement.equipmentType == .airHandler else { return [] }
return [.coilDrop, .filterDrop]
}
public subscript<T>(dynamicMember keyPath: WritableKeyPath<SharedPressureEstimationState, T>) -> T {
get { sharedSettings[keyPath: keyPath] }
set { sharedSettings[keyPath: keyPath] = newValue }
}
}
public enum Action: ViewAction, ReceiveAction {
case destination(PresentationAction<Destination.Action>)
case receive(TaskResult<ReceiveAction>)
case view(View)
@CasePathable
public enum ReceiveAction: Sendable {
case existingFlaggedMeasurement(EquipmentMeasurement.FlaggedMeasurement)
case estimatedFlaggedMeasurement(SharedPressureEstimationState.FlaggedEstimationContainer)
}
@CasePathable
public enum View {
case addButtonTapped
case deleteEstimationButtonTapped(id: SharedPressureEstimationState.FlaggedEstimationContainer.ID)
case destination(DestinationAction)
case editButtonTapped(id: SharedPressureEstimationState.FlaggedEstimationContainer.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<State, Action> {
ReceiveReducer { state, action in
switch action {
case let .existingFlaggedMeasurement(measurement):
state.sharedSettings.flaggedEquipmentMeasurement = measurement
return .none
case let .estimatedFlaggedMeasurement(container):
state.flaggedEstimations[id: container.id] = container
return .none
}
}
.onFailure(.log(logger: logger))
Reduce<State, Action> { 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(
existingMeasurement: state.$sharedSettings.equipmentMeasurement,
coolingCapacity: state.sharedSettings.equipmentMetadata.coolingCapacity
))
return .none
case let .deleteEstimationButtonTapped(id: id):
state.sharedSettings.flaggedEstimations.remove(id: id)
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 .run { [state] send in
do {
guard let flaggedEstimation = try await self._handleEstimationForm(form: form, state: state)
else { return }
await send(.receive(.success(.estimatedFlaggedMeasurement(flaggedEstimation))))
} catch {
await send(.receive(.failure(error)))
}
}
}
case .editButtonTapped(id: _):
return .none
case .onAppear:
guard state.sharedSettings.flaggedEquipmentMeasurement == nil
else { return .none }
guard let equipmentMeasurement = state.sharedSettings.equipmentMeasurement
else {
return .fail(
"""
Equipment measurement is not set, skipping generating flagged measurement for existing
equipment.
This is considered an application logic error.
""",
logger: logger
)
}
if state.sharedSettings.budgets == nil {
state.sharedSettings.budgets = .init(
equipmentType: state.sharedSettings.equipmentMeasurement!.equipmentType,
fanType: state.sharedSettings.equipmentMetadata.fanType
)
}
state.sharedSettings.flaggedEquipmentMeasurement = .init(
budgets: state.sharedSettings.budgets!,
measurement: equipmentMeasurement,
ratedPressures: state.sharedSettings.equipmentMetadata.ratedStaticPressures,
tons: state.sharedSettings.equipmentMetadata.coolingCapacity
)
return .none
}
}
}
.ifLet(\.$destination, action: \.destination)
}
enum EstimationFormFailure: Error {
case invalidState
}
func _handleEstimationForm(form: EstimationForm.State, state: State) async throws -> SharedPressureEstimationState.FlaggedEstimationContainer? {
guard let equipmentMeasurement = state.sharedSettings.equipmentMeasurement,
let budgets = state.sharedSettings.budgets
else {
throw EstimationFormFailure.invalidState
}
guard let estimationState = ensureHasChanges(
formState: form,
flaggedEstimations: state.sharedSettings.flaggedEstimations
) else {
logger.debug("No changes found, not generating a new flagged estimation measurement.")
return nil
}
let flaggedMeasurement = try await flaggedMeasurement(
airflow: estimationState.cfm.airflow,
budgets: budgets,
equipmentMeasurement: equipmentMeasurement,
filterPressureDrop: parseFilterPressureDrop(formState: form, sharedSettings: state.sharedSettings),
ratedStaticPresures: state.sharedSettings.ratedStaticPressures,
tons: form.coolingCapacity
)
return SharedPressureEstimationState.FlaggedEstimationContainer(
id: form.id ?? uuid(),
estimationState: estimationState,
flaggedMeasurement: flaggedMeasurement
)
}
#warning("This is making it hard to test, perhaps don't return an effect here.")
private func handleEstimationForm(form: EstimationForm.State, state: State) -> Effect<Action> {
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
)
}
guard let estimationState = ensureHasChanges(
formState: form,
flaggedEstimations: state.sharedSettings.flaggedEstimations
) else {
logger.debug("No changes found, not generating a new flagged estimation measurement.")
return .none
}
return .receive(action: \.receive) { [sharedSettings = state.sharedSettings] in
let flaggedMeasurement = try await flaggedMeasurement(
airflow: estimationState.cfm.airflow,
budgets: budgets,
equipmentMeasurement: equipmentMeasurement,
filterPressureDrop: parseFilterPressureDrop(formState: form, sharedSettings: sharedSettings),
ratedStaticPresures: sharedSettings.ratedStaticPressures,
tons: form.coolingCapacity
)
return .estimatedFlaggedMeasurement(.init(
id: form.id ?? uuid(),
estimationState: estimationState,
flaggedMeasurement: flaggedMeasurement
))
}
}
func ensureHasChanges(
formState: EstimationForm.State,
flaggedEstimations: IdentifiedArrayOf<SharedPressureEstimationState.FlaggedEstimationContainer>
) -> SharedPressureEstimationState.FlaggedEstimationContainer.EstimationState? {
let estimationState = formState.estimationState
guard let id = formState.id,
let existingState = flaggedEstimations[id: id]?.estimationState
else { return estimationState }
if existingState.hasChanges(estimationState) {
return estimationState
}
return nil
}
private func parseFilterPressureDrop(
formState: EstimationForm.State,
sharedSettings: SharedPressureEstimationState
) -> Positive<Double>? {
guard sharedSettings.flaggedEquipmentMeasurement?.filterPressureDrop.wrappedValue != 0,
let filterPressureDrop = formState.filterPressureDrop
else {
return nil
}
return Positive(filterPressureDrop)
}
private func flaggedMeasurement(
airflow: Double,
budgets: BudgetedPercentEnvelope,
equipmentMeasurement: EquipmentMeasurement,
filterPressureDrop: Positive<Double>?,
ratedStaticPresures: RatedStaticPressures,
tons: EquipmentMetadata.CoolingCapacity
) async throws -> EquipmentMeasurement.FlaggedMeasurement {
let measurement = try await estimatedPressuresClient.estimatedPressure(
equipmentMeasurement: equipmentMeasurement,
airflow: airflow,
filterPressureDrop: filterPressureDrop
)
return .init(
budgets: budgets,
measurement: measurement,
ratedPressures: ratedStaticPresures,
tons: tons
)
}
}
fileprivate extension EstimationForm.State {
var estimationState: SharedPressureEstimationState.FlaggedEstimationContainer.EstimationState {
.init(state: self)
}
}
@ViewAction(for: FlaggedMeasurementsList.self)
public struct FlaggedMeasurementListView: View {
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Bindable public var store: StoreOf<FlaggedMeasurementsList>
public init(store: StoreOf<FlaggedMeasurementsList>) {
self.store = store
}
public var body: some View {
List {
if let existingMeasurement = store.sharedSettings.flaggedEquipmentMeasurement {
Section {
FlaggedEquipmentMeasurementView(
existingMeasurement,
ignoreIfZero: store.ignoreIfZeroFields
)
} header: {
HStack {
Text("Existing Measurements")
}
}
}
ForEach(store.flaggedEstimations) { measurement in
Section {
FlaggedEquipmentMeasurementView(
measurement.flaggedMeasurement,
ignoreIfZero: store.ignoreIfZeroFields
)
} header: {
HStack {
Text(measurement.displayName)
Spacer()
Menu {
EditButton { send(.editButtonTapped(id: measurement.id)) }
DeleteButton { send(.deleteEstimationButtonTapped(id: measurement.id)) }
} label: {
Label("Actions", systemImage: "list.dash")
} primaryAction: {
send(.editButtonTapped(id: measurement.id))
}
.labelStyle(.iconOnly)
.menuStyle(ButtonMenuStyle())
}
}
}
}
.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) }
.flaggedMessageViewStyle(
.automatic(horizontalSizeClass: horizontalSizeClass)
)
.flaggedStatusLabelStyle(.textLabel)
.flaggedMessageLabelStyle(.font(.caption))
}
}
#if DEBUG
private let budgets = BudgetedPercentEnvelope(equipmentType: .airHandler, fanType: .constantSpeed)
private let flaggedMeasurements = IdentifiedArrayOf<SharedPressureEstimationState.FlaggedEstimationContainer>(
uniqueElements: [
.init(
id: UUID(0),
estimationState: .init(
cfm: .cfmPerTon(400, .three),
filterPressureDrop: 0.1
),
flaggedMeasurement: .init(
budgets: budgets,
measurement: .mock(type: .airHandler),
ratedPressures: .init(),
tons: .default
)
),
]
)
#Preview {
NavigationStack {
FlaggedMeasurementListView(
store: Store(
initialState: FlaggedMeasurementsList.State(
sharedSettings: Shared(
SharedPressureEstimationState(
budgets: budgets,
equipmentMeasurement: .mock(type: .airHandler),
flaggedEquipmentMeasurement: nil,
flaggedEstimations: flaggedMeasurements
)
)
)
) {
FlaggedMeasurementsList()
}
)
}
}
#endif