448 lines
14 KiB
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
|