From f171c3d6b209e84c79123ac298df7ed2f5956fd6 Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Tue, 21 Oct 2025 16:24:25 +0200 Subject: [PATCH] free value entry in the battery editor --- Cable/Base.lproj/Localizable.strings | 2 +- Cable/BatteryConfiguration.swift | 20 ++- Cable/BatteryEditorView.swift | 201 ++++++++++++++++----------- Cable/LoadsView.swift | 21 ++- Cable/de.lproj/Localizable.strings | 2 +- 5 files changed, 152 insertions(+), 94 deletions(-) diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index be7a07a..3189797 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -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"; diff --git a/Cable/BatteryConfiguration.swift b/Cable/BatteryConfiguration.swift index 715689d..369bfe1 100644 --- a/Cable/BatteryConfiguration.swift +++ b/Cable/BatteryConfiguration.swift @@ -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) + } +} diff --git a/Cable/BatteryEditorView.swift b/Cable/BatteryEditorView.swift index 4281ef2..5049847 100644 --- a/Cable/BatteryEditorView.swift +++ b/Cable/BatteryEditorView.swift @@ -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 { + 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 { + 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 } ) } } diff --git a/Cable/LoadsView.swift b/Cable/LoadsView.swift index 3699179..67f7990 100644 --- a/Cable/LoadsView.swift +++ b/Cable/LoadsView.swift @@ -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 diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index 303ad03..a71c3d3 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -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";