Files

405 lines
12 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
)
}
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: Positive(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