diff --git a/Package.swift b/Package.swift index 982345c..5d7060e 100644 --- a/Package.swift +++ b/Package.swift @@ -5,16 +5,17 @@ import PackageDescription let package = Package( name: "swift-estimated-pressures-core", platforms: [ - .iOS(.v16), - .macOS(.v13), + .iOS(.v17), + .macOS(.v14), .tvOS(.v14), .watchOS(.v7), ], products: [ .library(name: "CalculateAtFeature", targets: ["CalculateAtFeature"]), .library(name: "EstimatedPressureDependency", targets: ["EstimatedPressureDependency"]), + .library(name: "PressureEstimationsFeature", targets: ["PressureEstimationsFeature"]), .library(name: "SharedModels", targets: ["SharedModels"]), - .library(name: "StaticPressureFeature", targets: ["StaticPressureFeature"]), + .library(name: "Styleguide", targets: ["Styleguide"]), ], dependencies: [ .package( @@ -55,7 +56,12 @@ let package = Package( ] ), .target(name: "SharedModels"), - .target(name: "Styleguide"), + .target( + name: "Styleguide", + dependencies: [ + "SharedModels" + ] + ), .testTarget( name: "EstimatedPressureTests", dependencies: [ @@ -64,7 +70,7 @@ let package = Package( ] ), .target( - name: "StaticPressureFeature", + name: "PressureEstimationsFeature", dependencies: [ "EstimatedPressureDependency", "SharedModels", diff --git a/Sources/CalculateAtFeature/CalculateAtView.swift b/Sources/CalculateAtFeature/CalculateAtView.swift index 1420221..2662cec 100644 --- a/Sources/CalculateAtFeature/CalculateAtView.swift +++ b/Sources/CalculateAtFeature/CalculateAtView.swift @@ -321,6 +321,8 @@ public struct CalculateAtView: View { Text("Inputs") Spacer() InfoButton { send(.infoButtonTapped) } + .foregroundStyle(Color.accentColor) + .buttonStyle(.plain) } .labelStyle(.iconOnly) } footer: { @@ -338,7 +340,7 @@ public struct CalculateAtView: View { Spacer() } .bind($focus, to: $store.focus) - .onAppear { send(.onAppear) } + .onAppear { focus = .existingAirflow } .sheet(isPresented: $store.isPresentingInfoView) { NavigationStack { InfoView(calculationType: store.calculationType) diff --git a/Sources/PressureEstimationsFeature/AirHandlerMeasurementForm.swift b/Sources/PressureEstimationsFeature/AirHandlerMeasurementForm.swift new file mode 100644 index 0000000..fc2a38a --- /dev/null +++ b/Sources/PressureEstimationsFeature/AirHandlerMeasurementForm.swift @@ -0,0 +1,242 @@ +import ComposableArchitecture +import DependenciesAdditions +import EstimatedPressureDependency +import SharedModels +import Styleguide +import SwiftUI +import TCAExtras + +@Reducer +public struct AirHandlerMeasurementForm { + + @ObservableState + public struct State: Equatable { + public var calculatedMeasurement: EquipmentMeasurement.AirHandler? + public var focusedField: Field? + public var measurement: EquipmentMeasurement.AirHandler + public var updatedAirflow: Double? + public var fanType: FanType + public var ratedStaticPressures = RatedStaticPressures() + + public init( + calculatedMeasurement: EquipmentMeasurement.AirHandler? = nil, + focusedField: Field? = nil, + measurement: EquipmentMeasurement.AirHandler = .init(), + updatedAirflow: Double? = nil, + fanType: FanType = .constantSpeed + ) { + self.calculatedMeasurement = calculatedMeasurement + self.focusedField = focusedField + self.measurement = measurement + self.updatedAirflow = updatedAirflow + self.fanType = fanType + } + + public var isValid: Bool { + return measurement.returnPlenumPressure != nil + && measurement.postFilterPressure != nil + && measurement.postCoilPressure != nil + && measurement.supplyPlenumPressure != nil + && measurement.airflow != nil + && updatedAirflow != nil + } + + public enum Field: String, Equatable, CaseIterable, FocusableField { + case returnPlenumPressure + case postFilterPressure + case postCoilPressure + case supplyPlenumPressure + case airflow + case updatedAirflow + } + } + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case receive(TaskResult) + case view(View) + + @CasePathable + public enum View { + case infoButtonTapped + case resetButtonTapped + case submitField + } + } + + @Dependency(\.estimatedPressuresClient) var estimatedPressuresClient + @Dependency(\.logger["\(Self.self)"]) var logger + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case let .receive(.success(calculatedMeasurement)): + state.calculatedMeasurement = calculatedMeasurement + return .none + + case .receive: + return .none + + case let .view(action): + switch action { + + case .infoButtonTapped: + #warning("Fix me.") + return .none + + case .resetButtonTapped: + state.measurement = .init() + state.updatedAirflow = nil + return .none + + case .submitField: + state.focusedField = state.focusedField?.next + guard state.isValid else { return .none } + return calculateEstimates(state: state) + + } + } + } + .onFailure(case: \.receive, .log(logger: logger)) + } + + private func calculateEstimates(state: State) -> Effect { + .receive(action: \.receive) { + let result = try await estimatedPressuresClient.estimatedPressure( + for: .airHandler(state.measurement), + at: state.updatedAirflow ?? 0 + ) + + guard case let .airHandler(airHandler) = result else { + return .none + } + return airHandler + } + } +} + +@ViewAction(for: AirHandlerMeasurementForm.self) +public struct AirHandlerMeasurementFormView: View { + @FocusState private var focusedField: AirHandlerMeasurementForm.State.Field? + @Bindable public var store: StoreOf + + public init( + store: StoreOf + ) { + self.store = store + self.focusedField = store.focusedField + } + + + public var body: some View { + Form { + Section { + textField( + "Return Plenum Pressure", + value: $store.measurement.returnPlenumPressure + ) + .focused($focusedField, equals: .returnPlenumPressure) + .onSubmit { send(.submitField) } + .decimalPad() + + textField( + "Post-Filter Pressure", + value: $store.measurement.postFilterPressure + ) + .focused($focusedField, equals: .postFilterPressure) + .onSubmit { send(.submitField) } + .decimalPad() + + textField( + "Post-Coil Pressure", + value: $store.measurement.postCoilPressure + ) + .focused($focusedField, equals: .postCoilPressure) + .onSubmit { send(.submitField) } + .decimalPad() + + textField( + "Supply Plenum Pressure", + value: $store.measurement.supplyPlenumPressure + ) + .focused($focusedField, equals: .supplyPlenumPressure) + .onSubmit { send(.submitField) } + .decimalPad() + + textField( + "Airflow", + value: $store.measurement.airflow, + fractionLength: 0 + ) + .focused($focusedField, equals: .airflow) + .onSubmit { send(.submitField) } + .numberPad() + + } header: { + HStack { + Text("System Measurements") + Spacer() + InfoButton { + send(.infoButtonTapped) + } + .font(.title3) + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + } + } + + Section { + FanTypePicker(selection: $store.fanType) + .pickerStyle(.segmented) + + textField( + "Updated Airflow", + value: $store.updatedAirflow, + fractionLength: 0 + ) + .focused($focusedField, equals: .updatedAirflow) + .onSubmit { send(.submitField) } + .numberPad() + + } header: { + Text("Estimate Settings") + } footer: { + HStack { + Spacer() + ResetButton { send(.resetButtonTapped) } + Spacer() + } + .padding(.top) + } + + if let calculatedMeasurement = store.calculatedMeasurement { + Section { + Text("Display calculated measurement here.") + } + } + + } + .bind($focusedField, to: $store.focusedField) + } + + private func textField( + _ title: LocalizedStringKey, + value: Binding, + fractionLength: Int = 2 + ) -> TextField { + .init(title, value: value, fractionLength: fractionLength, prompt: Text(title)) + } +} + +#Preview { + AirHandlerMeasurementFormView( + store: Store(initialState: AirHandlerMeasurementForm.State()) { + AirHandlerMeasurementForm() + } + ) +} diff --git a/Sources/PressureEstimationsFeature/EstimateSettingsForm.swift b/Sources/PressureEstimationsFeature/EstimateSettingsForm.swift new file mode 100644 index 0000000..15df16a --- /dev/null +++ b/Sources/PressureEstimationsFeature/EstimateSettingsForm.swift @@ -0,0 +1,236 @@ +import ComposableArchitecture +import SharedModels +import Styleguide +import SwiftUI + +@Reducer +public struct EstimateSettingsForm { + + @ObservableState + public struct State: Equatable { + + public var budgets: BudgetedPercentEnvelope + public let equipmentType: EquipmentType + public var fanType: FanType + public var focusedField: Field? = nil + public var updatedAirflow: Double? + + public init( + equipmentType: EquipmentType, + fanType: FanType = .constantSpeed, + updatedAirflow: Double? = nil + ) { + self.budgets = .init(equipmentType: equipmentType, fanType: fanType) + self.equipmentType = equipmentType + self.fanType = fanType + self.updatedAirflow = updatedAirflow + } + + public var isValid: Bool { + updatedAirflow != nil + && Int(budgets.total.rawValue) == 100 + } + + public enum Field: Hashable, CaseIterable, FocusableField { + case coilBudget + case filterBudget + case returnPlenumBudget + case supplyPlenumBudget + case updatedAirflow + } + } + + public enum Action: BindableAction, ViewAction { + case binding(BindingAction) + case view(View) + + @CasePathable + public enum View { + case infoButtonTapped(InfoButton) + case resetButtonTapped + case submitField + + @CasePathable + public enum InfoButton { + case budgets + case updatedAirflow + } + } + } + + public var body: some Reducer { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.fanType): + state.budgets = .init( + equipmentType: state.equipmentType, + fanType: state.fanType + ) + return .none + + case .binding: + return .none + + case let .view(action): + switch action { + + case .infoButtonTapped: + #warning("Fix me.") + return .none + + case .resetButtonTapped: + state.budgets = .init(equipmentType: state.equipmentType, fanType: state.fanType) + state.updatedAirflow = nil + return .none + + case .submitField: + state.focusedField = state.focusedField?.next + return .none + + } + } + } + } +} + +@ViewAction(for: EstimateSettingsForm.self) +public struct EstimateSettingsFormView: View { + + @FocusState private var focusedField: EstimateSettingsForm.State.Field? + @Bindable public var store: StoreOf + + public init(store: StoreOf) { + self.store = store + self.focusedField = store.focusedField + } + + public var body: some View { + Form { + Section { + EmptyView() + } header: { + TextLabel("Fan Type") + #if os(macOS) + .font(.title2) + #endif + } footer: { + FanTypePicker(selection: $store.fanType) + .pickerStyle(.segmented) + } + + Section { + Grid(alignment: .leading, horizontalSpacing: 40) { + if store.equipmentType == .furnaceAndCoil { + GridRow { + TextLabel("Coil") + percentField("Coil Budget", value: $store.budgets.coilBudget.fraction) + .focused($focusedField, equals: .coilBudget) + .onSubmit { send(.submitField) } + .numberPad() + } + } + + GridRow { + TextLabel("Filter") + percentField("Filter Budget", value: $store.budgets.filterBudget.fraction) + .focused($focusedField, equals: .filterBudget) + .onSubmit { send(.submitField) } + .numberPad() + } + + GridRow { + TextLabel("Return") + percentField("Return Plenum Budget", value: $store.budgets.returnPlenumBudget.fraction) + .focused($focusedField, equals: .returnPlenumBudget) + .onSubmit { send(.submitField) } + .numberPad() + } + + GridRow { + TextLabel("Supply") + percentField("Supply Plenum Budget", value: $store.budgets.supplyPlenumBudget.fraction) + .focused($focusedField, equals: .supplyPlenumBudget) + .onSubmit { send(.submitField) } + .numberPad() + } + } + .textLabelStyle(.boldSecondary) + .textFieldStyle(.roundedBorder) + + } header: { + VStack { + HStack { + Text("Budgets") + Spacer() + InfoButton { send(.infoButtonTapped(.budgets)) } + } + #if os(macOS) + .font(.title2) + .padding(.top, 20) + #endif + FlaggedView( + flagged: Flagged(wrappedValue: store.budgets.total.rawValue, .percent()) + ) + .flaggedViewStyle(BudgetFlagViewStyle()) + } + } + + Section { + + } header: { + HStack { + Text("Updated Airflow") + Spacer() + InfoButton { send(.infoButtonTapped(.updatedAirflow)) } + } + } footer: { + HStack { + ResetButton { send(.resetButtonTapped) } + Spacer() + Button("Next") { + #warning("Fix me.") + } + } + .padding(.top) + } + } + .labelsHidden() + .bind($focusedField, to: $store.focusedField) + .navigationTitle("Estimate Settings") + } + + private func percentField( + _ title: LocalizedStringKey, + value: Binding + ) -> some View { + TextField(title, value: value, format: .percent, prompt: Text(title)) + } +} + +fileprivate struct BudgetFlagViewStyle: FlaggedViewStyle { + + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.flagged.flagImage + Spacer() + configuration.flagged.messageView + } + } +} + +#Preview { + NavigationStack { + EstimateSettingsFormView( + store: Store( + initialState: EstimateSettingsForm.State(equipmentType: .furnaceAndCoil) + ) { + EstimateSettingsForm() + } + ) + #if os(macOS) + .frame(width: 400, height: 600) + .padding() + #endif + } +} diff --git a/Sources/StaticPressureFeature/StaticPressureView.swift b/Sources/PressureEstimationsFeature/StaticPressureView.swift similarity index 100% rename from Sources/StaticPressureFeature/StaticPressureView.swift rename to Sources/PressureEstimationsFeature/StaticPressureView.swift diff --git a/Sources/SharedModels/BudgetedPercentEnvelope.swift b/Sources/SharedModels/BudgetedPercentEnvelope.swift index 5651bb5..a16e229 100644 --- a/Sources/SharedModels/BudgetedPercentEnvelope.swift +++ b/Sources/SharedModels/BudgetedPercentEnvelope.swift @@ -22,7 +22,11 @@ public struct BudgetedPercentEnvelope: Equatable { self.returnPlenumBudget = returnPlenumBudget self.supplyPlenumBudget = supplyPlenumBudget } - + + public var total: Percentage { + coilBudget + filterBudget + supplyPlenumBudget + returnPlenumBudget + } + public init(equipmentType: EquipmentType, fanType: FanType) { switch equipmentType { case .furnaceAndCoil: diff --git a/Sources/SharedModels/FanType.swift b/Sources/SharedModels/FanType.swift index f2ae384..d2fd2af 100644 --- a/Sources/SharedModels/FanType.swift +++ b/Sources/SharedModels/FanType.swift @@ -1,9 +1,11 @@ import Foundation -public enum FanType: Equatable, CaseIterable, CustomStringConvertible { +public enum FanType: Hashable, Equatable, CaseIterable, CustomStringConvertible, Identifiable { case constantSpeed case variableSpeed - + + public var id: Self { self } + public var description: String { switch self { case .constantSpeed: diff --git a/Sources/SharedModels/Flagged.swift b/Sources/SharedModels/Flagged.swift index fab4ca2..efc20e5 100644 --- a/Sources/SharedModels/Flagged.swift +++ b/Sources/SharedModels/Flagged.swift @@ -157,7 +157,13 @@ extension Flagged.CheckHandler { ) -> Self { .rated(RatedAirflowLimits(tons: tons, using: ratings), goodMessage: goodMessage) } - + + public static func percent( + goodMessage: Flagged.GoodMessageHandler = .none + ) -> Self { + .using(maximum: 100, minimum: 100, rated: 100) + } + public static func rated( _ ratings: RatedEnvelope, goodMessage: Flagged.GoodMessageHandler? = nil diff --git a/Sources/SharedModels/HelperTypes/Percentage.swift b/Sources/SharedModels/HelperTypes/Percentage.swift index 09529b0..f2c94da 100644 --- a/Sources/SharedModels/HelperTypes/Percentage.swift +++ b/Sources/SharedModels/HelperTypes/Percentage.swift @@ -13,7 +13,8 @@ public struct Percentage: Equatable, RawRepresentable { } public var fraction: Double { - self.rawValue / 100 + get { self.rawValue / 100 } + set { self.rawValue = newValue * 100 } } } diff --git a/Sources/StaticPressureFeature/AirHandlerMeasurementForm.swift b/Sources/StaticPressureFeature/AirHandlerMeasurementForm.swift deleted file mode 100644 index 2cc7a1a..0000000 --- a/Sources/StaticPressureFeature/AirHandlerMeasurementForm.swift +++ /dev/null @@ -1,116 +0,0 @@ -import ComposableArchitecture -import DependenciesAdditions -import EstimatedPressureDependency -import SharedModels -import SwiftUI -import TCAExtras - -@Reducer -public struct AirHandlerMeasurementForm { - - @ObservableState - public struct State: Equatable { - public var calculatedMeasurement: EquipmentMeasurement.AirHandler? - public var focusedField: Field? - public var measurement: EquipmentMeasurement.AirHandler - public var updatedAirflow: Double? - - public init( - calculatedMeasurement: EquipmentMeasurement.AirHandler? = nil, - focusedField: Field? = nil, - measurement: EquipmentMeasurement.AirHandler = .init(), - updatedAirflow: Double? = nil - ) { - self.calculatedMeasurement = calculatedMeasurement - self.focusedField = focusedField - self.measurement = measurement - self.updatedAirflow = updatedAirflow - } - - public var isValid: Bool { - return measurement.returnPlenumPressure != nil - && measurement.postFilterPressure != nil - && measurement.postCoilPressure != nil - && measurement.supplyPlenumPressure != nil - && measurement.airflow != nil - && updatedAirflow != nil - } - - public enum Field: String, Equatable, CaseIterable, FocusableField { - case returnPlenumPressure - case postFilterPressure - case postCoilPressure - case supplyPlenumPressure - case airflow - } - } - - public enum Action: BindableAction, ViewAction { - case binding(BindingAction) - case receive(TaskResult) - case view(View) - - @CasePathable - public enum View { - case submitField - } - } - - @Dependency(\.estimatedPressuresClient) var estimatedPressuresClient - @Dependency(\.logger["\(Self.self)"]) var logger - - public var body: some Reducer { - BindingReducer() - Reduce { state, action in - switch action { - case .binding: - return .none - - case let .receive(.success(calculatedMeasurement)): - state.calculatedMeasurement = calculatedMeasurement - return .none - - case .receive: - return .none - - case let .view(action): - switch action { - - case .submitField: - state.focusedField = state.focusedField?.next - guard state.isValid else { return .none } - return calculateEstimates(state: state) - - } - } - } - .onFailure(case: \.receive, .log(logger: logger)) - } - - private func calculateEstimates(state: State) -> Effect { - .receive(action: \.receive) { - let result = try await estimatedPressuresClient.estimatedPressure( - for: .airHandler(state.measurement), - at: state.updatedAirflow ?? 0 - ) - - guard case let .airHandler(airHandler) = result else { - return .none - } - return airHandler - } -// return .receive(action: \.recieve) { -// try await estimatedPressuresClient.estimatedM -// } - } -} - -public struct EquipmentMeasurementFormView: View { - public var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) - } -} - -#Preview { - EquipmentMeasurementFormView() -} diff --git a/Sources/Styleguide/Buttons.swift b/Sources/Styleguide/Buttons.swift index bab6790..9d6f382 100644 --- a/Sources/Styleguide/Buttons.swift +++ b/Sources/Styleguide/Buttons.swift @@ -1,6 +1,9 @@ import SwiftUI public struct InfoButton: View { + + @Environment(\.infoButtonStyle) private var infoButtonStyle + let action: () -> Void public init(action: @escaping () -> Void) { @@ -11,5 +14,22 @@ public struct InfoButton: View { Button(action: action) { Label("Info", systemImage: "info.circle") } + .buttonStyle(infoButtonStyle) + } +} + +public struct ResetButton: View { + + @Environment(\.resetButtonStyle) private var resetButtonStyle + + let action: () -> Void + + public init(action: @escaping () -> Void) { + self.action = action + } + + public var body: some View { + Button("Reset") { action() } + .buttonStyle(resetButtonStyle) } } diff --git a/Sources/Styleguide/FanTypePicker.swift b/Sources/Styleguide/FanTypePicker.swift new file mode 100644 index 0000000..b592fab --- /dev/null +++ b/Sources/Styleguide/FanTypePicker.swift @@ -0,0 +1,21 @@ +import SharedModels +import SwiftUI + +public struct FanTypePicker: View { + + @Binding var selection: FanType + + public init(selection: Binding) { + self._selection = selection + } + + public var body: some View { + Picker("Fan Type", selection: $selection) { + ForEach(FanType.allCases) { + Text($0.description) + .tag($0) + } + } + } +} + diff --git a/Sources/Styleguide/FlaggedView.swift b/Sources/Styleguide/FlaggedView.swift new file mode 100644 index 0000000..06dd7f8 --- /dev/null +++ b/Sources/Styleguide/FlaggedView.swift @@ -0,0 +1,142 @@ +import SharedModels +import SwiftUI + +public struct FlaggedView: View { + + @Environment(\.flaggedViewStyle) private var flaggedViewStyle + + let label: () -> Label + let flagged: Flagged + + public init( + flagged: Flagged, + @ViewBuilder label: @escaping () -> Label + ) { + self.label = label + self.flagged = flagged + } + + public var body: some View { + flaggedViewStyle.makeBody( + configuration: .init(flagged: flagged, label: .init(label())) + ) + } +} + +extension FlaggedView where Label == Text { + public init(_ title: LocalizedStringKey, flagged: Flagged) { + self.init(flagged: flagged) { Text(title) } + } +} + +extension FlaggedView where Label == EmptyView { + public init(flagged: Flagged) { + self.init(flagged: flagged) { EmptyView() } + } +} + +//public struct FlaggedView: View { +// +// public let style: Style? +// public let title: String +// public let flagged: Flagged +// public let fractionLength: Int +// +// public init( +// style: Style? = nil, +// title: String, +// flagged: Flagged, +// fractionLength: Int = 3 +// ) { +// self.style = style +// self.title = title +// self.flagged = flagged +// self.fractionLength = fractionLength +// } +// +// public init( +// _ title: String, +// flagged: Flagged, +// style: Style? = nil, +// fractionLength: Int = 3 +// ) { +// self.style = style +// self.title = title +// self.flagged = flagged +// self.fractionLength = fractionLength +// } +// +// @ViewBuilder +// private var messageView: some View { +// if let message = flagged.message { +// HStack { +// Text(flagged.projectedValue.key.title) +// .bold() +// .foregroundStyle(flagged.projectedValue.key.flagColor) +// +// Text(message) +// .foregroundStyle(Color.secondary) +// } +// .font(.caption) +// } +// } +// +// public var body: some View { +// switch style { +// case .none: +// VStack(alignment: .leading, spacing: 8) { +// HStack { +// Text(title) +// .foregroundStyle(Color.secondary) +// Spacer() +// Text( +// flagged.wrappedValue, +// format: .number.precision(.fractionLength(fractionLength)) +// ) +// Image(systemName: "flag.fill") +// .foregroundStyle(flagged.projectedValue.key.flagColor) +// } +// messageView +// } +// case .some(.gridRow): +// GridRow { +// VStack(alignment: .leading, spacing: 8) { +// Text(title) +// .foregroundStyle(Color.secondary) +// messageView +// } +// +// Spacer() +// +// Text(flagged.wrappedValue, format: .number.precision(.fractionLength(fractionLength))) +// .gridCellAnchor(.trailing) +// +// Image(systemName: "flag.fill") +// .foregroundStyle(flagged.flagColor) +// .gridCellAnchor(.trailing) +// } +// +// } +// } +// +// public enum Style { +// case gridRow +// } +// +// public static func gridRow(_ title: String, flagged: Flagged) -> Self { +// .init(style: .gridRow, title: title, flagged: flagged) +// } +//} + + +//#Preview { +// List { +// Grid(horizontalSpacing: 20) { +// FlaggedView(style: .gridRow, title: "Test", flagged: .error(message: "Error message")) +// FlaggedView(style: .gridRow, title: "Test", flagged: .init(wrappedValue: 0.5, .result(.good("Good message")))) +// } +// +// FlaggedView(style: nil, title: "Test", flagged: .error(message: "Error message")) +// FlaggedView(style: nil, title: "Test", flagged: .init(wrappedValue: 0.5, .result(.good("Good message")))) +// } +//} diff --git a/Sources/StaticPressureFeature/FocusableField.swift b/Sources/Styleguide/FocusableField.swift similarity index 91% rename from Sources/StaticPressureFeature/FocusableField.swift rename to Sources/Styleguide/FocusableField.swift index a58b114..9502516 100644 --- a/Sources/StaticPressureFeature/FocusableField.swift +++ b/Sources/Styleguide/FocusableField.swift @@ -1,6 +1,6 @@ import Foundation -public protocol FocusableField { +public protocol FocusableField: Hashable { var next: Self? { get } } diff --git a/Sources/Styleguide/Styles/ButtonStyles.swift b/Sources/Styleguide/Styles/ButtonStyles.swift new file mode 100644 index 0000000..48f8153 --- /dev/null +++ b/Sources/Styleguide/Styles/ButtonStyles.swift @@ -0,0 +1,90 @@ +import SwiftUI + +/// A name space for info button styles. +public enum InfoButtonType { } + +/// A name space for info button styles. +public enum ResetButtonType { } + + +public struct AnyButtonStyle: ButtonStyle { + private let _makeBody: (Configuration) -> AnyView + + public init(_ style: S) { + self._makeBody = { configuration in + AnyView(style.makeBody(configuration: configuration)) + } + } + + public func makeBody(configuration: Configuration) -> some View { + self._makeBody(configuration) + } +} + + +public struct DefaultInfoButtonStyle: ButtonStyle { + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + .labelStyle(.iconOnly) + } +} + +extension AnyButtonStyle where ButtonType == InfoButtonType { + public static var `default`: Self { + .init(DefaultInfoButtonStyle()) + } +} + +public struct DefaultResetButtonStyle: ButtonStyle { + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .buttonStyle(.borderedProminent) + } +} + +extension AnyButtonStyle where ButtonType == ResetButtonType { + public static var `default`: Self { + .init(DefaultResetButtonStyle()) + } +} + +private struct InfoButtonStyleKey: EnvironmentKey { + static var defaultValue = AnyButtonStyle.default +} + +private struct ResetButtonStyleKey: EnvironmentKey { + static var defaultValue = AnyButtonStyle.default +} + +extension EnvironmentValues { + public var infoButtonStyle: AnyButtonStyle { + get { self[InfoButtonStyleKey.self] } + set { self[InfoButtonStyleKey.self] = newValue } + } + + public var resetButtonStyle: AnyButtonStyle { + get { self[ResetButtonStyleKey.self] } + set { self[ResetButtonStyleKey.self] = newValue } + } +} + +extension View { + + public func infoButtonStyle(_ style: AnyButtonStyle) -> some View { + environment(\.infoButtonStyle, AnyButtonStyle(style)) + } + + public func infoButtonStyle(_ style: S) -> some View { + infoButtonStyle(AnyButtonStyle(style)) + } + + public func resetButtonStyle(_ style: AnyButtonStyle) -> some View { + environment(\.resetButtonStyle, AnyButtonStyle(style)) + } + + public func resetButtonStyle(_ style: S) -> some View { + resetButtonStyle(AnyButtonStyle(style)) + } +} diff --git a/Sources/Styleguide/Styles/FlaggedViewStyle.swift b/Sources/Styleguide/Styles/FlaggedViewStyle.swift new file mode 100644 index 0000000..f7c1ffe --- /dev/null +++ b/Sources/Styleguide/Styles/FlaggedViewStyle.swift @@ -0,0 +1,176 @@ +import SharedModels +import SwiftUI + +public protocol FlaggedViewStyle { + associatedtype Body: View + typealias Configuration = FlaggedViewStyleConfiguration + + @ViewBuilder + func makeBody(configuration: Self.Configuration) -> Self.Body +} + +public struct FlaggedViewStyleConfiguration { + public let flagged: Flagged + public let label: Label + + /// A type erased label for a flagged view. + public struct Label: View { + public let body: AnyView + + public init(_ content: Content) { + self.body = AnyView(content) + } + } +} + +public struct AnyFlaggedViewStyle: FlaggedViewStyle { + private var _makeBody: (Configuration) -> AnyView + + internal init(makeBody: @escaping (Configuration) -> AnyView) { + self._makeBody = makeBody + } + + public init(style: S) { + self.init { configuration in + AnyView(style.makeBody(configuration: configuration)) + } + } + + public func makeBody(configuration: Configuration) -> some View { + _makeBody(configuration) + } +} + +public struct DefaultFlagViewStyle: FlaggedViewStyle { + + let alignment: HorizontalAlignment + let fractionLength: Int + let spacing: CGFloat + + init( + alignment: HorizontalAlignment = .leading, + fractionLength: Int = 2, + spacing: CGFloat = 8 + ) { + self.alignment = alignment + self.fractionLength = fractionLength + self.spacing = spacing + } + + public func makeBody(configuration: Configuration) -> some View { + VStack(alignment: alignment, spacing: spacing) { + HStack { + configuration.label + Spacer() + Text( + configuration.flagged.wrappedValue, + format: .number.precision(.fractionLength(fractionLength)) + ) + configuration.flagged.flagImage + } + flaggedMessageView(flagged: configuration.flagged) + } + } + +} + +public struct FlagAndMessageOnlyStyle: FlaggedViewStyle { + + public enum StackStyle { + case horizontal + case vertical + } + + let stackStyle: StackStyle + + @ViewBuilder + public func makeBody(configuration: Configuration) -> some View { + switch stackStyle { + case .horizontal: + HStack { + flaggedMessageView(flagged: configuration.flagged) + configuration.flagged.flagImage + } + case .vertical: + VStack { + configuration.flagged.flagImage + flaggedMessageView(flagged: configuration.flagged) + } + } + } +} + +extension FlaggedViewStyle where Self == FlagAndMessageOnlyStyle { + public static func flagAndMessageOnly(_ stackStyle: FlagAndMessageOnlyStyle.StackStyle = .vertical) -> Self { + .init(stackStyle: stackStyle) + } +} + +private struct FlaggedViewStyleKey: EnvironmentKey { + static let defaultValue = AnyFlaggedViewStyle(style: DefaultFlagViewStyle()) +} + +extension EnvironmentValues { + public var flaggedViewStyle: AnyFlaggedViewStyle { + get { self[FlaggedViewStyleKey.self] } + set { self[FlaggedViewStyleKey.self] = newValue } + } +} + +extension FlaggedViewStyle where Self == DefaultFlagViewStyle { + public static func `default`(alignment: HorizontalAlignment = .leading, fractionLength: Int = 2, spacing: CGFloat = 8) -> Self { + .init(alignment: alignment, fractionLength: fractionLength, spacing: spacing) + } +} + + +extension Flagged.CheckResult.Key { + + public var flagColor: Color { + switch self { + case .good: + return .green + case .warning: + return .yellow + case .error: + return .red + } + } +} + + +extension Flagged { + public var flagColor: Color { projectedValue.key.flagColor } + + public var flagImage: some View { + Image(systemName: "flag.fill") + .foregroundStyle(flagColor) + } + + public var messageView: some View { flaggedMessageView(flagged: self) } +} + +@ViewBuilder +private func flaggedMessageView(flagged: Flagged) -> some View { + if let message = flagged.message { + HStack { + Text(flagged.projectedValue.key.title) + .bold() + .foregroundStyle(flagged.projectedValue.key.flagColor) + TextLabel(message) + } + .font(.caption) + } +} + +extension View { + public func flaggedViewStyle(_ style: AnyFlaggedViewStyle) -> some View { + environment(\.flaggedViewStyle, style) + } + + public func flaggedViewStyle( + _ style: S + ) -> some View { + environment(\.flaggedViewStyle, AnyFlaggedViewStyle(style: style)) + } +} diff --git a/Sources/Styleguide/Styles/TextLabelStyle.swift b/Sources/Styleguide/Styles/TextLabelStyle.swift new file mode 100644 index 0000000..0a50c2a --- /dev/null +++ b/Sources/Styleguide/Styles/TextLabelStyle.swift @@ -0,0 +1,175 @@ +import SwiftUI + +public protocol TextLabelStyle { + + associatedtype Body: View + typealias Configuration = TextLabelConfiguration + + func makeBody(configuration: Self.Configuration) -> Self.Body +} + +extension TextLabelStyle { + public func combining(_ style: Other) -> AnyTextLabelStyle { + AnyTextLabelStyle(style: self).combining(style) + } +} + +public struct TextLabelConfiguration { + + /// A type erased label for a text label. + public struct Label: View { + public var body: AnyView + + public init(content: Content) { + body = AnyView(content) + } + } + + public let label: TextLabelConfiguration.Label +} + +public struct AnyTextLabelStyle: TextLabelStyle { + private var _makeBody: (Configuration) -> AnyView + + internal init(makeBody: @escaping (Configuration) -> AnyView) { + self._makeBody = makeBody + } + + public init(style: S) { + self._makeBody = { configuration in + AnyView(style.makeBody(configuration: configuration)) + } + } + + public func makeBody(configuration: Configuration) -> some View { + _makeBody(configuration) + } + + public func combining(_ style: S) -> Self { + Self.init { configuration in + AnyView( + self.makeBody( + configuration: TextLabelConfiguration( + label: .init(content: style.makeBody(configuration: configuration)) + ) + )) + } + } +} + +public struct AutomaticTextLabelStyle: TextLabelStyle { + public func makeBody(configuration: Configuration) -> some View { + configuration.label + } +} + +private struct TextLabelStyleKey: EnvironmentKey { + static let defaultValue = AnyTextLabelStyle(style: AutomaticTextLabelStyle()) +} + +private struct SectionHeaderLabelStyleKey: EnvironmentKey { + static let defaultValue = AnyTextLabelStyle(style: AutomaticTextLabelStyle()) +} + +extension EnvironmentValues { + public var textLabelStyle: AnyTextLabelStyle { + get { self[TextLabelStyleKey.self] } + set { self[TextLabelStyleKey.self] = newValue } + } + + public var sectionHeaderLabelStyle: AnyTextLabelStyle { + get { self[SectionHeaderLabelStyleKey.self] } + set { self[SectionHeaderLabelStyleKey.self] = newValue } + } +} + +public struct FontTextLabelStyle: TextLabelStyle { + let font: Font? + let fontWeight: Font.Weight? + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(font) + .fontWeight(fontWeight) + } +} + +public struct ColoredTextLabelStyle: TextLabelStyle { + + let primary: Color + let secondary: Color? + let tertiary: Color? + + @ViewBuilder + public func makeBody(configuration: Configuration) -> some View { + switch (secondary, tertiary) { + case let (.some(secondary), .some(tertiary)): + configuration.label.foregroundStyle(primary, secondary, tertiary) + case let (.some(secondary), .none): + configuration.label.foregroundStyle(primary, secondary) + case let (.none, .some(tertiary)): + configuration.label.foregroundStyle(primary, tertiary) + case (.none, .none): + configuration.label.foregroundStyle(primary) + } + } + + public func font(_ font: Font? = nil, fontWeight: Font.Weight? = nil) -> AnyTextLabelStyle { + self.combining(.font(font, fontWeight: fontWeight)) + } +} + +extension TextLabelStyle where Self == AutomaticTextLabelStyle { + public static var automatic: Self { + .init() + } +} + +extension TextLabelStyle where Self == FontTextLabelStyle { + public static func font(_ font: Font? = nil, fontWeight: Font.Weight? = nil) -> Self { + .init(font: font, fontWeight: fontWeight) + } + + public static var heavyTitle2: Self { + .font(.title2, fontWeight: .heavy) + } +} + +extension TextLabelStyle where Self == ColoredTextLabelStyle { + + public static func colored(_ color: Color) -> Self { + .init(primary: color, secondary: nil, tertiary: nil) + } + + public static func colored(_ primary: Color, _ secondary: Color, _ tertiary: Color? = nil) -> Self { + .init(primary: primary, secondary: secondary, tertiary: tertiary) + } + + public static var secondary: Self { + self.colored(.secondary) + } +} + +extension AnyTextLabelStyle { + + public static var boldSecondary: AnyTextLabelStyle { + ColoredTextLabelStyle(primary: .secondary, secondary: nil, tertiary: nil) + .combining(.font(fontWeight: .bold)) + } + +} + +extension View { + + public func textLabelStyle(_ style: AnyTextLabelStyle) -> some View { + environment(\.textLabelStyle, style) + } + + public func textLabelStyle(_ style: S) -> some View { + environment(\.textLabelStyle, AnyTextLabelStyle(style: style)) + } + + public func sectionHeaderLabelStyle(_ style: S) -> some View { + environment(\.sectionHeaderLabelStyle, AnyTextLabelStyle(style: style)) + } +} diff --git a/Sources/Styleguide/TextField+Precision.swift b/Sources/Styleguide/TextField+Precision.swift new file mode 100644 index 0000000..8bd688c --- /dev/null +++ b/Sources/Styleguide/TextField+Precision.swift @@ -0,0 +1,19 @@ +import SwiftUI + +extension TextField where Label == Text { + + public init( + _ titleKey: LocalizedStringKey, + value: Binding, + fractionLength: Int, + prompt: Text? = nil + ) { + self.init( + titleKey, + value: value, + format: .number.precision(.fractionLength(fractionLength)), + prompt: prompt + ) + } + +} diff --git a/Sources/Styleguide/TextLabel.swift b/Sources/Styleguide/TextLabel.swift new file mode 100644 index 0000000..b4c28dd --- /dev/null +++ b/Sources/Styleguide/TextLabel.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// A view that can be styled view the `.textLabelStyle` modifier, generally they will be +/// simple `Text` views, however it will accept any content view and will apply the style to. +/// +/// Custom styles can be created by conforming to the ``TextLabelStyle`` protocol. +/// +public struct TextLabel: View { + + @Environment(\.textLabelStyle) private var textLabelStyle + + private let content: () -> Content + + public init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + public var body: some View { + textLabelStyle.makeBody( + configuration: TextLabelConfiguration( + label: TextLabelConfiguration.Label(content: content()) + ) + ) + } +} + +extension TextLabel where Content == Text { + public init(_ text: S) where S: StringProtocol { + self.init { Text(text) } + } +} + +#Preview { + VStack { + TextLabel("Automatic") + + TextLabel("Secondary-Bold") + .textLabelStyle(AnyTextLabelStyle.boldSecondary) + + TextLabel("Secondary") + .textLabelStyle(.secondary) + .padding(.bottom) + + Group { + TextLabel("One") + TextLabel("Two") + TextLabel("Three") + } + .font(.title) + .textLabelStyle(.boldSecondary) + } +}