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.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";
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: {}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user