524 lines
18 KiB
Swift
524 lines
18 KiB
Swift
import SwiftUI
|
|
|
|
struct BatteryEditorView: View {
|
|
@State private var configuration: BatteryConfiguration
|
|
@State private var editingField: EditingField?
|
|
@State private var voltageInput: String = ""
|
|
@State private var capacityInput: String = ""
|
|
let onSave: (BatteryConfiguration) -> Void
|
|
|
|
private enum EditingField {
|
|
case voltage
|
|
case capacity
|
|
}
|
|
|
|
private let voltageSnapValues: [Double] = [6, 12, 12.8, 24, 25.6, 36, 48, 51.2]
|
|
private let capacitySnapValues: [Double] = [10, 20, 50, 75, 100, 125, 150, 200, 300, 400, 600, 800, 1000]
|
|
private let voltageSnapTolerance: Double = 0.5
|
|
private let capacitySnapTolerance: Double = 10.0
|
|
|
|
private var nameFieldLabel: String {
|
|
String(
|
|
localized: "battery.editor.field.name",
|
|
bundle: .main,
|
|
comment: "Label for the battery name text field"
|
|
)
|
|
}
|
|
|
|
private var namePlaceholder: String {
|
|
String(
|
|
localized: "battery.editor.placeholder.name",
|
|
bundle: .main,
|
|
comment: "Placeholder example for the battery name field"
|
|
)
|
|
}
|
|
|
|
private var chemistryLabel: String {
|
|
String(
|
|
localized: "battery.editor.field.chemistry",
|
|
bundle: .main,
|
|
comment: "Label describing the chemistry menu"
|
|
)
|
|
}
|
|
|
|
private var summaryLabel: String {
|
|
String(
|
|
localized: "battery.editor.section.summary",
|
|
bundle: .main,
|
|
comment: "Label for the summary section in the editor"
|
|
)
|
|
}
|
|
|
|
private var sliderVoltageTitle: String {
|
|
String(
|
|
localized: "battery.editor.slider.voltage",
|
|
bundle: .main,
|
|
comment: "Title for the nominal voltage slider"
|
|
)
|
|
}
|
|
|
|
private var sliderCapacityTitle: String {
|
|
String(
|
|
localized: "battery.editor.slider.capacity",
|
|
bundle: .main,
|
|
comment: "Title for the capacity slider"
|
|
)
|
|
}
|
|
|
|
private var summaryVoltageLabel: String {
|
|
String(
|
|
localized: "battery.bank.badge.voltage",
|
|
bundle: .main,
|
|
comment: "Label used for voltage values"
|
|
)
|
|
}
|
|
|
|
private var summaryCapacityLabel: String {
|
|
String(
|
|
localized: "battery.bank.badge.capacity",
|
|
bundle: .main,
|
|
comment: "Label used for capacity values"
|
|
)
|
|
}
|
|
|
|
private var summaryEnergyLabel: String {
|
|
String(
|
|
localized: "battery.bank.badge.energy",
|
|
bundle: .main,
|
|
comment: "Label used for energy values"
|
|
)
|
|
}
|
|
|
|
private var voltageSliderRange: ClosedRange<Double> {
|
|
let lowerBound = max(0, min(6, configuration.nominalVoltage))
|
|
let upperBound = max(60, configuration.nominalVoltage)
|
|
return lowerBound...upperBound
|
|
}
|
|
|
|
private var capacitySliderRange: ClosedRange<Double> {
|
|
let lowerBound = max(0, min(5, configuration.capacityAmpHours))
|
|
let upperBound = max(1000, configuration.capacityAmpHours)
|
|
return lowerBound...upperBound
|
|
}
|
|
|
|
init(configuration: BatteryConfiguration, onSave: @escaping (BatteryConfiguration) -> Void) {
|
|
_configuration = State(initialValue: configuration)
|
|
self.onSave = onSave
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
headerCard
|
|
slidersSection
|
|
}
|
|
.padding(.vertical, 24)
|
|
.padding(.horizontal)
|
|
}
|
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
|
.navigationTitle(
|
|
NSLocalizedString(
|
|
"battery.editor.title",
|
|
bundle: .main,
|
|
value: "Battery Setup",
|
|
comment: "Title for the battery editor"
|
|
)
|
|
)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.onDisappear {
|
|
onSave(configuration)
|
|
}
|
|
.alert(
|
|
NSLocalizedString(
|
|
"battery.editor.alert.voltage.title",
|
|
bundle: .main,
|
|
value: "Edit Nominal Voltage",
|
|
comment: "Title for the voltage edit alert"
|
|
),
|
|
isPresented: Binding(
|
|
get: { editingField == .voltage },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
editingField = nil
|
|
voltageInput = ""
|
|
}
|
|
}
|
|
)
|
|
) {
|
|
TextField(
|
|
NSLocalizedString(
|
|
"battery.editor.alert.voltage.placeholder",
|
|
bundle: .main,
|
|
value: "Voltage",
|
|
comment: "Placeholder for voltage text field"
|
|
),
|
|
text: $voltageInput
|
|
)
|
|
.keyboardType(.decimalPad)
|
|
.onAppear {
|
|
if voltageInput.isEmpty {
|
|
voltageInput = formattedEditValue(configuration.nominalVoltage)
|
|
}
|
|
}
|
|
.onChange(of: voltageInput) { newValue in
|
|
guard editingField == .voltage, let parsed = parseInput(newValue) else { return }
|
|
configuration.nominalVoltage = roundToTenth(parsed)
|
|
}
|
|
|
|
Button(
|
|
NSLocalizedString(
|
|
"battery.editor.alert.cancel",
|
|
bundle: .main,
|
|
value: "Cancel",
|
|
comment: "Cancel button title for edit alerts"
|
|
),
|
|
role: .cancel
|
|
) {
|
|
editingField = nil
|
|
voltageInput = ""
|
|
}
|
|
|
|
Button(
|
|
NSLocalizedString(
|
|
"battery.editor.alert.save",
|
|
bundle: .main,
|
|
value: "Save",
|
|
comment: "Save button title for edit alerts"
|
|
)
|
|
) {
|
|
if let parsed = parseInput(voltageInput) {
|
|
configuration.nominalVoltage = roundToTenth(parsed)
|
|
}
|
|
editingField = nil
|
|
voltageInput = ""
|
|
}
|
|
} message: {
|
|
Text(
|
|
NSLocalizedString(
|
|
"battery.editor.alert.voltage.message",
|
|
bundle: .main,
|
|
value: "Enter voltage in volts (V)",
|
|
comment: "Message for the voltage edit alert"
|
|
)
|
|
)
|
|
}
|
|
.alert(
|
|
NSLocalizedString(
|
|
"battery.editor.alert.capacity.title",
|
|
bundle: .main,
|
|
value: "Edit Capacity",
|
|
comment: "Title for the capacity edit alert"
|
|
),
|
|
isPresented: Binding(
|
|
get: { editingField == .capacity },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
editingField = nil
|
|
capacityInput = ""
|
|
}
|
|
}
|
|
)
|
|
) {
|
|
TextField(
|
|
NSLocalizedString(
|
|
"battery.editor.alert.capacity.placeholder",
|
|
bundle: .main,
|
|
value: "Capacity",
|
|
comment: "Placeholder for capacity text field"
|
|
),
|
|
text: $capacityInput
|
|
)
|
|
.keyboardType(.decimalPad)
|
|
.onAppear {
|
|
if capacityInput.isEmpty {
|
|
capacityInput = formattedEditValue(configuration.capacityAmpHours)
|
|
}
|
|
}
|
|
.onChange(of: capacityInput) { newValue in
|
|
guard editingField == .capacity, let parsed = parseInput(newValue) else { return }
|
|
configuration.capacityAmpHours = roundToTenth(parsed)
|
|
}
|
|
|
|
Button(
|
|
NSLocalizedString(
|
|
"battery.editor.alert.cancel",
|
|
bundle: .main,
|
|
value: "Cancel",
|
|
comment: "Cancel button title for edit alerts"
|
|
),
|
|
role: .cancel
|
|
) {
|
|
editingField = nil
|
|
capacityInput = ""
|
|
}
|
|
|
|
Button(
|
|
NSLocalizedString(
|
|
"battery.editor.alert.save",
|
|
bundle: .main,
|
|
value: "Save",
|
|
comment: "Save button title for edit alerts"
|
|
)
|
|
) {
|
|
if let parsed = parseInput(capacityInput) {
|
|
configuration.capacityAmpHours = roundToTenth(parsed)
|
|
}
|
|
editingField = nil
|
|
capacityInput = ""
|
|
}
|
|
} message: {
|
|
Text(
|
|
NSLocalizedString(
|
|
"battery.editor.alert.capacity.message",
|
|
bundle: .main,
|
|
value: "Enter capacity in amp-hours (Ah)",
|
|
comment: "Message for the capacity edit alert"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private var headerCard: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(nameFieldLabel)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
TextField(namePlaceholder, text: $configuration.name)
|
|
.textInputAutocapitalization(.words)
|
|
.padding(.vertical, 10)
|
|
.padding(.horizontal, 12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(Color(.secondarySystemBackground))
|
|
)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(chemistryLabel)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Menu {
|
|
ForEach(BatteryConfiguration.Chemistry.allCases) { chemistry in
|
|
Button {
|
|
configuration.chemistry = chemistry
|
|
} label: {
|
|
if chemistry == configuration.chemistry {
|
|
Label(chemistry.displayName, systemImage: "checkmark")
|
|
} else {
|
|
Text(chemistry.displayName)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Text(configuration.chemistry.displayName)
|
|
.font(.body.weight(.semibold))
|
|
Spacer()
|
|
Image(systemName: "chevron.down")
|
|
.font(.footnote.weight(.bold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 10)
|
|
.padding(.horizontal, 12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(Color(.secondarySystemBackground))
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(summaryLabel)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
ViewThatFits(in: .horizontal) {
|
|
HStack(spacing: 16) {
|
|
summaryBadge(
|
|
title: summaryVoltageLabel,
|
|
value: formattedValue(configuration.nominalVoltage, unit: "V"),
|
|
symbol: "bolt"
|
|
)
|
|
summaryBadge(
|
|
title: summaryCapacityLabel,
|
|
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
|
|
symbol: "gauge.medium"
|
|
)
|
|
summaryBadge(
|
|
title: summaryEnergyLabel,
|
|
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
|
|
symbol: "battery.100.bolt"
|
|
)
|
|
}
|
|
|
|
VStack(spacing: 12) {
|
|
summaryBadge(
|
|
title: summaryVoltageLabel,
|
|
value: formattedValue(configuration.nominalVoltage, unit: "V"),
|
|
symbol: "bolt"
|
|
)
|
|
summaryBadge(
|
|
title: summaryCapacityLabel,
|
|
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
|
|
symbol: "gauge.medium"
|
|
)
|
|
summaryBadge(
|
|
title: summaryEnergyLabel,
|
|
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
|
|
symbol: "battery.100.bolt"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.fill(Color(.tertiarySystemBackground))
|
|
)
|
|
}
|
|
|
|
private var slidersSection: some View {
|
|
VStack(spacing: 30) {
|
|
SliderSection(
|
|
title: sliderVoltageTitle,
|
|
value: Binding(
|
|
get: { configuration.nominalVoltage },
|
|
set: { newValue in
|
|
if editingField == .voltage {
|
|
configuration.nominalVoltage = roundToTenth(newValue)
|
|
} else {
|
|
configuration.nominalVoltage = normalizedVoltage(for: newValue)
|
|
}
|
|
}
|
|
),
|
|
range: voltageSliderRange,
|
|
unit: "V",
|
|
tapAction: beginVoltageEditing,
|
|
snapValues: editingField == .voltage ? nil : voltageSnapValues
|
|
)
|
|
|
|
SliderSection(
|
|
title: sliderCapacityTitle,
|
|
value: Binding(
|
|
get: { configuration.capacityAmpHours },
|
|
set: { newValue in
|
|
if editingField == .capacity {
|
|
configuration.capacityAmpHours = roundToTenth(newValue)
|
|
} else {
|
|
configuration.capacityAmpHours = normalizedCapacity(for: newValue)
|
|
}
|
|
}
|
|
),
|
|
range: capacitySliderRange,
|
|
unit: "Ah",
|
|
tapAction: beginCapacityEditing,
|
|
snapValues: editingField == .capacity ? nil : capacitySnapValues
|
|
)
|
|
}
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.fill(Color(.secondarySystemBackground))
|
|
)
|
|
}
|
|
|
|
private func normalizedVoltage(for value: Double) -> Double {
|
|
let rounded = (value * 10).rounded() / 10
|
|
if let snapped = nearestValue(to: rounded, in: voltageSnapValues, tolerance: voltageSnapTolerance) {
|
|
return snapped
|
|
}
|
|
return rounded
|
|
}
|
|
|
|
private func normalizedCapacity(for value: Double) -> Double {
|
|
let rounded = (value * 10).rounded() / 10
|
|
if let snapped = nearestValue(to: rounded, in: capacitySnapValues, tolerance: capacitySnapTolerance) {
|
|
return snapped
|
|
}
|
|
return rounded
|
|
}
|
|
|
|
private func roundToTenth(_ value: Double) -> Double {
|
|
max(0, (value * 10).rounded() / 10)
|
|
}
|
|
|
|
private func formattedEditValue(_ value: Double) -> String {
|
|
Self.numberFormatter.string(from: NSNumber(value: roundToTenth(value))) ?? String(format: "%.1f", value)
|
|
}
|
|
|
|
private func parseInput(_ text: String) -> Double? {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
if let number = Self.numberFormatter.number(from: trimmed)?.doubleValue {
|
|
return number
|
|
}
|
|
let decimalSeparator = Locale.current.decimalSeparator ?? "."
|
|
let altSeparator = decimalSeparator == "." ? "," : "."
|
|
let normalized = trimmed.replacingOccurrences(of: altSeparator, with: decimalSeparator)
|
|
return Self.numberFormatter.number(from: normalized)?.doubleValue
|
|
}
|
|
|
|
private func beginVoltageEditing() {
|
|
voltageInput = formattedEditValue(configuration.nominalVoltage)
|
|
editingField = .voltage
|
|
}
|
|
|
|
private func beginCapacityEditing() {
|
|
capacityInput = formattedEditValue(configuration.capacityAmpHours)
|
|
editingField = .capacity
|
|
}
|
|
|
|
private func nearestValue(to value: Double, in options: [Double], tolerance: Double) -> Double? {
|
|
guard let closest = options.min(by: { abs($0 - value) < abs($1 - value) }) else { return nil }
|
|
return abs(closest - value) <= tolerance ? closest : nil
|
|
}
|
|
|
|
private func summaryBadge(title: String, value: String, symbol: String) -> some View {
|
|
VStack(spacing: 4) {
|
|
Image(systemName: symbol)
|
|
.font(.title3)
|
|
.foregroundStyle(Color.accentColor)
|
|
Text(value)
|
|
.font(.subheadline.weight(.semibold))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.8)
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.fill(Color(.secondarySystemBackground))
|
|
)
|
|
}
|
|
|
|
private static let numberFormatter: NumberFormatter = {
|
|
let formatter = NumberFormatter()
|
|
formatter.locale = .current
|
|
formatter.numberStyle = .decimal
|
|
formatter.minimumFractionDigits = 0
|
|
formatter.maximumFractionDigits = 1
|
|
return formatter
|
|
}()
|
|
|
|
private func formattedValue(_ value: Double, unit: String) -> String {
|
|
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
|
return "\(numberString) \(unit)"
|
|
}
|
|
|
|
}
|
|
|
|
#Preview {
|
|
let previewSystem = ElectricalSystem(name: "Camper")
|
|
return NavigationStack {
|
|
BatteryEditorView(
|
|
configuration: BatteryConfiguration(name: "House Bank", system: previewSystem),
|
|
onSave: { _ in }
|
|
)
|
|
}
|
|
}
|