Files
Cable/Cable/BatteryEditorView.swift
2025-10-21 16:24:25 +02:00

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 }
)
}
}