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 { @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 handleEstimationForm(form: form, state: state) } case .editButtonTapped(id: _): return .none case .onAppear: guard let equipmentMeasurement = state.sharedSettings.equipmentMeasurement else { return .none } 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) } private func handleEstimationForm(form: EstimationForm.State, state: State) -> Effect { 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.") print("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.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: 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