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.title" = "No loads configured yet";
"overview.loads.empty.subtitle" = "Add components to get cable sizing and fuse recommendations tailored to this system."; "overview.loads.empty.subtitle" = "Add components to get cable sizing and fuse recommendations tailored to this system.";
"overview.runtime.title" = "Estimated runtime"; "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."; "overview.runtime.unavailable" = "Add battery capacity and load power to estimate runtime.";
"battery.bank.warning.voltage.short" = "Voltage"; "battery.bank.warning.voltage.short" = "Voltage";
"battery.bank.warning.capacity.short" = "Capacity"; "battery.bank.warning.capacity.short" = "Capacity";

View File

@@ -1,7 +1,7 @@
import Foundation import Foundation
import SwiftData import SwiftData
struct BatteryConfiguration: Identifiable { struct BatteryConfiguration: Identifiable, Hashable {
enum Chemistry: String, CaseIterable, Identifiable { enum Chemistry: String, CaseIterable, Identifiable {
case agm = "AGM" case agm = "AGM"
case gel = "Gel" case gel = "Gel"
@@ -61,3 +61,21 @@ struct BatteryConfiguration: Identifiable {
savedBattery.timestamp = Date() 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 import SwiftUI
struct BatteryEditorView: View { struct BatteryEditorView: View {
@Environment(\.dismiss) private var dismiss
@State private var configuration: BatteryConfiguration @State private var configuration: BatteryConfiguration
@State private var editingField: EditingField? @State private var editingField: EditingField?
@State private var voltageInput: String = ""
@State private var capacityInput: String = ""
let onSave: (BatteryConfiguration) -> Void let onSave: (BatteryConfiguration) -> Void
let onCancel: () -> Void
private enum EditingField { private enum EditingField {
case voltage case voltage
@@ -89,11 +88,22 @@ struct BatteryEditorView: View {
comment: "Label used for energy values" 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) _configuration = State(initialValue: configuration)
self.onSave = onSave self.onSave = onSave
self.onCancel = onCancel
} }
var body: some View { var body: some View {
@@ -115,32 +125,8 @@ struct BatteryEditorView: View {
) )
) )
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .onDisappear {
ToolbarItem(placement: .cancellationAction) { onSave(configuration)
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)
}
} }
.alert( .alert(
NSLocalizedString( NSLocalizedString(
@@ -151,7 +137,12 @@ struct BatteryEditorView: View {
), ),
isPresented: Binding( isPresented: Binding(
get: { editingField == .voltage }, get: { editingField == .voltage },
set: { if !$0 { editingField = nil } } set: { isPresented in
if !isPresented {
editingField = nil
voltageInput = ""
}
}
) )
) { ) {
TextField( TextField(
@@ -161,10 +152,18 @@ struct BatteryEditorView: View {
value: "Voltage", value: "Voltage",
comment: "Placeholder for voltage text field" comment: "Placeholder for voltage text field"
), ),
value: $configuration.nominalVoltage, text: $voltageInput
format: .number
) )
.keyboardType(.decimalPad) .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( Button(
NSLocalizedString( NSLocalizedString(
@@ -174,7 +173,10 @@ struct BatteryEditorView: View {
comment: "Cancel button title for edit alerts" comment: "Cancel button title for edit alerts"
), ),
role: .cancel role: .cancel
) { editingField = nil } ) {
editingField = nil
voltageInput = ""
}
Button( Button(
NSLocalizedString( NSLocalizedString(
@@ -184,11 +186,11 @@ struct BatteryEditorView: View {
comment: "Save button title for edit alerts" comment: "Save button title for edit alerts"
) )
) { ) {
editingField = nil if let parsed = parseInput(voltageInput) {
let normalized = normalizedVoltage(for: configuration.nominalVoltage) configuration.nominalVoltage = roundToTenth(parsed)
if abs(normalized - configuration.nominalVoltage) > 0.000001 {
configuration.nominalVoltage = normalized
} }
editingField = nil
voltageInput = ""
} }
} message: { } message: {
Text( Text(
@@ -209,7 +211,12 @@ struct BatteryEditorView: View {
), ),
isPresented: Binding( isPresented: Binding(
get: { editingField == .capacity }, get: { editingField == .capacity },
set: { if !$0 { editingField = nil } } set: { isPresented in
if !isPresented {
editingField = nil
capacityInput = ""
}
}
) )
) { ) {
TextField( TextField(
@@ -219,10 +226,18 @@ struct BatteryEditorView: View {
value: "Capacity", value: "Capacity",
comment: "Placeholder for capacity text field" comment: "Placeholder for capacity text field"
), ),
value: $configuration.capacityAmpHours, text: $capacityInput
format: .number
) )
.keyboardType(.decimalPad) .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( Button(
NSLocalizedString( NSLocalizedString(
@@ -232,7 +247,10 @@ struct BatteryEditorView: View {
comment: "Cancel button title for edit alerts" comment: "Cancel button title for edit alerts"
), ),
role: .cancel role: .cancel
) { editingField = nil } ) {
editingField = nil
capacityInput = ""
}
Button( Button(
NSLocalizedString( NSLocalizedString(
@@ -242,11 +260,11 @@ struct BatteryEditorView: View {
comment: "Save button title for edit alerts" comment: "Save button title for edit alerts"
) )
) { ) {
editingField = nil if let parsed = parseInput(capacityInput) {
let normalized = normalizedCapacity(for: configuration.capacityAmpHours) configuration.capacityAmpHours = roundToTenth(parsed)
if abs(normalized - configuration.capacityAmpHours) > 0.000001 {
configuration.capacityAmpHours = normalized
} }
editingField = nil
capacityInput = ""
} }
} message: { } message: {
Text( Text(
@@ -365,33 +383,39 @@ struct BatteryEditorView: View {
VStack(spacing: 30) { VStack(spacing: 30) {
SliderSection( SliderSection(
title: sliderVoltageTitle, title: sliderVoltageTitle,
value: $configuration.nominalVoltage, value: Binding(
range: 6...60, get: { configuration.nominalVoltage },
set: { newValue in
if editingField == .voltage {
configuration.nominalVoltage = roundToTenth(newValue)
} else {
configuration.nominalVoltage = normalizedVoltage(for: newValue)
}
}
),
range: voltageSliderRange,
unit: "V", unit: "V",
tapAction: { editingField = .voltage }, tapAction: beginVoltageEditing,
snapValues: voltageSnapValues 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( SliderSection(
title: sliderCapacityTitle, title: sliderCapacityTitle,
value: $configuration.capacityAmpHours, value: Binding(
range: 5...1000, get: { configuration.capacityAmpHours },
set: { newValue in
if editingField == .capacity {
configuration.capacityAmpHours = roundToTenth(newValue)
} else {
configuration.capacityAmpHours = normalizedCapacity(for: newValue)
}
}
),
range: capacitySliderRange,
unit: "Ah", unit: "Ah",
tapAction: { editingField = .capacity }, tapAction: beginCapacityEditing,
snapValues: capacitySnapValues 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() .padding()
.background( .background(
@@ -416,6 +440,36 @@ struct BatteryEditorView: View {
return rounded 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? { 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 } guard let closest = options.min(by: { abs($0 - value) < abs($1 - value) }) else { return nil }
return abs(closest - value) <= tolerance ? closest : nil return abs(closest - value) <= tolerance ? closest : nil
@@ -445,6 +499,7 @@ struct BatteryEditorView: View {
private static let numberFormatter: NumberFormatter = { private static let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter() let formatter = NumberFormatter()
formatter.locale = .current formatter.locale = .current
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 0 formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1 formatter.maximumFractionDigits = 1
return formatter return formatter
@@ -455,15 +510,6 @@ struct BatteryEditorView: View {
return "\(numberString) \(unit)" return "\(numberString) \(unit)"
} }
private func save() {
onSave(configuration)
dismiss()
}
private func cancel() {
onCancel()
dismiss()
}
} }
#Preview { #Preview {
@@ -471,8 +517,7 @@ struct BatteryEditorView: View {
return NavigationStack { return NavigationStack {
BatteryEditorView( BatteryEditorView(
configuration: BatteryConfiguration(name: "House Bank", system: previewSystem), configuration: BatteryConfiguration(name: "House Bank", system: previewSystem),
onSave: { _ in }, onSave: { _ in }
onCancel: {}
) )
} }
} }

View File

@@ -165,19 +165,14 @@ struct LoadsView: View {
.navigationDestination(item: $newLoadToEdit) { load in .navigationDestination(item: $newLoadToEdit) { load in
CalculatorView(savedLoad: load) CalculatorView(savedLoad: load)
} }
.sheet(item: $batteryDraft) { draft in .navigationDestination(item: $batteryDraft) { draft in
NavigationStack { BatteryEditorView(
BatteryEditorView( configuration: draft,
configuration: draft, onSave: { configuration in
onSave: { configuration in saveBattery(configuration)
saveBattery(configuration) batteryDraft = nil
batteryDraft = nil }
}, )
onCancel: {
batteryDraft = nil
}
)
}
} }
.sheet(isPresented: $showingComponentLibrary) { .sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in ComponentLibraryView { item in

View File

@@ -157,7 +157,7 @@
"overview.loads.empty.title" = "Noch keine Verbraucher eingerichtet"; "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.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.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."; "overview.runtime.unavailable" = "Trage Batteriekapazität und Leistungsaufnahme ein, um die Laufzeit zu schätzen.";
"battery.bank.warning.voltage.short" = "Spannung"; "battery.bank.warning.voltage.short" = "Spannung";
"battery.bank.warning.capacity.short" = "Kapazität"; "battery.bank.warning.capacity.short" = "Kapazität";