Compare commits
6 Commits
4827ea4cdb
...
858bf2a305
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
858bf2a305 | ||
|
|
f171c3d6b2 | ||
|
|
a6f2f8fc91 | ||
|
|
1fef290abf | ||
|
|
df315ea7d8 | ||
|
|
2a2c48e89f |
@@ -68,10 +68,32 @@
|
|||||||
"system.icon.keywords.cold" = "cold, freeze, cool";
|
"system.icon.keywords.cold" = "cold, freeze, cool";
|
||||||
"system.icon.keywords.climate" = "climate, hvac, temperature";
|
"system.icon.keywords.climate" = "climate, hvac, temperature";
|
||||||
|
|
||||||
|
"tab.overview" = "Overview";
|
||||||
"tab.components" = "Components";
|
"tab.components" = "Components";
|
||||||
"tab.batteries" = "Batteries";
|
"tab.batteries" = "Batteries";
|
||||||
"tab.chargers" = "Chargers";
|
"tab.chargers" = "Chargers";
|
||||||
|
|
||||||
|
"loads.overview.header.title" = "Load Overview";
|
||||||
|
"loads.overview.metric.count" = "Loads";
|
||||||
|
"loads.overview.metric.current" = "Total Current";
|
||||||
|
"loads.overview.metric.power" = "Total Power";
|
||||||
|
"loads.overview.status.missing_details.title" = "Missing load details";
|
||||||
|
"loads.overview.status.missing_details.message" = "Enter cable length and wire size for %d %@ to see accurate recommendations.";
|
||||||
|
"loads.overview.status.missing_details.singular" = "load";
|
||||||
|
"loads.overview.status.missing_details.plural" = "loads";
|
||||||
|
"loads.overview.status.missing_details.banner" = "Finish configuring your loads";
|
||||||
|
"loads.metric.fuse" = "Fuse";
|
||||||
|
"loads.metric.cable" = "Cable";
|
||||||
|
"loads.metric.length" = "Length";
|
||||||
|
"overview.system.header.title" = "System Overview";
|
||||||
|
"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 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";
|
||||||
|
|
||||||
"battery.bank.header.title" = "Battery Bank";
|
"battery.bank.header.title" = "Battery Bank";
|
||||||
"battery.bank.metric.count" = "Batteries";
|
"battery.bank.metric.count" = "Batteries";
|
||||||
"battery.bank.metric.capacity" = "Capacity";
|
"battery.bank.metric.capacity" = "Capacity";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct BatteriesView: View {
|
struct BatteriesView: View {
|
||||||
|
@Binding var editMode: EditMode
|
||||||
let system: ElectricalSystem
|
let system: ElectricalSystem
|
||||||
let batteries: [SavedBattery]
|
let batteries: [SavedBattery]
|
||||||
let onEdit: (SavedBattery) -> Void
|
let onEdit: (SavedBattery) -> Void
|
||||||
@@ -120,6 +121,7 @@ struct BatteriesView: View {
|
|||||||
init(
|
init(
|
||||||
system: ElectricalSystem,
|
system: ElectricalSystem,
|
||||||
batteries: [SavedBattery],
|
batteries: [SavedBattery],
|
||||||
|
editMode: Binding<EditMode> = .constant(.inactive),
|
||||||
onEdit: @escaping (SavedBattery) -> Void = { _ in },
|
onEdit: @escaping (SavedBattery) -> Void = { _ in },
|
||||||
onDelete: @escaping (IndexSet) -> Void = { _ in }
|
onDelete: @escaping (IndexSet) -> Void = { _ in }
|
||||||
) {
|
) {
|
||||||
@@ -127,6 +129,7 @@ struct BatteriesView: View {
|
|||||||
self.batteries = batteries
|
self.batteries = batteries
|
||||||
self.onEdit = onEdit
|
self.onEdit = onEdit
|
||||||
self.onDelete = onDelete
|
self.onDelete = onDelete
|
||||||
|
self._editMode = editMode
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -145,6 +148,7 @@ struct BatteriesView: View {
|
|||||||
batteryRow(for: battery)
|
batteryRow(for: battery)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.disabled(editMode == .active)
|
||||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
@@ -153,6 +157,7 @@ struct BatteriesView: View {
|
|||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
|
.environment(\.editMode, $editMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -183,9 +188,6 @@ struct BatteriesView: View {
|
|||||||
Text(bankTitle)
|
Text(bankTitle)
|
||||||
.font(.headline.weight(.semibold))
|
.font(.headline.weight(.semibold))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(system.name)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ViewThatFits(in: .horizontal) {
|
ViewThatFits(in: .horizontal) {
|
||||||
@@ -602,6 +604,7 @@ private enum BatteriesViewPreviewData {
|
|||||||
#Preview {
|
#Preview {
|
||||||
BatteriesView(
|
BatteriesView(
|
||||||
system: BatteriesViewPreviewData.system,
|
system: BatteriesViewPreviewData.system,
|
||||||
batteries: BatteriesViewPreviewData.batteries
|
batteries: BatteriesViewPreviewData.batteries,
|
||||||
|
editMode: .constant(.inactive)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import Foundation
|
||||||
|
|
||||||
struct CalculatorView: View {
|
struct CalculatorView: View {
|
||||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||||
@@ -17,6 +18,10 @@ struct CalculatorView: View {
|
|||||||
@State private var showingLibrary = false
|
@State private var showingLibrary = false
|
||||||
@State private var isWattMode = false
|
@State private var isWattMode = false
|
||||||
@State private var editingValue: EditingValue? = nil
|
@State private var editingValue: EditingValue? = nil
|
||||||
|
@State private var voltageInput: String = ""
|
||||||
|
@State private var currentInput: String = ""
|
||||||
|
@State private var powerInput: String = ""
|
||||||
|
@State private var lengthInput: String = ""
|
||||||
@State private var showingLoadEditor = false
|
@State private var showingLoadEditor = false
|
||||||
@State private var presentedAffiliateLink: AffiliateLinkInfo?
|
@State private var presentedAffiliateLink: AffiliateLinkInfo?
|
||||||
@State private var completedItemIDs: Set<String>
|
@State private var completedItemIDs: Set<String>
|
||||||
@@ -32,6 +37,15 @@ struct CalculatorView: View {
|
|||||||
case voltage, current, power, length
|
case voltage, current, power, length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let editFormatter: NumberFormatter = {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.locale = .current
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
formatter.minimumFractionDigits = 0
|
||||||
|
formatter.maximumFractionDigits = 1
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
struct AffiliateLinkInfo: Identifiable, Equatable {
|
struct AffiliateLinkInfo: Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
let affiliateURL: URL?
|
let affiliateURL: URL?
|
||||||
@@ -123,13 +137,34 @@ struct CalculatorView: View {
|
|||||||
}
|
}
|
||||||
.alert("Edit Length", isPresented: Binding(
|
.alert("Edit Length", isPresented: Binding(
|
||||||
get: { editingValue == .length },
|
get: { editingValue == .length },
|
||||||
set: { if !$0 { editingValue = nil } }
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
editingValue = nil
|
||||||
|
lengthInput = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
)) {
|
)) {
|
||||||
TextField("Length", value: $calculator.length, format: .number)
|
TextField("Length", text: $lengthInput)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
Button("Cancel", role: .cancel) { editingValue = nil }
|
.onAppear {
|
||||||
Button("Save") {
|
if lengthInput.isEmpty {
|
||||||
|
lengthInput = formattedValue(calculator.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: lengthInput) { newValue in
|
||||||
|
guard editingValue == .length, let parsed = parseInput(newValue) else { return }
|
||||||
|
calculator.length = roundToTenth(parsed)
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
editingValue = nil
|
editingValue = nil
|
||||||
|
lengthInput = ""
|
||||||
|
}
|
||||||
|
Button("Save") {
|
||||||
|
if let parsed = parseInput(lengthInput) {
|
||||||
|
calculator.length = roundToTenth(parsed)
|
||||||
|
}
|
||||||
|
editingValue = nil
|
||||||
|
lengthInput = ""
|
||||||
calculator.objectWillChange.send()
|
calculator.objectWillChange.send()
|
||||||
autoUpdateSavedLoad()
|
autoUpdateSavedLoad()
|
||||||
}
|
}
|
||||||
@@ -138,18 +173,39 @@ struct CalculatorView: View {
|
|||||||
}
|
}
|
||||||
.alert("Edit Voltage", isPresented: Binding(
|
.alert("Edit Voltage", isPresented: Binding(
|
||||||
get: { editingValue == .voltage },
|
get: { editingValue == .voltage },
|
||||||
set: { if !$0 { editingValue = nil } }
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
editingValue = nil
|
||||||
|
voltageInput = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
)) {
|
)) {
|
||||||
TextField("Voltage", value: $calculator.voltage, format: .number)
|
TextField("Voltage", text: $voltageInput)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
Button("Cancel", role: .cancel) { editingValue = nil }
|
.onAppear {
|
||||||
|
if voltageInput.isEmpty {
|
||||||
|
voltageInput = formattedValue(calculator.voltage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: voltageInput) { newValue in
|
||||||
|
guard editingValue == .voltage, let parsed = parseInput(newValue) else { return }
|
||||||
|
calculator.voltage = roundToTenth(parsed)
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
editingValue = nil
|
||||||
|
voltageInput = ""
|
||||||
|
}
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
|
if let parsed = parseInput(voltageInput) {
|
||||||
|
calculator.voltage = roundToTenth(parsed)
|
||||||
|
}
|
||||||
editingValue = nil
|
editingValue = nil
|
||||||
if isWattMode {
|
if isWattMode {
|
||||||
calculator.updateFromPower()
|
calculator.updateFromPower()
|
||||||
} else {
|
} else {
|
||||||
calculator.updateFromCurrent()
|
calculator.updateFromCurrent()
|
||||||
}
|
}
|
||||||
|
voltageInput = ""
|
||||||
calculator.objectWillChange.send()
|
calculator.objectWillChange.send()
|
||||||
autoUpdateSavedLoad()
|
autoUpdateSavedLoad()
|
||||||
}
|
}
|
||||||
@@ -158,14 +214,35 @@ struct CalculatorView: View {
|
|||||||
}
|
}
|
||||||
.alert("Edit Current", isPresented: Binding(
|
.alert("Edit Current", isPresented: Binding(
|
||||||
get: { editingValue == .current },
|
get: { editingValue == .current },
|
||||||
set: { if !$0 { editingValue = nil } }
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
editingValue = nil
|
||||||
|
currentInput = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
)) {
|
)) {
|
||||||
TextField("Current", value: $calculator.current, format: .number)
|
TextField("Current", text: $currentInput)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
Button("Cancel", role: .cancel) { editingValue = nil }
|
.onAppear {
|
||||||
|
if currentInput.isEmpty {
|
||||||
|
currentInput = formattedValue(calculator.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: currentInput) { newValue in
|
||||||
|
guard editingValue == .current, let parsed = parseInput(newValue) else { return }
|
||||||
|
calculator.current = roundToTenth(parsed)
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
editingValue = nil
|
||||||
|
currentInput = ""
|
||||||
|
}
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
|
if let parsed = parseInput(currentInput) {
|
||||||
|
calculator.current = roundToTenth(parsed)
|
||||||
|
}
|
||||||
editingValue = nil
|
editingValue = nil
|
||||||
calculator.updateFromCurrent()
|
calculator.updateFromCurrent()
|
||||||
|
currentInput = ""
|
||||||
calculator.objectWillChange.send()
|
calculator.objectWillChange.send()
|
||||||
autoUpdateSavedLoad()
|
autoUpdateSavedLoad()
|
||||||
}
|
}
|
||||||
@@ -174,14 +251,35 @@ struct CalculatorView: View {
|
|||||||
}
|
}
|
||||||
.alert("Edit Power", isPresented: Binding(
|
.alert("Edit Power", isPresented: Binding(
|
||||||
get: { editingValue == .power },
|
get: { editingValue == .power },
|
||||||
set: { if !$0 { editingValue = nil } }
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
editingValue = nil
|
||||||
|
powerInput = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
)) {
|
)) {
|
||||||
TextField("Power", value: $calculator.power, format: .number)
|
TextField("Power", text: $powerInput)
|
||||||
.keyboardType(.decimalPad)
|
.keyboardType(.decimalPad)
|
||||||
Button("Cancel", role: .cancel) { editingValue = nil }
|
.onAppear {
|
||||||
|
if powerInput.isEmpty {
|
||||||
|
powerInput = formattedValue(calculator.power)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: powerInput) { newValue in
|
||||||
|
guard editingValue == .power, let parsed = parseInput(newValue) else { return }
|
||||||
|
calculator.power = roundToNearestFive(parsed)
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
editingValue = nil
|
||||||
|
powerInput = ""
|
||||||
|
}
|
||||||
Button("Save") {
|
Button("Save") {
|
||||||
|
if let parsed = parseInput(powerInput) {
|
||||||
|
calculator.power = roundToNearestFive(parsed)
|
||||||
|
}
|
||||||
editingValue = nil
|
editingValue = nil
|
||||||
calculator.updateFromPower()
|
calculator.updateFromPower()
|
||||||
|
powerInput = ""
|
||||||
calculator.objectWillChange.send()
|
calculator.objectWillChange.send()
|
||||||
autoUpdateSavedLoad()
|
autoUpdateSavedLoad()
|
||||||
}
|
}
|
||||||
@@ -331,7 +429,7 @@ struct CalculatorView: View {
|
|||||||
VStack() {
|
VStack() {
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
editingValue = .voltage
|
beginVoltageEditing()
|
||||||
}) {
|
}) {
|
||||||
Text(String(format: "%.1fV", calculator.voltage))
|
Text(String(format: "%.1fV", calculator.voltage))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -405,7 +503,11 @@ struct CalculatorView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
editingValue = isWattMode ? .power : .current
|
if isWattMode {
|
||||||
|
beginPowerEditing()
|
||||||
|
} else {
|
||||||
|
beginCurrentEditing()
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Text(String(format: "%.1fA", calculator.current))
|
Text(String(format: "%.1fA", calculator.current))
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
@@ -414,7 +516,11 @@ struct CalculatorView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
editingValue = isWattMode ? .current : .power
|
if isWattMode {
|
||||||
|
beginCurrentEditing()
|
||||||
|
} else {
|
||||||
|
beginPowerEditing()
|
||||||
|
}
|
||||||
}) {
|
}) {
|
||||||
Text(String(format: "%.0fW", calculator.calculatedPower))
|
Text(String(format: "%.0fW", calculator.calculatedPower))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
@@ -459,7 +565,7 @@ struct CalculatorView: View {
|
|||||||
|
|
||||||
Text("•").foregroundColor(.secondary)
|
Text("•").foregroundColor(.secondary)
|
||||||
|
|
||||||
Button(action: { editingValue = .length }) {
|
Button(action: { beginLengthEditing() }) {
|
||||||
Text(String(format: "%.1f %@", calculator.length, unitSettings.unitSystem.lengthUnit))
|
Text(String(format: "%.1f %@", calculator.length, unitSettings.unitSystem.lengthUnit))
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
@@ -660,14 +766,64 @@ struct CalculatorView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var voltageSliderRange: ClosedRange<Double> {
|
||||||
|
let upperBound = max(48, calculator.voltage)
|
||||||
|
return 0...upperBound
|
||||||
|
}
|
||||||
|
|
||||||
|
private var powerSliderRange: ClosedRange<Double> {
|
||||||
|
let upperBound = max(2000, calculator.power)
|
||||||
|
return 0...upperBound
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentSliderRange: ClosedRange<Double> {
|
||||||
|
let upperBound = max(100, calculator.current)
|
||||||
|
return 0...upperBound
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lengthSliderRange: ClosedRange<Double> {
|
||||||
|
let baseMax = unitSettings.unitSystem == .metric ? 20.0 : 60.0
|
||||||
|
let upperBound = max(baseMax, calculator.length)
|
||||||
|
return 0...upperBound
|
||||||
|
}
|
||||||
|
|
||||||
|
private var voltageSnapValues: [Double] {
|
||||||
|
[3.3, 5.0, 6.0, 9.0, 12.0, 13.8, 24.0, 28.0, 48.0]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var powerSnapValues: [Double] {
|
||||||
|
[5, 10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 125, 150, 200, 250, 300, 400, 500, 600, 750, 1000, 1250, 1500, 2000]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentSnapValues: [Double] {
|
||||||
|
[0.5, 1, 2, 3, 4, 5, 7.5, 10, 12, 15, 20, 25, 30, 40, 50, 60, 75, 80, 100]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lengthSnapValues: [Double] {
|
||||||
|
if unitSettings.unitSystem == .metric {
|
||||||
|
return [0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7.5, 10, 12, 15, 20]
|
||||||
|
} else {
|
||||||
|
return [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var voltageSlider: some View {
|
private var voltageSlider: some View {
|
||||||
SliderSection(title: String(localized: "slider.voltage.title", comment: "Title for the voltage slider"),
|
SliderSection(title: String(localized: "slider.voltage.title", comment: "Title for the voltage slider"),
|
||||||
value: $calculator.voltage,
|
value: Binding(
|
||||||
range: 3...48,
|
get: { calculator.voltage },
|
||||||
unit: "V",
|
set: { newValue in
|
||||||
tapAction: { editingValue = .voltage },
|
if editingValue == .voltage {
|
||||||
snapValues: [3.3, 5.0, 6.0, 9.0, 12.0, 13.8, 24.0, 28.0, 48.0])
|
calculator.voltage = roundToTenth(newValue)
|
||||||
|
} else {
|
||||||
|
calculator.voltage = normalizedVoltage(for: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
range: voltageSliderRange,
|
||||||
|
unit: "V",
|
||||||
|
tapAction: beginVoltageEditing,
|
||||||
|
snapValues: editingValue == .voltage ? nil : voltageSnapValues)
|
||||||
.onChange(of: calculator.voltage) {
|
.onChange(of: calculator.voltage) {
|
||||||
if isWattMode {
|
if isWattMode {
|
||||||
calculator.updateFromPower()
|
calculator.updateFromPower()
|
||||||
@@ -683,8 +839,17 @@ struct CalculatorView: View {
|
|||||||
private var currentPowerSlider: some View {
|
private var currentPowerSlider: some View {
|
||||||
if isWattMode {
|
if isWattMode {
|
||||||
SliderSection(title: String(localized: "slider.power.title", comment: "Title for the power slider"),
|
SliderSection(title: String(localized: "slider.power.title", comment: "Title for the power slider"),
|
||||||
value: $calculator.power,
|
value: Binding(
|
||||||
range: 0...2000,
|
get: { calculator.power },
|
||||||
|
set: { newValue in
|
||||||
|
if editingValue == .power {
|
||||||
|
calculator.power = roundToNearestFive(newValue)
|
||||||
|
} else {
|
||||||
|
calculator.power = normalizedPower(for: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
range: powerSliderRange,
|
||||||
unit: "W",
|
unit: "W",
|
||||||
buttonText: String(localized: "slider.button.watt", comment: "Button label when showing power control"),
|
buttonText: String(localized: "slider.button.watt", comment: "Button label when showing power control"),
|
||||||
buttonAction: {
|
buttonAction: {
|
||||||
@@ -692,8 +857,8 @@ struct CalculatorView: View {
|
|||||||
calculator.updateFromPower()
|
calculator.updateFromPower()
|
||||||
autoUpdateSavedLoad()
|
autoUpdateSavedLoad()
|
||||||
},
|
},
|
||||||
tapAction: { editingValue = .power },
|
tapAction: beginPowerEditing,
|
||||||
snapValues: [5, 10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 125, 150, 200, 250, 300, 400, 500, 600, 750, 1000, 1250, 1500, 2000])
|
snapValues: editingValue == .power ? nil : powerSnapValues)
|
||||||
.onChange(of: calculator.power) {
|
.onChange(of: calculator.power) {
|
||||||
calculator.updateFromPower()
|
calculator.updateFromPower()
|
||||||
calculator.objectWillChange.send()
|
calculator.objectWillChange.send()
|
||||||
@@ -701,8 +866,17 @@ struct CalculatorView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SliderSection(title: String(localized: "slider.current.title", comment: "Title for the current slider"),
|
SliderSection(title: String(localized: "slider.current.title", comment: "Title for the current slider"),
|
||||||
value: $calculator.current,
|
value: Binding(
|
||||||
range: 0...100,
|
get: { calculator.current },
|
||||||
|
set: { newValue in
|
||||||
|
if editingValue == .current {
|
||||||
|
calculator.current = roundToTenth(newValue)
|
||||||
|
} else {
|
||||||
|
calculator.current = normalizedCurrent(for: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
range: currentSliderRange,
|
||||||
unit: "A",
|
unit: "A",
|
||||||
buttonText: String(localized: "slider.button.ampere", comment: "Button label when showing current control"),
|
buttonText: String(localized: "slider.button.ampere", comment: "Button label when showing current control"),
|
||||||
buttonAction: {
|
buttonAction: {
|
||||||
@@ -710,8 +884,8 @@ struct CalculatorView: View {
|
|||||||
calculator.updateFromCurrent()
|
calculator.updateFromCurrent()
|
||||||
autoUpdateSavedLoad()
|
autoUpdateSavedLoad()
|
||||||
},
|
},
|
||||||
tapAction: { editingValue = .current },
|
tapAction: beginCurrentEditing,
|
||||||
snapValues: [0.5, 1, 2, 3, 4, 5, 7.5, 10, 12, 15, 20, 25, 30, 40, 50, 60, 75, 80, 100])
|
snapValues: editingValue == .current ? nil : currentSnapValues)
|
||||||
.onChange(of: calculator.current) {
|
.onChange(of: calculator.current) {
|
||||||
calculator.updateFromCurrent()
|
calculator.updateFromCurrent()
|
||||||
calculator.objectWillChange.send()
|
calculator.objectWillChange.send()
|
||||||
@@ -726,17 +900,104 @@ struct CalculatorView: View {
|
|||||||
comment: "Title format for the cable length slider"
|
comment: "Title format for the cable length slider"
|
||||||
)
|
)
|
||||||
return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit),
|
return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit),
|
||||||
value: $calculator.length,
|
value: Binding(
|
||||||
range: 0...20,
|
get: { calculator.length },
|
||||||
|
set: { newValue in
|
||||||
|
if editingValue == .length {
|
||||||
|
calculator.length = roundToTenth(newValue)
|
||||||
|
} else {
|
||||||
|
calculator.length = normalizedLength(for: newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
range: lengthSliderRange,
|
||||||
unit: unitSettings.unitSystem.lengthUnit,
|
unit: unitSettings.unitSystem.lengthUnit,
|
||||||
tapAction: { editingValue = .length },
|
tapAction: beginLengthEditing,
|
||||||
snapValues: unitSettings.unitSystem == .metric ?
|
snapValues: editingValue == .length ? nil : lengthSnapValues)
|
||||||
[0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7.5, 10, 12, 15, 20] :
|
.onChange(of: calculator.length) {
|
||||||
[1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60])
|
calculator.objectWillChange.send()
|
||||||
.onChange(of: calculator.length) {
|
autoUpdateSavedLoad()
|
||||||
calculator.objectWillChange.send()
|
}
|
||||||
autoUpdateSavedLoad()
|
}
|
||||||
|
|
||||||
|
private func normalizedVoltage(for value: Double) -> Double {
|
||||||
|
let rounded = roundToTenth(value)
|
||||||
|
if let snap = nearestValue(to: rounded, in: voltageSnapValues, tolerance: 0.3) {
|
||||||
|
return snap
|
||||||
}
|
}
|
||||||
|
return rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedCurrent(for value: Double) -> Double {
|
||||||
|
let rounded = roundToTenth(value)
|
||||||
|
if let snap = nearestValue(to: rounded, in: currentSnapValues, tolerance: 0.3) {
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
return rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedPower(for value: Double) -> Double {
|
||||||
|
if let snap = nearestValue(to: value, in: powerSnapValues, tolerance: 2.5) {
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
return roundToNearestFive(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedLength(for value: Double) -> Double {
|
||||||
|
let rounded = roundToTenth(value)
|
||||||
|
if let snap = nearestValue(to: rounded, in: lengthSnapValues, tolerance: 0.5) {
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
return rounded
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
private func roundToTenth(_ value: Double) -> Double {
|
||||||
|
max(0, (value * 10).rounded() / 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func roundToNearestFive(_ value: Double) -> Double {
|
||||||
|
max(0, (value / 5).rounded() * 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedValue(_ value: Double) -> String {
|
||||||
|
Self.editFormatter.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.editFormatter.number(from: trimmed)?.doubleValue {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
let decimalSeparator = Locale.current.decimalSeparator ?? "."
|
||||||
|
let altSeparator = decimalSeparator == "." ? "," : "."
|
||||||
|
let normalized = trimmed.replacingOccurrences(of: altSeparator, with: decimalSeparator)
|
||||||
|
return Self.editFormatter.number(from: normalized)?.doubleValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginVoltageEditing() {
|
||||||
|
voltageInput = formattedValue(calculator.voltage)
|
||||||
|
editingValue = .voltage
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginCurrentEditing() {
|
||||||
|
currentInput = formattedValue(calculator.current)
|
||||||
|
editingValue = .current
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginPowerEditing() {
|
||||||
|
powerInput = formattedValue(calculator.power)
|
||||||
|
editingValue = .power
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginLengthEditing() {
|
||||||
|
lengthInput = formattedValue(calculator.length)
|
||||||
|
editingValue = .length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
76
Cable/LoadConfigurationStatus.swift
Normal file
76
Cable/LoadConfigurationStatus.swift
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum LoadConfigurationStatus: Identifiable, Equatable {
|
||||||
|
case missingDetails(count: Int)
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .missingDetails(let count):
|
||||||
|
return "missing-details-\(count)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .missingDetails:
|
||||||
|
return "exclamationmark.triangle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tint: Color {
|
||||||
|
switch self {
|
||||||
|
case .missingDetails:
|
||||||
|
return .orange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bannerText: String {
|
||||||
|
switch self {
|
||||||
|
case .missingDetails:
|
||||||
|
return NSLocalizedString(
|
||||||
|
"loads.overview.status.missing_details.banner",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Finish configuring your loads",
|
||||||
|
comment: "Short banner text describing loads that need additional details"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailInfo() -> LoadStatusDetail {
|
||||||
|
switch self {
|
||||||
|
case .missingDetails(let count):
|
||||||
|
let title = NSLocalizedString(
|
||||||
|
"loads.overview.status.missing_details.title",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Missing load details",
|
||||||
|
comment: "Alert title when loads are missing required details"
|
||||||
|
)
|
||||||
|
let format = NSLocalizedString(
|
||||||
|
"loads.overview.status.missing_details.message",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Enter cable length and wire size for %d %@ to see accurate recommendations.",
|
||||||
|
comment: "Alert message when loads are missing required details"
|
||||||
|
)
|
||||||
|
let loadWord = count == 1
|
||||||
|
? NSLocalizedString(
|
||||||
|
"loads.overview.status.missing_details.singular",
|
||||||
|
bundle: .main,
|
||||||
|
value: "load",
|
||||||
|
comment: "Singular noun for load"
|
||||||
|
)
|
||||||
|
: NSLocalizedString(
|
||||||
|
"loads.overview.status.missing_details.plural",
|
||||||
|
bundle: .main,
|
||||||
|
value: "loads",
|
||||||
|
comment: "Plural noun for loads"
|
||||||
|
)
|
||||||
|
let message = String(format: format, count, loadWord)
|
||||||
|
return LoadStatusDetail(title: title, message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoadStatusDetail {
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
@@ -20,8 +20,10 @@ struct LoadsView: View {
|
|||||||
@State private var hasOpenedLoadOnAppear = false
|
@State private var hasOpenedLoadOnAppear = false
|
||||||
@State private var showingComponentLibrary = false
|
@State private var showingComponentLibrary = false
|
||||||
@State private var showingSystemBOM = false
|
@State private var showingSystemBOM = false
|
||||||
@State private var selectedComponentTab: ComponentTab = .components
|
@State private var selectedComponentTab: ComponentTab = .overview
|
||||||
@State private var batteryDraft: BatteryConfiguration?
|
@State private var batteryDraft: BatteryConfiguration?
|
||||||
|
@State private var activeStatus: LoadConfigurationStatus?
|
||||||
|
@State private var editMode: EditMode = .inactive
|
||||||
|
|
||||||
let system: ElectricalSystem
|
let system: ElectricalSystem
|
||||||
private let presentSystemEditorOnAppear: Bool
|
private let presentSystemEditorOnAppear: Bool
|
||||||
@@ -43,52 +45,62 @@ struct LoadsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if savedLoads.isEmpty {
|
TabView(selection: $selectedComponentTab) {
|
||||||
emptyStateView
|
overviewTab
|
||||||
} else {
|
.tag(ComponentTab.overview)
|
||||||
TabView(selection: $selectedComponentTab) {
|
.tabItem {
|
||||||
componentsTab
|
Label(
|
||||||
.tag(ComponentTab.components)
|
String(
|
||||||
.tabItem {
|
localized: "tab.overview",
|
||||||
Label(
|
bundle: .main,
|
||||||
String(
|
comment: "Tab title for system overview"
|
||||||
localized: "tab.components",
|
),
|
||||||
bundle: .main,
|
systemImage: "rectangle.3.group"
|
||||||
comment: "Tab title for components list"
|
)
|
||||||
),
|
}
|
||||||
systemImage: "square.stack.3d.up"
|
componentsTab
|
||||||
)
|
.tag(ComponentTab.components)
|
||||||
}
|
.tabItem {
|
||||||
BatteriesView(
|
Label(
|
||||||
system: system,
|
String(
|
||||||
batteries: savedBatteries,
|
localized: "tab.components",
|
||||||
onEdit: { editBattery($0) },
|
bundle: .main,
|
||||||
onDelete: deleteBatteries
|
comment: "Tab title for components list"
|
||||||
)
|
),
|
||||||
.tag(ComponentTab.batteries)
|
systemImage: "square.stack.3d.up"
|
||||||
.tabItem {
|
)
|
||||||
Label(
|
}
|
||||||
String(
|
BatteriesView(
|
||||||
localized: "tab.batteries",
|
system: system,
|
||||||
bundle: .main,
|
batteries: savedBatteries,
|
||||||
comment: "Tab title for battery configurations"
|
editMode: $editMode,
|
||||||
),
|
onEdit: { editBattery($0) },
|
||||||
systemImage: "battery.100"
|
onDelete: deleteBatteries
|
||||||
)
|
)
|
||||||
}
|
.environment(\.editMode, $editMode)
|
||||||
ChargersView(system: system)
|
.tag(ComponentTab.batteries)
|
||||||
.tag(ComponentTab.chargers)
|
.tabItem {
|
||||||
.tabItem {
|
Label(
|
||||||
Label(
|
String(
|
||||||
String(
|
localized: "tab.batteries",
|
||||||
localized: "tab.chargers",
|
bundle: .main,
|
||||||
bundle: .main,
|
comment: "Tab title for battery configurations"
|
||||||
comment: "Tab title for chargers view"
|
),
|
||||||
),
|
systemImage: "battery.100"
|
||||||
systemImage: "bolt.fill"
|
)
|
||||||
)
|
}
|
||||||
}
|
ChargersView(system: system)
|
||||||
}
|
.tag(ComponentTab.chargers)
|
||||||
|
.tabItem {
|
||||||
|
Label(
|
||||||
|
String(
|
||||||
|
localized: "tab.chargers",
|
||||||
|
bundle: .main,
|
||||||
|
comment: "Tab title for chargers view"
|
||||||
|
),
|
||||||
|
systemImage: "bolt.fill"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -118,7 +130,15 @@ struct LoadsView: View {
|
|||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack {
|
HStack {
|
||||||
if !savedLoads.isEmpty && selectedComponentTab == .components {
|
if selectedComponentTab == .components {
|
||||||
|
Button(action: {
|
||||||
|
showingComponentLibrary = true
|
||||||
|
}) {
|
||||||
|
Image(systemName: "books.vertical")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("component-library-button")
|
||||||
|
}
|
||||||
|
if !savedLoads.isEmpty && (selectedComponentTab == .components || selectedComponentTab == .overview) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingSystemBOM = true
|
showingSystemBOM = true
|
||||||
}) {
|
}) {
|
||||||
@@ -132,8 +152,12 @@ struct LoadsView: View {
|
|||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
}
|
}
|
||||||
.disabled(selectedComponentTab == .chargers)
|
.disabled(selectedComponentTab == .chargers)
|
||||||
if selectedComponentTab == .components || selectedComponentTab == .batteries {
|
if selectedComponentTab == .components {
|
||||||
EditButton()
|
EditButton()
|
||||||
|
.disabled(savedLoads.isEmpty)
|
||||||
|
} else if selectedComponentTab == .batteries {
|
||||||
|
EditButton()
|
||||||
|
.disabled(savedBatteries.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,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
|
||||||
@@ -187,6 +206,23 @@ struct LoadsView: View {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.alert(item: $activeStatus) { status in
|
||||||
|
let detail = status.detailInfo()
|
||||||
|
return Alert(
|
||||||
|
title: Text(detail.title),
|
||||||
|
message: Text(detail.message),
|
||||||
|
dismissButton: .default(
|
||||||
|
Text(
|
||||||
|
NSLocalizedString(
|
||||||
|
"battery.bank.status.dismiss",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Got it",
|
||||||
|
comment: "Dismiss button title for load status alert"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
||||||
hasPresentedSystemEditorOnAppear = true
|
hasPresentedSystemEditorOnAppear = true
|
||||||
@@ -202,65 +238,131 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: selectedComponentTab) { _, newValue in
|
||||||
|
if newValue == .chargers || newValue == .overview {
|
||||||
|
editMode = .inactive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.editMode, $editMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var librarySection: some View {
|
private var overviewTab: some View {
|
||||||
|
SystemOverviewView(
|
||||||
|
system: system,
|
||||||
|
loads: savedLoads,
|
||||||
|
batteries: savedBatteries,
|
||||||
|
onSelectLoads: { selectedComponentTab = .components },
|
||||||
|
onSelectBatteries: { selectedComponentTab = .batteries }
|
||||||
|
)
|
||||||
|
.accessibilityIdentifier("system-overview")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var summarySection: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
Text("Component Library")
|
Text(loadsSummaryTitle)
|
||||||
.font(.headline)
|
.font(.headline.weight(.semibold))
|
||||||
.fontWeight(.semibold)
|
Spacer()
|
||||||
Text("Browse electrical components from VoltPlan")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
ViewThatFits(in: .horizontal) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
Button(action: {
|
summaryMetric(
|
||||||
showingComponentLibrary = true
|
icon: "square.stack.3d.up",
|
||||||
}) {
|
label: loadsCountLabel,
|
||||||
HStack(spacing: 6) {
|
value: "\(savedLoads.count)",
|
||||||
Text("Browse")
|
tint: .blue
|
||||||
.font(.subheadline)
|
)
|
||||||
.fontWeight(.medium)
|
summaryMetric(
|
||||||
Image(systemName: "arrow.up.right")
|
icon: "bolt.fill",
|
||||||
.font(.caption)
|
label: loadsCurrentLabel,
|
||||||
|
value: formattedCurrent(totalCurrent),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "gauge.medium",
|
||||||
|
label: loadsPowerLabel,
|
||||||
|
value: formattedPower(totalPower),
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
summaryMetric(
|
||||||
|
icon: "square.stack.3d.up",
|
||||||
|
label: loadsCountLabel,
|
||||||
|
value: "\(savedLoads.count)",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "bolt.fill",
|
||||||
|
label: loadsCurrentLabel,
|
||||||
|
value: formattedCurrent(totalCurrent),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "gauge.medium",
|
||||||
|
label: loadsPowerLabel,
|
||||||
|
value: formattedPower(totalPower),
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
if let status = loadStatus {
|
||||||
|
Button {
|
||||||
|
activeStatus = status
|
||||||
|
} label: {
|
||||||
|
statusBanner(for: status)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 10)
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
.background(Color(.separator))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var componentsTab: some View {
|
private var componentsTab: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
librarySection
|
summarySection
|
||||||
|
|
||||||
List {
|
if savedLoads.isEmpty {
|
||||||
ForEach(savedLoads) { load in
|
ScrollView {
|
||||||
Button {
|
ComponentsOnboardingView(
|
||||||
selectLoad(load)
|
onCreate: { createNewLoad() },
|
||||||
} label: {
|
onBrowse: { showingComponentLibrary = true }
|
||||||
loadRow(for: load)
|
)
|
||||||
}
|
.padding(.horizontal, 16)
|
||||||
.buttonStyle(.plain)
|
.padding(.top, 32)
|
||||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
.padding(.bottom, 24)
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
}
|
||||||
.onDelete(perform: deleteLoads)
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(savedLoads) { load in
|
||||||
|
Button {
|
||||||
|
selectLoad(load)
|
||||||
|
} label: {
|
||||||
|
loadRow(for: load)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(editMode == .active)
|
||||||
|
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
.onDelete(perform: deleteLoads)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.accessibilityIdentifier("loads-list")
|
||||||
|
.environment(\.editMode, $editMode)
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.accessibilityIdentifier("loads-list")
|
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
@@ -301,44 +403,7 @@ struct LoadsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
ViewThatFits(in: .horizontal) {
|
metricsSection(for: load)
|
||||||
HStack(spacing: 12) {
|
|
||||||
metricBadge(
|
|
||||||
label: "Fuse",
|
|
||||||
value: "\(recommendedFuse(for: load)) A",
|
|
||||||
tint: .pink
|
|
||||||
)
|
|
||||||
metricBadge(
|
|
||||||
label: "Cable",
|
|
||||||
value: wireGaugeString(for: load),
|
|
||||||
tint: .teal
|
|
||||||
)
|
|
||||||
metricBadge(
|
|
||||||
label: "Length",
|
|
||||||
value: lengthString(for: load),
|
|
||||||
tint: .orange
|
|
||||||
)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
metricBadge(
|
|
||||||
label: "Fuse",
|
|
||||||
value: "\(recommendedFuse(for: load)) A",
|
|
||||||
tint: .pink
|
|
||||||
)
|
|
||||||
metricBadge(
|
|
||||||
label: "Cable",
|
|
||||||
value: wireGaugeString(for: load),
|
|
||||||
tint: .teal
|
|
||||||
)
|
|
||||||
metricBadge(
|
|
||||||
label: "Length",
|
|
||||||
value: lengthString(for: load),
|
|
||||||
tint: .orange
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@@ -381,6 +446,197 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func metricsSection(for load: SavedLoad) -> some View {
|
||||||
|
if editMode == .active {
|
||||||
|
horizontalMetrics(for: load)
|
||||||
|
} else {
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
|
horizontalMetrics(for: load)
|
||||||
|
verticalMetrics(for: load)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func horizontalMetrics(for load: SavedLoad) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
metricBadge(
|
||||||
|
label: fuseMetricLabel,
|
||||||
|
value: "\(recommendedFuse(for: load)) A",
|
||||||
|
tint: .pink
|
||||||
|
)
|
||||||
|
metricBadge(
|
||||||
|
label: cableMetricLabel,
|
||||||
|
value: wireGaugeString(for: load),
|
||||||
|
tint: .teal
|
||||||
|
)
|
||||||
|
metricBadge(
|
||||||
|
label: lengthMetricLabel,
|
||||||
|
value: lengthString(for: load),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func verticalMetrics(for load: SavedLoad) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
metricBadge(
|
||||||
|
label: fuseMetricLabel,
|
||||||
|
value: "\(recommendedFuse(for: load)) A",
|
||||||
|
tint: .pink
|
||||||
|
)
|
||||||
|
metricBadge(
|
||||||
|
label: cableMetricLabel,
|
||||||
|
value: wireGaugeString(for: load),
|
||||||
|
tint: .teal
|
||||||
|
)
|
||||||
|
metricBadge(
|
||||||
|
label: lengthMetricLabel,
|
||||||
|
value: lengthString(for: load),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fuseMetricLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"loads.metric.fuse",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Fuse",
|
||||||
|
comment: "Label for fuse metric in load detail row"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cableMetricLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"loads.metric.cable",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Cable",
|
||||||
|
comment: "Label for cable metric in load detail row"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lengthMetricLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"loads.metric.length",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Length",
|
||||||
|
comment: "Label for cable length metric in load detail row"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadsSummaryTitle: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"loads.overview.header.title",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Load Overview",
|
||||||
|
comment: "Title for the loads overview summary section"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadsCountLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"loads.overview.metric.count",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Loads",
|
||||||
|
comment: "Label for number of loads metric"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadsCurrentLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"loads.overview.metric.current",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Total Current",
|
||||||
|
comment: "Label for total load current metric"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadsPowerLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"loads.overview.metric.power",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Total Power",
|
||||||
|
comment: "Label for total load power metric"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalCurrent: Double {
|
||||||
|
savedLoads.reduce(0) { result, load in
|
||||||
|
result + max(load.current, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalPower: Double {
|
||||||
|
savedLoads.reduce(0) { result, load in
|
||||||
|
result + max(load.power, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadStatus: LoadConfigurationStatus? {
|
||||||
|
guard !savedLoads.isEmpty else { return nil }
|
||||||
|
let incompleteLoads = savedLoads.filter { load in
|
||||||
|
load.length <= 0 || load.crossSection <= 0
|
||||||
|
}
|
||||||
|
if !incompleteLoads.isEmpty {
|
||||||
|
return .missingDetails(count: incompleteLoads.count)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
Text(value)
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
}
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusBanner(for status: LoadConfigurationStatus) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: status.symbol)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(status.tint)
|
||||||
|
Text(status.bannerText)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(status.tint)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(status.tint.opacity(0.12))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedCurrent(_ value: Double) -> String {
|
||||||
|
let numberString = LoadsView.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
||||||
|
return "\(numberString) A"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedPower(_ value: Double) -> String {
|
||||||
|
let numberString = LoadsView.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
||||||
|
return "\(numberString) W"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let numberFormatter: NumberFormatter = {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.locale = .current
|
||||||
|
formatter.minimumFractionDigits = 0
|
||||||
|
formatter.maximumFractionDigits = 1
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
private func metricBadge(label: String, value: String, tint: Color) -> some View {
|
private func metricBadge(label: String, value: String, tint: Color) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(label.uppercased())
|
Text(label.uppercased())
|
||||||
@@ -399,13 +655,6 @@ struct LoadsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyStateView: some View {
|
|
||||||
ComponentsOnboardingView(
|
|
||||||
onCreate: { createNewLoad() },
|
|
||||||
onBrowse: { showingComponentLibrary = true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteLoads(offsets: IndexSet) {
|
private func deleteLoads(offsets: IndexSet) {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
for index in offsets {
|
for index in offsets {
|
||||||
@@ -416,6 +665,8 @@ struct LoadsView: View {
|
|||||||
|
|
||||||
private func handlePrimaryAction() {
|
private func handlePrimaryAction() {
|
||||||
switch selectedComponentTab {
|
switch selectedComponentTab {
|
||||||
|
case .overview:
|
||||||
|
createNewLoad()
|
||||||
case .components:
|
case .components:
|
||||||
createNewLoad()
|
createNewLoad()
|
||||||
case .batteries:
|
case .batteries:
|
||||||
@@ -517,6 +768,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum ComponentTab: Hashable {
|
private enum ComponentTab: Hashable {
|
||||||
|
case overview
|
||||||
case components
|
case components
|
||||||
case batteries
|
case batteries
|
||||||
case chargers
|
case chargers
|
||||||
|
|||||||
692
Cable/SystemOverviewView.swift
Normal file
692
Cable/SystemOverviewView.swift
Normal file
@@ -0,0 +1,692 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SystemOverviewView: View {
|
||||||
|
@State private var activeStatus: LoadConfigurationStatus?
|
||||||
|
@State private var suppressLoadNavigation = false
|
||||||
|
let system: ElectricalSystem
|
||||||
|
let loads: [SavedLoad]
|
||||||
|
let batteries: [SavedBattery]
|
||||||
|
let onSelectLoads: () -> Void
|
||||||
|
let onSelectBatteries: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
systemCard
|
||||||
|
loadsCard
|
||||||
|
batteriesCard
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var systemCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text(systemOverviewTitle)
|
||||||
|
.font(.headline.weight(.semibold))
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(colorForName(system.colorName))
|
||||||
|
.frame(width: 54, height: 54)
|
||||||
|
Image(systemName: system.iconName)
|
||||||
|
.font(.system(size: 24, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(system.name)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
if !system.location.isEmpty {
|
||||||
|
Label(system.location, systemImage: "mappin.and.ellipse")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.labelStyle(.titleAndIcon)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
summaryMetric(
|
||||||
|
icon: "square.stack.3d.up",
|
||||||
|
label: loadsCountLabel,
|
||||||
|
value: "\(loads.count)",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "battery.100",
|
||||||
|
label: batteryCountLabel,
|
||||||
|
value: "\(batteries.count)",
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
summaryMetric(
|
||||||
|
icon: "square.stack.3d.up",
|
||||||
|
label: loadsCountLabel,
|
||||||
|
value: "\(loads.count)",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "battery.100",
|
||||||
|
label: batteryCountLabel,
|
||||||
|
value: "\(batteries.count)",
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeSection
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.fill(Color(.systemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadsCard: some View {
|
||||||
|
Button {
|
||||||
|
if suppressLoadNavigation {
|
||||||
|
suppressLoadNavigation = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSelectLoads()
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(loadsSummaryTitle)
|
||||||
|
.font(.headline.weight(.semibold))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if loads.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(loadsEmptyTitle)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(loadsEmptySubtitle)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
summaryMetric(
|
||||||
|
icon: "square.stack.3d.up",
|
||||||
|
label: loadsCountLabel,
|
||||||
|
value: "\(loads.count)",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "bolt.fill",
|
||||||
|
label: loadsCurrentLabel,
|
||||||
|
value: formattedCurrent(totalCurrent),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "gauge.medium",
|
||||||
|
label: loadsPowerLabel,
|
||||||
|
value: formattedPower(totalPower),
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
summaryMetric(
|
||||||
|
icon: "square.stack.3d.up",
|
||||||
|
label: loadsCountLabel,
|
||||||
|
value: "\(loads.count)",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "bolt.fill",
|
||||||
|
label: loadsCurrentLabel,
|
||||||
|
value: formattedCurrent(totalCurrent),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "gauge.medium",
|
||||||
|
label: loadsPowerLabel,
|
||||||
|
value: formattedPower(totalPower),
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let status = loadStatus {
|
||||||
|
statusBanner(for: status)
|
||||||
|
.simultaneousGesture(
|
||||||
|
TapGesture().onEnded {
|
||||||
|
suppressLoadNavigation = true
|
||||||
|
activeStatus = status
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.fill(Color(.systemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.alert(item: $activeStatus) { status in
|
||||||
|
let detail = status.detailInfo()
|
||||||
|
return Alert(
|
||||||
|
title: Text(detail.title),
|
||||||
|
message: Text(detail.message),
|
||||||
|
dismissButton: .default(
|
||||||
|
Text(
|
||||||
|
NSLocalizedString(
|
||||||
|
"battery.bank.status.dismiss",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Got it",
|
||||||
|
comment: "Dismiss button title for load status alert"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batteriesCard: some View {
|
||||||
|
Button(action: onSelectBatteries) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack(alignment: .center, spacing: 10) {
|
||||||
|
Text(batterySummaryTitle)
|
||||||
|
.font(.headline.weight(.semibold))
|
||||||
|
if let warning = batteryWarning {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: warning.symbol)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(warning.tint)
|
||||||
|
Text(warning.shortLabel)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(warning.tint)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(warning.tint.opacity(0.12))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if batteries.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(batteryEmptyTitle)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(batteryEmptySubtitle)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
summaryMetric(
|
||||||
|
icon: "battery.100",
|
||||||
|
label: batteryCountLabel,
|
||||||
|
value: "\(batteries.count)",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "gauge.medium",
|
||||||
|
label: batteryCapacityLabel,
|
||||||
|
value: formattedValue(totalCapacity, unit: "Ah"),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "bolt.circle",
|
||||||
|
label: batteryEnergyLabel,
|
||||||
|
value: formattedValue(totalEnergy, unit: "Wh"),
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
summaryMetric(
|
||||||
|
icon: "battery.100",
|
||||||
|
label: batteryCountLabel,
|
||||||
|
value: "\(batteries.count)",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "gauge.medium",
|
||||||
|
label: batteryCapacityLabel,
|
||||||
|
value: formattedValue(totalCapacity, unit: "Ah"),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "bolt.circle",
|
||||||
|
label: batteryEnergyLabel,
|
||||||
|
value: formattedValue(totalEnergy, unit: "Wh"),
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.fill(Color(.systemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadStatus: LoadConfigurationStatus? {
|
||||||
|
guard !loads.isEmpty else { return nil }
|
||||||
|
let incomplete = loads.filter { load in
|
||||||
|
load.length <= 0 || load.crossSection <= 0
|
||||||
|
}
|
||||||
|
if !incomplete.isEmpty {
|
||||||
|
return .missingDetails(count: incomplete.count)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalCurrent: Double {
|
||||||
|
loads.reduce(0) { result, load in
|
||||||
|
result + max(load.current, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalPower: Double {
|
||||||
|
loads.reduce(0) { result, load in
|
||||||
|
result + max(load.power, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalCapacity: Double {
|
||||||
|
batteries.reduce(0) { result, battery in
|
||||||
|
result + battery.capacityAmpHours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var totalEnergy: Double {
|
||||||
|
batteries.reduce(0) { result, battery in
|
||||||
|
result + battery.energyWattHours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batteryWarning: BatteryWarning? {
|
||||||
|
guard batteries.count > 1 else { return nil }
|
||||||
|
|
||||||
|
if let targetVoltage = dominantValue(from: batteries.map { $0.nominalVoltage }, scale: 0.1) {
|
||||||
|
let mismatched = batteries.filter { abs($0.nominalVoltage - targetVoltage) > 0.05 }
|
||||||
|
if !mismatched.isEmpty {
|
||||||
|
return .voltage(count: mismatched.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let targetCapacity = dominantValue(from: batteries.map { $0.capacityAmpHours }, scale: 1.0) {
|
||||||
|
let mismatched = batteries.filter { abs($0.capacityAmpHours - targetCapacity) > 0.5 }
|
||||||
|
if !mismatched.isEmpty {
|
||||||
|
return .capacity(count: mismatched.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dominantValue(from values: [Double], scale: Double) -> Double? {
|
||||||
|
guard !values.isEmpty else { return nil }
|
||||||
|
var counts: [Double: Int] = [:]
|
||||||
|
var bestKey: Double?
|
||||||
|
var bestCount = 0
|
||||||
|
|
||||||
|
for value in values {
|
||||||
|
let key = (value / scale).rounded() * scale
|
||||||
|
let newCount = (counts[key] ?? 0) + 1
|
||||||
|
counts[key] = newCount
|
||||||
|
if newCount > bestCount {
|
||||||
|
bestCount = newCount
|
||||||
|
bestKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestKey
|
||||||
|
}
|
||||||
|
|
||||||
|
private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
Text(value)
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
}
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var runtimeSection: some View {
|
||||||
|
if let runtimeText = formattedRuntime {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.font(.system(size: 20, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.orange)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(runtimeTitle)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text(runtimeSubtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(runtimeText)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(Color.orange.opacity(0.12))
|
||||||
|
)
|
||||||
|
} else if shouldShowRuntimeHint {
|
||||||
|
Text(runtimeUnavailableText)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusBanner(for status: LoadConfigurationStatus) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: status.symbol)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(status.tint)
|
||||||
|
Text(status.bannerText)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(status.tint)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(status.tint.opacity(0.12))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedCurrent(_ value: Double) -> String {
|
||||||
|
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
||||||
|
return "\(numberString) A"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedPower(_ value: Double) -> String {
|
||||||
|
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
||||||
|
return "\(numberString) W"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formattedValue(_ value: Double, unit: String) -> String {
|
||||||
|
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
||||||
|
return "\(numberString) \(unit)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var estimatedRuntimeHours: Double? {
|
||||||
|
guard totalPower > 0, totalEnergy > 0 else { return nil }
|
||||||
|
let hours = totalEnergy / totalPower
|
||||||
|
return hours.isFinite && hours > 0 ? hours : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var formattedRuntime: String? {
|
||||||
|
guard let hours = estimatedRuntimeHours else { return nil }
|
||||||
|
let seconds = hours * 3600
|
||||||
|
if let formatted = Self.runtimeFormatter.string(from: seconds) {
|
||||||
|
return formatted
|
||||||
|
}
|
||||||
|
let numberString = Self.numberFormatter.string(from: NSNumber(value: hours)) ?? String(format: "%.1f", hours)
|
||||||
|
return "\(numberString) h"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowRuntimeHint: Bool {
|
||||||
|
!batteries.isEmpty && !loads.isEmpty && formattedRuntime == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func colorForName(_ colorName: String) -> Color {
|
||||||
|
switch colorName {
|
||||||
|
case "blue": return .blue
|
||||||
|
case "green": return .green
|
||||||
|
case "orange": return .orange
|
||||||
|
case "red": return .red
|
||||||
|
case "purple": return .purple
|
||||||
|
case "yellow": return .yellow
|
||||||
|
case "pink": return .pink
|
||||||
|
case "teal": return .teal
|
||||||
|
case "indigo": return .indigo
|
||||||
|
case "mint": return .mint
|
||||||
|
case "cyan": return .cyan
|
||||||
|
case "brown": return .brown
|
||||||
|
case "gray": return .gray
|
||||||
|
default: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadsSummaryTitle: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"loads.overview.header.title",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Load Overview",
|
||||||
|
comment: "Title for the loads overview summary section"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadsCountLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"loads.overview.metric.count",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Loads",
|
||||||
|
comment: "Label for number of loads metric"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadsCurrentLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"loads.overview.metric.current",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Total Current",
|
||||||
|
comment: "Label for total load current metric"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadsPowerLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"loads.overview.metric.power",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Total Power",
|
||||||
|
comment: "Label for total load power metric"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadsEmptyTitle: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"overview.loads.empty.title",
|
||||||
|
bundle: .main,
|
||||||
|
value: "No loads configured yet",
|
||||||
|
comment: "Title shown in overview when no loads exist"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadsEmptySubtitle: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"overview.loads.empty.subtitle",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Add components to get cable sizing and fuse recommendations tailored to this system.",
|
||||||
|
comment: "Subtitle shown in overview when no loads exist"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batterySummaryTitle: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"battery.bank.header.title",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Battery Bank",
|
||||||
|
comment: "Title for the battery bank summary section"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batteryCountLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"battery.bank.metric.count",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Batteries",
|
||||||
|
comment: "Label for number of batteries metric"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batteryCapacityLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"battery.bank.metric.capacity",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Capacity",
|
||||||
|
comment: "Label for total capacity metric"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batteryEnergyLabel: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"battery.bank.metric.energy",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Energy",
|
||||||
|
comment: "Label for total energy metric"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batteryEmptyTitle: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"battery.bank.empty.title",
|
||||||
|
bundle: .main,
|
||||||
|
value: "No Batteries Yet",
|
||||||
|
comment: "Title shown when no batteries are configured"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batteryEmptySubtitle: String {
|
||||||
|
let format = NSLocalizedString(
|
||||||
|
"battery.bank.empty.subtitle",
|
||||||
|
tableName: nil,
|
||||||
|
bundle: .main,
|
||||||
|
value: "Tap the plus button to configure a battery for %@.",
|
||||||
|
comment: "Subtitle shown when no batteries are configured"
|
||||||
|
)
|
||||||
|
return String(format: format, system.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var systemOverviewTitle: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"overview.system.header.title",
|
||||||
|
bundle: .main,
|
||||||
|
value: "System Overview",
|
||||||
|
comment: "Title for system overview card"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var runtimeTitle: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"overview.runtime.title",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Estimated runtime",
|
||||||
|
comment: "Title for estimated runtime section"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var runtimeSubtitle: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"overview.runtime.subtitle",
|
||||||
|
bundle: .main,
|
||||||
|
value: "At current load draw",
|
||||||
|
comment: "Subtitle describing runtime assumption"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var runtimeUnavailableText: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"overview.runtime.unavailable",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Add battery capacity and load power to estimate runtime.",
|
||||||
|
comment: "Message shown when runtime cannot be calculated"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let numberFormatter: NumberFormatter = {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.locale = .current
|
||||||
|
formatter.minimumFractionDigits = 0
|
||||||
|
formatter.maximumFractionDigits = 1
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let runtimeFormatter: DateComponentsFormatter = {
|
||||||
|
let formatter = DateComponentsFormatter()
|
||||||
|
formatter.allowedUnits = [.day, .hour, .minute]
|
||||||
|
formatter.unitsStyle = .abbreviated
|
||||||
|
formatter.maximumUnitCount = 2
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private enum BatteryWarning {
|
||||||
|
case voltage(count: Int)
|
||||||
|
case capacity(count: Int)
|
||||||
|
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .voltage:
|
||||||
|
return "exclamationmark.triangle.fill"
|
||||||
|
case .capacity:
|
||||||
|
return "exclamationmark.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tint: Color {
|
||||||
|
switch self {
|
||||||
|
case .voltage:
|
||||||
|
return .red
|
||||||
|
case .capacity:
|
||||||
|
return .orange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var shortLabel: String {
|
||||||
|
switch self {
|
||||||
|
case .voltage:
|
||||||
|
return NSLocalizedString(
|
||||||
|
"battery.bank.warning.voltage.short",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Voltage",
|
||||||
|
comment: "Short label for voltage warning"
|
||||||
|
)
|
||||||
|
case .capacity:
|
||||||
|
return NSLocalizedString(
|
||||||
|
"battery.bank.warning.capacity.short",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Capacity",
|
||||||
|
comment: "Short label for capacity warning"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,10 +136,32 @@
|
|||||||
"VoltPlan Library" = "VoltPlan-Bibliothek";
|
"VoltPlan Library" = "VoltPlan-Bibliothek";
|
||||||
"New Load" = "Neuer Verbraucher";
|
"New Load" = "Neuer Verbraucher";
|
||||||
|
|
||||||
|
"tab.overview" = "Übersicht";
|
||||||
"tab.components" = "Verbraucher";
|
"tab.components" = "Verbraucher";
|
||||||
"tab.batteries" = "Batterien";
|
"tab.batteries" = "Batterien";
|
||||||
"tab.chargers" = "Ladegeräte";
|
"tab.chargers" = "Ladegeräte";
|
||||||
|
|
||||||
|
"loads.overview.header.title" = "Verbraucherübersicht";
|
||||||
|
"loads.overview.metric.count" = "Verbraucher";
|
||||||
|
"loads.overview.metric.current" = "Strom";
|
||||||
|
"loads.overview.metric.power" = "Leistung";
|
||||||
|
"loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails";
|
||||||
|
"loads.overview.status.missing_details.message" = "Gib Kabellänge und Leitungsquerschnitt für %d %@ ein, um genaue Empfehlungen zu erhalten.";
|
||||||
|
"loads.overview.status.missing_details.singular" = "Verbraucher";
|
||||||
|
"loads.overview.status.missing_details.plural" = "Verbraucher";
|
||||||
|
"loads.overview.status.missing_details.banner" = "Konfiguration deiner Verbraucher abschließen";
|
||||||
|
"loads.metric.fuse" = "Sicherung";
|
||||||
|
"loads.metric.cable" = "Schnitt";
|
||||||
|
"loads.metric.length" = "Länge";
|
||||||
|
"overview.system.header.title" = "Systemübersicht";
|
||||||
|
"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 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";
|
||||||
|
|
||||||
"battery.bank.header.title" = "Batteriebank";
|
"battery.bank.header.title" = "Batteriebank";
|
||||||
"battery.bank.metric.count" = "Batterien";
|
"battery.bank.metric.count" = "Batterien";
|
||||||
"battery.bank.metric.capacity" = "Kapazität";
|
"battery.bank.metric.capacity" = "Kapazität";
|
||||||
|
|||||||
@@ -135,10 +135,32 @@
|
|||||||
"VoltPlan Library" = "Biblioteca de VoltPlan";
|
"VoltPlan Library" = "Biblioteca de VoltPlan";
|
||||||
"New Load" = "Carga nueva";
|
"New Load" = "Carga nueva";
|
||||||
|
|
||||||
|
"tab.overview" = "Resumen";
|
||||||
"tab.components" = "Componentes";
|
"tab.components" = "Componentes";
|
||||||
"tab.batteries" = "Baterías";
|
"tab.batteries" = "Baterías";
|
||||||
"tab.chargers" = "Cargadores";
|
"tab.chargers" = "Cargadores";
|
||||||
|
|
||||||
|
"loads.overview.header.title" = "Resumen de cargas";
|
||||||
|
"loads.overview.metric.count" = "Cargas";
|
||||||
|
"loads.overview.metric.current" = "Corriente total";
|
||||||
|
"loads.overview.metric.power" = "Potencia total";
|
||||||
|
"loads.overview.status.missing_details.title" = "Faltan detalles de la carga";
|
||||||
|
"loads.overview.status.missing_details.message" = "Introduce la longitud del cable y el calibre del conductor para %d %@ para obtener recomendaciones precisas.";
|
||||||
|
"loads.overview.status.missing_details.singular" = "carga";
|
||||||
|
"loads.overview.status.missing_details.plural" = "cargas";
|
||||||
|
"loads.overview.status.missing_details.banner" = "Completa la configuración de tus cargas";
|
||||||
|
"loads.metric.fuse" = "Fusible";
|
||||||
|
"loads.metric.cable" = "Cable";
|
||||||
|
"loads.metric.length" = "Longitud";
|
||||||
|
"overview.system.header.title" = "Resumen del sistema";
|
||||||
|
"overview.loads.empty.title" = "Aún no hay cargas configuradas";
|
||||||
|
"overview.loads.empty.subtitle" = "Añade cargas para obtener recomendaciones de cables y fusibles adaptadas a este sistema.";
|
||||||
|
"overview.runtime.title" = "Autonomía estimada";
|
||||||
|
"overview.runtime.subtitle" = "Con la carga actual";
|
||||||
|
"overview.runtime.unavailable" = "Añade capacidad de batería y potencia de carga para estimar la autonomía.";
|
||||||
|
"battery.bank.warning.voltage.short" = "Voltaje";
|
||||||
|
"battery.bank.warning.capacity.short" = "Capacidad";
|
||||||
|
|
||||||
"battery.bank.header.title" = "Banco de baterías";
|
"battery.bank.header.title" = "Banco de baterías";
|
||||||
"battery.bank.metric.count" = "Baterías";
|
"battery.bank.metric.count" = "Baterías";
|
||||||
"battery.bank.metric.capacity" = "Capacidad";
|
"battery.bank.metric.capacity" = "Capacidad";
|
||||||
|
|||||||
@@ -135,10 +135,32 @@
|
|||||||
"VoltPlan Library" = "Bibliothèque VoltPlan";
|
"VoltPlan Library" = "Bibliothèque VoltPlan";
|
||||||
"New Load" = "Nouvelle charge";
|
"New Load" = "Nouvelle charge";
|
||||||
|
|
||||||
|
"tab.overview" = "Aperçu";
|
||||||
"tab.components" = "Composants";
|
"tab.components" = "Composants";
|
||||||
"tab.batteries" = "Batteries";
|
"tab.batteries" = "Batteries";
|
||||||
"tab.chargers" = "Chargeurs";
|
"tab.chargers" = "Chargeurs";
|
||||||
|
|
||||||
|
"loads.overview.header.title" = "Aperçu des charges";
|
||||||
|
"loads.overview.metric.count" = "Charges";
|
||||||
|
"loads.overview.metric.current" = "Courant total";
|
||||||
|
"loads.overview.metric.power" = "Puissance totale";
|
||||||
|
"loads.overview.status.missing_details.title" = "Détails de charge manquants";
|
||||||
|
"loads.overview.status.missing_details.message" = "Saisissez la longueur de câble et la section du conducteur pour %d %@ afin d'obtenir des recommandations précises.";
|
||||||
|
"loads.overview.status.missing_details.singular" = "charge";
|
||||||
|
"loads.overview.status.missing_details.plural" = "charges";
|
||||||
|
"loads.overview.status.missing_details.banner" = "Terminez la configuration de vos charges";
|
||||||
|
"loads.metric.fuse" = "Fusible";
|
||||||
|
"loads.metric.cable" = "Câble";
|
||||||
|
"loads.metric.length" = "Longueur";
|
||||||
|
"overview.system.header.title" = "Aperçu du système";
|
||||||
|
"overview.loads.empty.title" = "Aucune charge configurée pour l'instant";
|
||||||
|
"overview.loads.empty.subtitle" = "Ajoutez des charges pour obtenir des recommandations de câbles et de fusibles adaptées à ce système.";
|
||||||
|
"overview.runtime.title" = "Autonomie estimée";
|
||||||
|
"overview.runtime.subtitle" = "Avec la charge actuelle";
|
||||||
|
"overview.runtime.unavailable" = "Ajoutez la capacité de la batterie et la puissance consommée pour estimer l’autonomie.";
|
||||||
|
"battery.bank.warning.voltage.short" = "Tension";
|
||||||
|
"battery.bank.warning.capacity.short" = "Capacité";
|
||||||
|
|
||||||
"battery.bank.header.title" = "Banque de batteries";
|
"battery.bank.header.title" = "Banque de batteries";
|
||||||
"battery.bank.metric.count" = "Batteries";
|
"battery.bank.metric.count" = "Batteries";
|
||||||
"battery.bank.metric.capacity" = "Capacité";
|
"battery.bank.metric.capacity" = "Capacité";
|
||||||
|
|||||||
@@ -135,10 +135,32 @@
|
|||||||
"VoltPlan Library" = "VoltPlan-bibliotheek";
|
"VoltPlan Library" = "VoltPlan-bibliotheek";
|
||||||
"New Load" = "Nieuwe last";
|
"New Load" = "Nieuwe last";
|
||||||
|
|
||||||
|
"tab.overview" = "Overzicht";
|
||||||
"tab.components" = "Componenten";
|
"tab.components" = "Componenten";
|
||||||
"tab.batteries" = "Batterijen";
|
"tab.batteries" = "Batterijen";
|
||||||
"tab.chargers" = "Laders";
|
"tab.chargers" = "Laders";
|
||||||
|
|
||||||
|
"loads.overview.header.title" = "Lastenoverzicht";
|
||||||
|
"loads.overview.metric.count" = "Lasten";
|
||||||
|
"loads.overview.metric.current" = "Totale stroom";
|
||||||
|
"loads.overview.metric.power" = "Totaal vermogen";
|
||||||
|
"loads.overview.status.missing_details.title" = "Ontbrekende lastdetails";
|
||||||
|
"loads.overview.status.missing_details.message" = "Voer kabellengte en kabeldoorsnede in voor %d %@ om nauwkeurige aanbevelingen te krijgen.";
|
||||||
|
"loads.overview.status.missing_details.singular" = "last";
|
||||||
|
"loads.overview.status.missing_details.plural" = "lasten";
|
||||||
|
"loads.overview.status.missing_details.banner" = "Rond de configuratie van je lasten af";
|
||||||
|
"loads.metric.fuse" = "Zekering";
|
||||||
|
"loads.metric.cable" = "Kabel";
|
||||||
|
"loads.metric.length" = "Lengte";
|
||||||
|
"overview.system.header.title" = "Systeemoverzicht";
|
||||||
|
"overview.loads.empty.title" = "Nog geen lasten geconfigureerd";
|
||||||
|
"overview.loads.empty.subtitle" = "Voeg lasten toe om kabel- en zekeringsadviezen te krijgen die zijn afgestemd op dit systeem.";
|
||||||
|
"overview.runtime.title" = "Geschatte looptijd";
|
||||||
|
"overview.runtime.subtitle" = "Bij huidige belasting";
|
||||||
|
"overview.runtime.unavailable" = "Voeg batterijcapaciteit en vermogensvraag toe om de looptijd te berekenen.";
|
||||||
|
"battery.bank.warning.voltage.short" = "Spanning";
|
||||||
|
"battery.bank.warning.capacity.short" = "Capaciteit";
|
||||||
|
|
||||||
"battery.bank.header.title" = "Accubank";
|
"battery.bank.header.title" = "Accubank";
|
||||||
"battery.bank.metric.count" = "Batterijen";
|
"battery.bank.metric.count" = "Batterijen";
|
||||||
"battery.bank.metric.capacity" = "Capaciteit";
|
"battery.bank.metric.capacity" = "Capaciteit";
|
||||||
|
|||||||
Reference in New Issue
Block a user