free value entry in the battery editor

This commit is contained in:
Stefan Lange-Hegermann
2025-10-21 16:24:25 +02:00
parent a6f2f8fc91
commit f171c3d6b2
5 changed files with 152 additions and 94 deletions

View File

@@ -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";

View File

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

View File

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

View File

@@ -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

View File

@@ -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";