Compare commits

...

6 Commits

Author SHA1 Message Date
Stefan Lange-Hegermann
858bf2a305 calculator allows manual entries too 2025-10-21 16:42:25 +02:00
Stefan Lange-Hegermann
f171c3d6b2 free value entry in the battery editor 2025-10-21 16:24:25 +02:00
Stefan Lange-Hegermann
a6f2f8fc91 some fixes 2025-10-21 15:37:24 +02:00
Stefan Lange-Hegermann
1fef290abf some fixes 2025-10-21 15:37:07 +02:00
Stefan Lange-Hegermann
df315ea7d8 All localized 2025-10-21 13:55:44 +02:00
Stefan Lange-Hegermann
2a2c48e89f loads info bar above list 2025-10-21 11:43:56 +02:00
12 changed files with 1728 additions and 271 deletions

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import Foundation import Foundation
import SwiftData import SwiftData
struct BatteryConfiguration: Identifiable { struct BatteryConfiguration: Identifiable, Hashable {
enum Chemistry: String, CaseIterable, Identifiable { enum Chemistry: String, CaseIterable, Identifiable {
case agm = "AGM" case agm = "AGM"
case gel = "Gel" case gel = "Gel"
@@ -61,3 +61,21 @@ struct BatteryConfiguration: Identifiable {
savedBattery.timestamp = Date() savedBattery.timestamp = Date()
} }
} }
extension BatteryConfiguration {
static func == (lhs: BatteryConfiguration, rhs: BatteryConfiguration) -> Bool {
lhs.id == rhs.id &&
lhs.name == rhs.name &&
lhs.nominalVoltage == rhs.nominalVoltage &&
lhs.capacityAmpHours == rhs.capacityAmpHours &&
lhs.chemistry == rhs.chemistry
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(name)
hasher.combine(nominalVoltage)
hasher.combine(capacityAmpHours)
hasher.combine(chemistry)
}
}

View File

@@ -1,12 +1,11 @@
import SwiftUI import SwiftUI
struct BatteryEditorView: View { struct BatteryEditorView: View {
@Environment(\.dismiss) private var dismiss
@State private var configuration: BatteryConfiguration @State private var configuration: BatteryConfiguration
@State private var editingField: EditingField? @State private var editingField: EditingField?
@State private var voltageInput: String = ""
@State private var capacityInput: String = ""
let onSave: (BatteryConfiguration) -> Void let onSave: (BatteryConfiguration) -> Void
let onCancel: () -> Void
private enum EditingField { private enum EditingField {
case voltage case voltage
@@ -89,11 +88,22 @@ struct BatteryEditorView: View {
comment: "Label used for energy values" comment: "Label used for energy values"
) )
} }
private var voltageSliderRange: ClosedRange<Double> {
let lowerBound = max(0, min(6, configuration.nominalVoltage))
let upperBound = max(60, configuration.nominalVoltage)
return lowerBound...upperBound
}
init(configuration: BatteryConfiguration, onSave: @escaping (BatteryConfiguration) -> Void, onCancel: @escaping () -> Void) { private var capacitySliderRange: ClosedRange<Double> {
let lowerBound = max(0, min(5, configuration.capacityAmpHours))
let upperBound = max(1000, configuration.capacityAmpHours)
return lowerBound...upperBound
}
init(configuration: BatteryConfiguration, onSave: @escaping (BatteryConfiguration) -> Void) {
_configuration = State(initialValue: configuration) _configuration = State(initialValue: configuration)
self.onSave = onSave self.onSave = onSave
self.onCancel = onCancel
} }
var body: some View { var body: some View {
@@ -115,32 +125,8 @@ struct BatteryEditorView: View {
) )
) )
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .onDisappear {
ToolbarItem(placement: .cancellationAction) { onSave(configuration)
Button(
NSLocalizedString(
"battery.editor.cancel",
bundle: .main,
value: "Cancel",
comment: "Cancel button title"
)
) {
cancel()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(
NSLocalizedString(
"battery.editor.save",
bundle: .main,
value: "Save",
comment: "Save button title"
)
) {
save()
}
.disabled(configuration.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
} }
.alert( .alert(
NSLocalizedString( NSLocalizedString(
@@ -151,7 +137,12 @@ struct BatteryEditorView: View {
), ),
isPresented: Binding( isPresented: Binding(
get: { editingField == .voltage }, get: { editingField == .voltage },
set: { if !$0 { editingField = nil } } set: { isPresented in
if !isPresented {
editingField = nil
voltageInput = ""
}
}
) )
) { ) {
TextField( TextField(
@@ -161,10 +152,18 @@ struct BatteryEditorView: View {
value: "Voltage", value: "Voltage",
comment: "Placeholder for voltage text field" comment: "Placeholder for voltage text field"
), ),
value: $configuration.nominalVoltage, text: $voltageInput
format: .number
) )
.keyboardType(.decimalPad) .keyboardType(.decimalPad)
.onAppear {
if voltageInput.isEmpty {
voltageInput = formattedEditValue(configuration.nominalVoltage)
}
}
.onChange(of: voltageInput) { newValue in
guard editingField == .voltage, let parsed = parseInput(newValue) else { return }
configuration.nominalVoltage = roundToTenth(parsed)
}
Button( Button(
NSLocalizedString( NSLocalizedString(
@@ -174,7 +173,10 @@ struct BatteryEditorView: View {
comment: "Cancel button title for edit alerts" comment: "Cancel button title for edit alerts"
), ),
role: .cancel role: .cancel
) { editingField = nil } ) {
editingField = nil
voltageInput = ""
}
Button( Button(
NSLocalizedString( NSLocalizedString(
@@ -184,11 +186,11 @@ struct BatteryEditorView: View {
comment: "Save button title for edit alerts" comment: "Save button title for edit alerts"
) )
) { ) {
editingField = nil if let parsed = parseInput(voltageInput) {
let normalized = normalizedVoltage(for: configuration.nominalVoltage) configuration.nominalVoltage = roundToTenth(parsed)
if abs(normalized - configuration.nominalVoltage) > 0.000001 {
configuration.nominalVoltage = normalized
} }
editingField = nil
voltageInput = ""
} }
} message: { } message: {
Text( Text(
@@ -209,7 +211,12 @@ struct BatteryEditorView: View {
), ),
isPresented: Binding( isPresented: Binding(
get: { editingField == .capacity }, get: { editingField == .capacity },
set: { if !$0 { editingField = nil } } set: { isPresented in
if !isPresented {
editingField = nil
capacityInput = ""
}
}
) )
) { ) {
TextField( TextField(
@@ -219,10 +226,18 @@ struct BatteryEditorView: View {
value: "Capacity", value: "Capacity",
comment: "Placeholder for capacity text field" comment: "Placeholder for capacity text field"
), ),
value: $configuration.capacityAmpHours, text: $capacityInput
format: .number
) )
.keyboardType(.decimalPad) .keyboardType(.decimalPad)
.onAppear {
if capacityInput.isEmpty {
capacityInput = formattedEditValue(configuration.capacityAmpHours)
}
}
.onChange(of: capacityInput) { newValue in
guard editingField == .capacity, let parsed = parseInput(newValue) else { return }
configuration.capacityAmpHours = roundToTenth(parsed)
}
Button( Button(
NSLocalizedString( NSLocalizedString(
@@ -232,7 +247,10 @@ struct BatteryEditorView: View {
comment: "Cancel button title for edit alerts" comment: "Cancel button title for edit alerts"
), ),
role: .cancel role: .cancel
) { editingField = nil } ) {
editingField = nil
capacityInput = ""
}
Button( Button(
NSLocalizedString( NSLocalizedString(
@@ -242,11 +260,11 @@ struct BatteryEditorView: View {
comment: "Save button title for edit alerts" comment: "Save button title for edit alerts"
) )
) { ) {
editingField = nil if let parsed = parseInput(capacityInput) {
let normalized = normalizedCapacity(for: configuration.capacityAmpHours) configuration.capacityAmpHours = roundToTenth(parsed)
if abs(normalized - configuration.capacityAmpHours) > 0.000001 {
configuration.capacityAmpHours = normalized
} }
editingField = nil
capacityInput = ""
} }
} message: { } message: {
Text( Text(
@@ -365,33 +383,39 @@ struct BatteryEditorView: View {
VStack(spacing: 30) { VStack(spacing: 30) {
SliderSection( SliderSection(
title: sliderVoltageTitle, title: sliderVoltageTitle,
value: $configuration.nominalVoltage, value: Binding(
range: 6...60, get: { configuration.nominalVoltage },
set: { newValue in
if editingField == .voltage {
configuration.nominalVoltage = roundToTenth(newValue)
} else {
configuration.nominalVoltage = normalizedVoltage(for: newValue)
}
}
),
range: voltageSliderRange,
unit: "V", unit: "V",
tapAction: { editingField = .voltage }, tapAction: beginVoltageEditing,
snapValues: voltageSnapValues snapValues: editingField == .voltage ? nil : voltageSnapValues
) )
.onChange(of: configuration.nominalVoltage) { _, newValue in
let normalized = normalizedVoltage(for: newValue)
if abs(normalized - newValue) > 0.000001 {
configuration.nominalVoltage = normalized
}
}
SliderSection( SliderSection(
title: sliderCapacityTitle, title: sliderCapacityTitle,
value: $configuration.capacityAmpHours, value: Binding(
range: 5...1000, get: { configuration.capacityAmpHours },
set: { newValue in
if editingField == .capacity {
configuration.capacityAmpHours = roundToTenth(newValue)
} else {
configuration.capacityAmpHours = normalizedCapacity(for: newValue)
}
}
),
range: capacitySliderRange,
unit: "Ah", unit: "Ah",
tapAction: { editingField = .capacity }, tapAction: beginCapacityEditing,
snapValues: capacitySnapValues snapValues: editingField == .capacity ? nil : capacitySnapValues
) )
.onChange(of: configuration.capacityAmpHours) { _, newValue in
let normalized = normalizedCapacity(for: newValue)
if abs(normalized - newValue) > 0.000001 {
configuration.capacityAmpHours = normalized
}
}
} }
.padding() .padding()
.background( .background(
@@ -416,6 +440,36 @@ struct BatteryEditorView: View {
return rounded return rounded
} }
private func roundToTenth(_ value: Double) -> Double {
max(0, (value * 10).rounded() / 10)
}
private func formattedEditValue(_ value: Double) -> String {
Self.numberFormatter.string(from: NSNumber(value: roundToTenth(value))) ?? String(format: "%.1f", value)
}
private func parseInput(_ text: String) -> Double? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let number = Self.numberFormatter.number(from: trimmed)?.doubleValue {
return number
}
let decimalSeparator = Locale.current.decimalSeparator ?? "."
let altSeparator = decimalSeparator == "." ? "," : "."
let normalized = trimmed.replacingOccurrences(of: altSeparator, with: decimalSeparator)
return Self.numberFormatter.number(from: normalized)?.doubleValue
}
private func beginVoltageEditing() {
voltageInput = formattedEditValue(configuration.nominalVoltage)
editingField = .voltage
}
private func beginCapacityEditing() {
capacityInput = formattedEditValue(configuration.capacityAmpHours)
editingField = .capacity
}
private func nearestValue(to value: Double, in options: [Double], tolerance: Double) -> Double? { private func nearestValue(to value: Double, in options: [Double], tolerance: Double) -> Double? {
guard let closest = options.min(by: { abs($0 - value) < abs($1 - value) }) else { return nil } guard let closest = options.min(by: { abs($0 - value) < abs($1 - value) }) else { return nil }
return abs(closest - value) <= tolerance ? closest : nil return abs(closest - value) <= tolerance ? closest : nil
@@ -445,6 +499,7 @@ struct BatteryEditorView: View {
private static let numberFormatter: NumberFormatter = { private static let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter() let formatter = NumberFormatter()
formatter.locale = .current formatter.locale = .current
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 0 formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1 formatter.maximumFractionDigits = 1
return formatter return formatter
@@ -455,15 +510,6 @@ struct BatteryEditorView: View {
return "\(numberString) \(unit)" return "\(numberString) \(unit)"
} }
private func save() {
onSave(configuration)
dismiss()
}
private func cancel() {
onCancel()
dismiss()
}
} }
#Preview { #Preview {
@@ -471,8 +517,7 @@ struct BatteryEditorView: View {
return NavigationStack { return NavigationStack {
BatteryEditorView( BatteryEditorView(
configuration: BatteryConfiguration(name: "House Bank", system: previewSystem), configuration: BatteryConfiguration(name: "House Bank", system: previewSystem),
onSave: { _ in }, onSave: { _ in }
onCancel: {}
) )
} }
} }

View File

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

View 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
}

View File

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

View 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"
)
}
}
}
}

View File

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

View File

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

View File

@@ -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 lautonomie.";
"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é";

View File

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