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 ) { 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(dynamicMember keyPath: WritableKeyPath) -> T { get { sharedSettings[keyPath: keyPath] } set { sharedSettings[keyPath: keyPath] = newValue } } } public enum Action: ViewAction, ReceiveAction { case destination(PresentationAction) case receive(TaskResult) 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 { 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 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.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? { 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?, 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 public init(store: StoreOf) { 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( 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