free value entry in the battery editor
This commit is contained in:
@@ -89,7 +89,7 @@
|
||||
"overview.loads.empty.title" = "No loads configured yet";
|
||||
"overview.loads.empty.subtitle" = "Add components to get cable sizing and fuse recommendations tailored to this system.";
|
||||
"overview.runtime.title" = "Estimated runtime";
|
||||
"overview.runtime.subtitle" = "At current load draw";
|
||||
"overview.runtime.subtitle" = "At maximum load draw";
|
||||
"overview.runtime.unavailable" = "Add battery capacity and load power to estimate runtime.";
|
||||
"battery.bank.warning.voltage.short" = "Voltage";
|
||||
"battery.bank.warning.capacity.short" = "Capacity";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
struct BatteryConfiguration: Identifiable {
|
||||
struct BatteryConfiguration: Identifiable, Hashable {
|
||||
enum Chemistry: String, CaseIterable, Identifiable {
|
||||
case agm = "AGM"
|
||||
case gel = "Gel"
|
||||
@@ -61,3 +61,21 @@ struct BatteryConfiguration: Identifiable {
|
||||
savedBattery.timestamp = Date()
|
||||
}
|
||||
}
|
||||
|
||||
extension BatteryConfiguration {
|
||||
static func == (lhs: BatteryConfiguration, rhs: BatteryConfiguration) -> Bool {
|
||||
lhs.id == rhs.id &&
|
||||
lhs.name == rhs.name &&
|
||||
lhs.nominalVoltage == rhs.nominalVoltage &&
|
||||
lhs.capacityAmpHours == rhs.capacityAmpHours &&
|
||||
lhs.chemistry == rhs.chemistry
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
hasher.combine(name)
|
||||
hasher.combine(nominalVoltage)
|
||||
hasher.combine(capacityAmpHours)
|
||||
hasher.combine(chemistry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BatteryEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var configuration: BatteryConfiguration
|
||||
@State private var editingField: EditingField?
|
||||
|
||||
@State private var voltageInput: String = ""
|
||||
@State private var capacityInput: String = ""
|
||||
let onSave: (BatteryConfiguration) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
private enum EditingField {
|
||||
case voltage
|
||||
@@ -89,11 +88,22 @@ struct BatteryEditorView: View {
|
||||
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
|
||||
}
|
||||
|
||||
init(configuration: BatteryConfiguration, onSave: @escaping (BatteryConfiguration) -> Void, onCancel: @escaping () -> Void) {
|
||||
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
|
||||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -115,32 +125,8 @@ struct BatteryEditorView: View {
|
||||
)
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(
|
||||
NSLocalizedString(
|
||||
"battery.editor.cancel",
|
||||
bundle: .main,
|
||||
value: "Cancel",
|
||||
comment: "Cancel button title"
|
||||
)
|
||||
) {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(
|
||||
NSLocalizedString(
|
||||
"battery.editor.save",
|
||||
bundle: .main,
|
||||
value: "Save",
|
||||
comment: "Save button title"
|
||||
)
|
||||
) {
|
||||
save()
|
||||
}
|
||||
.disabled(configuration.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.onDisappear {
|
||||
onSave(configuration)
|
||||
}
|
||||
.alert(
|
||||
NSLocalizedString(
|
||||
@@ -151,7 +137,12 @@ struct BatteryEditorView: View {
|
||||
),
|
||||
isPresented: Binding(
|
||||
get: { editingField == .voltage },
|
||||
set: { if !$0 { editingField = nil } }
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
editingField = nil
|
||||
voltageInput = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
TextField(
|
||||
@@ -161,10 +152,18 @@ struct BatteryEditorView: View {
|
||||
value: "Voltage",
|
||||
comment: "Placeholder for voltage text field"
|
||||
),
|
||||
value: $configuration.nominalVoltage,
|
||||
format: .number
|
||||
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(
|
||||
@@ -174,7 +173,10 @@ struct BatteryEditorView: View {
|
||||
comment: "Cancel button title for edit alerts"
|
||||
),
|
||||
role: .cancel
|
||||
) { editingField = nil }
|
||||
) {
|
||||
editingField = nil
|
||||
voltageInput = ""
|
||||
}
|
||||
|
||||
Button(
|
||||
NSLocalizedString(
|
||||
@@ -184,11 +186,11 @@ struct BatteryEditorView: View {
|
||||
comment: "Save button title for edit alerts"
|
||||
)
|
||||
) {
|
||||
editingField = nil
|
||||
let normalized = normalizedVoltage(for: configuration.nominalVoltage)
|
||||
if abs(normalized - configuration.nominalVoltage) > 0.000001 {
|
||||
configuration.nominalVoltage = normalized
|
||||
if let parsed = parseInput(voltageInput) {
|
||||
configuration.nominalVoltage = roundToTenth(parsed)
|
||||
}
|
||||
editingField = nil
|
||||
voltageInput = ""
|
||||
}
|
||||
} message: {
|
||||
Text(
|
||||
@@ -209,7 +211,12 @@ struct BatteryEditorView: View {
|
||||
),
|
||||
isPresented: Binding(
|
||||
get: { editingField == .capacity },
|
||||
set: { if !$0 { editingField = nil } }
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
editingField = nil
|
||||
capacityInput = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
TextField(
|
||||
@@ -219,10 +226,18 @@ struct BatteryEditorView: View {
|
||||
value: "Capacity",
|
||||
comment: "Placeholder for capacity text field"
|
||||
),
|
||||
value: $configuration.capacityAmpHours,
|
||||
format: .number
|
||||
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(
|
||||
@@ -232,7 +247,10 @@ struct BatteryEditorView: View {
|
||||
comment: "Cancel button title for edit alerts"
|
||||
),
|
||||
role: .cancel
|
||||
) { editingField = nil }
|
||||
) {
|
||||
editingField = nil
|
||||
capacityInput = ""
|
||||
}
|
||||
|
||||
Button(
|
||||
NSLocalizedString(
|
||||
@@ -242,11 +260,11 @@ struct BatteryEditorView: View {
|
||||
comment: "Save button title for edit alerts"
|
||||
)
|
||||
) {
|
||||
editingField = nil
|
||||
let normalized = normalizedCapacity(for: configuration.capacityAmpHours)
|
||||
if abs(normalized - configuration.capacityAmpHours) > 0.000001 {
|
||||
configuration.capacityAmpHours = normalized
|
||||
if let parsed = parseInput(capacityInput) {
|
||||
configuration.capacityAmpHours = roundToTenth(parsed)
|
||||
}
|
||||
editingField = nil
|
||||
capacityInput = ""
|
||||
}
|
||||
} message: {
|
||||
Text(
|
||||
@@ -365,33 +383,39 @@ struct BatteryEditorView: View {
|
||||
VStack(spacing: 30) {
|
||||
SliderSection(
|
||||
title: sliderVoltageTitle,
|
||||
value: $configuration.nominalVoltage,
|
||||
range: 6...60,
|
||||
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: { editingField = .voltage },
|
||||
snapValues: voltageSnapValues
|
||||
tapAction: beginVoltageEditing,
|
||||
snapValues: editingField == .voltage ? nil : voltageSnapValues
|
||||
)
|
||||
.onChange(of: configuration.nominalVoltage) { _, newValue in
|
||||
let normalized = normalizedVoltage(for: newValue)
|
||||
if abs(normalized - newValue) > 0.000001 {
|
||||
configuration.nominalVoltage = normalized
|
||||
}
|
||||
}
|
||||
|
||||
SliderSection(
|
||||
title: sliderCapacityTitle,
|
||||
value: $configuration.capacityAmpHours,
|
||||
range: 5...1000,
|
||||
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: { editingField = .capacity },
|
||||
snapValues: capacitySnapValues
|
||||
tapAction: beginCapacityEditing,
|
||||
snapValues: editingField == .capacity ? nil : capacitySnapValues
|
||||
)
|
||||
.onChange(of: configuration.capacityAmpHours) { _, newValue in
|
||||
let normalized = normalizedCapacity(for: newValue)
|
||||
if abs(normalized - newValue) > 0.000001 {
|
||||
configuration.capacityAmpHours = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
@@ -416,6 +440,36 @@ struct BatteryEditorView: View {
|
||||
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
|
||||
@@ -445,6 +499,7 @@ struct BatteryEditorView: View {
|
||||
private static let numberFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.locale = .current
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 1
|
||||
return formatter
|
||||
@@ -455,15 +510,6 @@ struct BatteryEditorView: View {
|
||||
return "\(numberString) \(unit)"
|
||||
}
|
||||
|
||||
private func save() {
|
||||
onSave(configuration)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
onCancel()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@@ -471,8 +517,7 @@ struct BatteryEditorView: View {
|
||||
return NavigationStack {
|
||||
BatteryEditorView(
|
||||
configuration: BatteryConfiguration(name: "House Bank", system: previewSystem),
|
||||
onSave: { _ in },
|
||||
onCancel: {}
|
||||
onSave: { _ in }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,19 +165,14 @@ struct LoadsView: View {
|
||||
.navigationDestination(item: $newLoadToEdit) { load in
|
||||
CalculatorView(savedLoad: load)
|
||||
}
|
||||
.sheet(item: $batteryDraft) { draft in
|
||||
NavigationStack {
|
||||
BatteryEditorView(
|
||||
configuration: draft,
|
||||
onSave: { configuration in
|
||||
saveBattery(configuration)
|
||||
batteryDraft = nil
|
||||
},
|
||||
onCancel: {
|
||||
batteryDraft = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationDestination(item: $batteryDraft) { draft in
|
||||
BatteryEditorView(
|
||||
configuration: draft,
|
||||
onSave: { configuration in
|
||||
saveBattery(configuration)
|
||||
batteryDraft = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingComponentLibrary) {
|
||||
ComponentLibraryView { item in
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
"overview.loads.empty.title" = "Noch keine Verbraucher eingerichtet";
|
||||
"overview.loads.empty.subtitle" = "Füge Verbraucher hinzu, um auf dieses System zugeschnittene Kabel- und Sicherungsempfehlungen zu erhalten.";
|
||||
"overview.runtime.title" = "Geschätzte Laufzeit";
|
||||
"overview.runtime.subtitle" = "Bei aktueller Last";
|
||||
"overview.runtime.subtitle" = "Bei dauerhafter Vollast";
|
||||
"overview.runtime.unavailable" = "Trage Batteriekapazität und Leistungsaufnahme ein, um die Laufzeit zu schätzen.";
|
||||
"battery.bank.warning.voltage.short" = "Spannung";
|
||||
"battery.bank.warning.capacity.short" = "Kapazität";
|
||||
|
||||
Reference in New Issue
Block a user