From 4827ea4cdb9163badfc2644daaed04fb44a0d00a Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Tue, 21 Oct 2025 10:43:51 +0200 Subject: [PATCH] localization updates --- Cable.xcodeproj/project.pbxproj | 4 +- Cable/Base.lproj/Localizable.strings | 45 ++++ Cable/BatteriesView.swift | 361 ++++++++++++++++++++++++--- Cable/BatteryEditorView.swift | 263 +++++++++++++++++-- Cable/ChargersView.swift | 21 +- Cable/LoadsView.swift | 27 +- Cable/de.lproj/Localizable.strings | 53 +++- Cable/es.lproj/Localizable.strings | 45 ++++ Cable/fr.lproj/Localizable.strings | 45 ++++ Cable/nl.lproj/Localizable.strings | 45 ++++ 10 files changed, 846 insertions(+), 63 deletions(-) diff --git a/Cable.xcodeproj/project.pbxproj b/Cable.xcodeproj/project.pbxproj index b840e20..3266118 100644 --- a/Cable.xcodeproj/project.pbxproj +++ b/Cable.xcodeproj/project.pbxproj @@ -406,7 +406,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 14; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = RE4FXQ754N; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; @@ -441,7 +441,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 14; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = RE4FXQ754N; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index 99e266c..d32677a 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -67,3 +67,48 @@ "system.icon.keywords.heat" = "heat, heater, furnace"; "system.icon.keywords.cold" = "cold, freeze, cool"; "system.icon.keywords.climate" = "climate, hvac, temperature"; + +"tab.components" = "Components"; +"tab.batteries" = "Batteries"; +"tab.chargers" = "Chargers"; + +"battery.bank.header.title" = "Battery Bank"; +"battery.bank.metric.count" = "Batteries"; +"battery.bank.metric.capacity" = "Capacity"; +"battery.bank.metric.energy" = "Energy"; +"battery.bank.badge.voltage" = "Voltage"; +"battery.bank.badge.capacity" = "Capacity"; +"battery.bank.badge.energy" = "Energy"; +"battery.bank.banner.voltage" = "Voltage mismatch detected"; +"battery.bank.banner.capacity" = "Capacity mismatch detected"; +"battery.bank.empty.title" = "No Batteries Yet"; +"battery.bank.empty.subtitle" = "Tap the plus button to configure a battery for %@."; +"battery.bank.status.dismiss" = "Got it"; +"battery.bank.status.single.battery" = "One battery"; +"battery.bank.status.multiple.batteries" = "%d batteries"; +"battery.bank.status.voltage.title" = "Voltage mismatch"; +"battery.bank.status.voltage.message" = "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters."; +"battery.bank.status.capacity.title" = "Capacity mismatch"; +"battery.bank.status.capacity.message" = "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear."; + +"battery.editor.title" = "Battery Setup"; +"battery.editor.cancel" = "Cancel"; +"battery.editor.save" = "Save"; +"battery.editor.field.name" = "Name"; +"battery.editor.placeholder.name" = "House Bank"; +"battery.editor.field.chemistry" = "Chemistry"; +"battery.editor.section.summary" = "Summary"; +"battery.editor.slider.voltage" = "Nominal Voltage"; +"battery.editor.slider.capacity" = "Capacity"; +"battery.editor.alert.voltage.title" = "Edit Nominal Voltage"; +"battery.editor.alert.voltage.placeholder" = "Voltage"; +"battery.editor.alert.voltage.message" = "Enter voltage in volts (V)"; +"battery.editor.alert.capacity.title" = "Edit Capacity"; +"battery.editor.alert.capacity.placeholder" = "Capacity"; +"battery.editor.alert.capacity.message" = "Enter capacity in amp-hours (Ah)"; +"battery.editor.alert.cancel" = "Cancel"; +"battery.editor.alert.save" = "Save"; +"battery.editor.default_name" = "New Battery"; + +"chargers.title" = "Chargers for %@"; +"chargers.subtitle" = "Charger components will be available soon."; diff --git a/Cable/BatteriesView.swift b/Cable/BatteriesView.swift index 9b5613e..0eb5712 100644 --- a/Cable/BatteriesView.swift +++ b/Cable/BatteriesView.swift @@ -6,6 +6,117 @@ struct BatteriesView: View { let onEdit: (SavedBattery) -> Void let onDelete: (IndexSet) -> Void + private enum BankStatus: Identifiable { + case voltage(target: Double, mismatchedCount: Int) + case capacity(target: Double, mismatchedCount: Int) + + var id: String { + switch self { + case .voltage: return "voltage" + case .capacity: return "capacity" + } + } + + 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 bannerText: String { + switch self { + case .voltage: + return String( + localized: "battery.bank.banner.voltage", + bundle: .main, + comment: "Short banner text describing a voltage mismatch" + ) + case .capacity: + return String( + localized: "battery.bank.banner.capacity", + bundle: .main, + comment: "Short banner text describing a capacity mismatch" + ) + } + } + } + + private let voltageTolerance: Double = 0.05 + private let capacityTolerance: Double = 0.5 + @State private var activeStatus: BankStatus? + + private var bankTitle: String { + String( + localized: "battery.bank.header.title", + bundle: .main, + comment: "Title for the battery bank summary section" + ) + } + + private var metricCountLabel: String { + String( + localized: "battery.bank.metric.count", + bundle: .main, + comment: "Label for number of batteries metric" + ) + } + + private var metricCapacityLabel: String { + String( + localized: "battery.bank.metric.capacity", + bundle: .main, + comment: "Label for total capacity metric" + ) + } + + private var metricEnergyLabel: String { + String( + localized: "battery.bank.metric.energy", + bundle: .main, + comment: "Label for total energy metric" + ) + } + + private var badgeVoltageLabel: String { + String( + localized: "battery.bank.badge.voltage", + bundle: .main, + comment: "Label for voltage badge" + ) + } + + private var badgeCapacityLabel: String { + String( + localized: "battery.bank.badge.capacity", + bundle: .main, + comment: "Label for capacity badge" + ) + } + + private var badgeEnergyLabel: String { + String( + localized: "battery.bank.badge.energy", + bundle: .main, + comment: "Label for energy badge" + ) + } + + private var emptyTitle: String { + String( + localized: "battery.bank.empty.title", + bundle: .main, + comment: "Title shown when no batteries are configured" + ) + } + init( system: ElectricalSystem, batteries: [SavedBattery], @@ -46,15 +157,31 @@ struct BatteriesView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) + .alert(item: $activeStatus) { status in + let detail = detailInfo(for: status) + 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 battery bank status alert" + ) + ) + ) + ) + } } private var summarySection: some View { VStack(spacing: 0) { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline) { - Text("Battery Bank") - .font(.headline) - .fontWeight(.semibold) + Text(bankTitle) + .font(.headline.weight(.semibold)) Spacer() Text(system.name) .font(.subheadline) @@ -62,51 +189,60 @@ struct BatteriesView: View { } ViewThatFits(in: .horizontal) { - HStack(spacing: 12) { + HStack(spacing: 16) { summaryMetric( icon: "battery.100", - label: "Batteries", + label: metricCountLabel, value: "\(batteries.count)", tint: .blue ) summaryMetric( icon: "gauge.medium", - label: "Capacity", + label: metricCapacityLabel, value: formattedValue(totalCapacity, unit: "Ah"), tint: .orange ) summaryMetric( icon: "bolt.circle", - label: "Energy", + label: metricEnergyLabel, value: formattedValue(totalEnergy, unit: "Wh"), tint: .green ) } - VStack(spacing: 12) { + VStack(alignment: .leading, spacing: 12) { summaryMetric( icon: "battery.100", - label: "Batteries", + label: metricCountLabel, value: "\(batteries.count)", tint: .blue ) summaryMetric( icon: "gauge.medium", - label: "Capacity", + label: metricCapacityLabel, value: formattedValue(totalCapacity, unit: "Ah"), tint: .orange ) summaryMetric( icon: "bolt.circle", - label: "Energy", + label: metricEnergyLabel, value: formattedValue(totalEnergy, unit: "Wh"), tint: .green ) } } + + if let status = bankStatus { + Button { + activeStatus = status + } label: { + statusBanner(for: status) + } + .buttonStyle(.plain) + } } .padding(.horizontal, 16) - .padding(.vertical, 12) + .padding(.vertical, 10) .background(Color(.systemGroupedBackground)) Divider() @@ -149,17 +285,17 @@ struct BatteriesView: View { HStack(spacing: 12) { metricBadge( - label: "Voltage", + label: badgeVoltageLabel, value: formattedValue(battery.nominalVoltage, unit: "V"), tint: .orange ) metricBadge( - label: "Capacity", + label: badgeCapacityLabel, value: formattedValue(battery.capacityAmpHours, unit: "Ah"), tint: .blue ) metricBadge( - label: "Energy", + label: badgeEnergyLabel, value: formattedValue(battery.energyWattHours, unit: "Wh"), tint: .green ) @@ -198,31 +334,19 @@ struct BatteriesView: View { } private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View { - HStack(spacing: 10) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(tint.opacity(0.12)) - .frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { Image(systemName: icon) - .font(.system(size: 16, weight: .semibold)) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(tint) - } - - VStack(alignment: .leading, spacing: 2) { - Text(label.uppercased()) - .font(.caption2) - .fontWeight(.medium) - .foregroundStyle(.secondary) Text(value) - .font(.subheadline.weight(.semibold)) + .font(.body.weight(.semibold)) } + Text(label.uppercased()) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Color(.secondarySystemBackground)) - ) } private func metricBadge(label: String, value: String, tint: Color) -> some View { @@ -243,6 +367,24 @@ struct BatteriesView: View { ) } + private func statusBanner(for status: BankStatus) -> 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 colorForName(_ colorName: String) -> Color { switch colorName { case "blue": return .blue @@ -268,11 +410,11 @@ struct BatteriesView: View { .font(.largeTitle) .foregroundStyle(.secondary) - Text("No Batteries Yet") + Text(emptyTitle) .font(.title3) .fontWeight(.semibold) - Text("Tap the plus button to configure a battery for \(system.name).") + Text(emptySubtitle(for: system.name)) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -292,6 +434,149 @@ struct BatteriesView: View { let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value) return "\(numberString) \(unit)" } + + private var dominantVoltage: Double? { + guard batteries.count > 1 else { return nil } + return dominantValue( + from: batteries.map { $0.nominalVoltage }, + scale: 0.1 + ) + } + + private var dominantCapacity: Double? { + guard batteries.count > 1 else { return nil } + return dominantValue( + from: batteries.map { $0.capacityAmpHours }, + scale: 1.0 + ) + } + + 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 var bankStatus: BankStatus? { + guard batteries.count > 1 else { return nil } + + if let targetVoltage = dominantVoltage { + let mismatched = batteries.filter { abs($0.nominalVoltage - targetVoltage) > voltageTolerance } + if !mismatched.isEmpty { + return .voltage(target: targetVoltage, mismatchedCount: mismatched.count) + } + } + + if let targetCapacity = dominantCapacity { + let mismatched = batteries.filter { abs($0.capacityAmpHours - targetCapacity) > capacityTolerance } + if !mismatched.isEmpty { + return .capacity(target: targetCapacity, mismatchedCount: mismatched.count) + } + } + + return nil + } + + private func emptySubtitle(for systemName: String) -> 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, systemName) + } + + private func detailInfo(for status: BankStatus) -> (title: String, message: String) { + switch status { + case let .voltage(target, mismatchedCount): + let countText = mismatchedCount == 1 + ? NSLocalizedString( + "battery.bank.status.single.battery", + bundle: .main, + value: "One battery", + comment: "Singular form describing mismatched battery count" + ) + : String( + format: NSLocalizedString( + "battery.bank.status.multiple.batteries", + bundle: .main, + value: "%d batteries", + comment: "Plural form describing mismatched battery count" + ), + mismatchedCount + ) + let expected = formattedValue(target, unit: "V") + let message = String( + format: NSLocalizedString( + "battery.bank.status.voltage.message", + bundle: .main, + value: "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters.", + comment: "Explanation for voltage mismatch in battery bank" + ), + countText, + expected + ) + return ( + NSLocalizedString( + "battery.bank.status.voltage.title", + bundle: .main, + value: "Voltage mismatch", + comment: "Title for voltage mismatch alert" + ), + message + ) + case let .capacity(target, mismatchedCount): + let countText = mismatchedCount == 1 + ? NSLocalizedString( + "battery.bank.status.single.battery", + bundle: .main, + value: "One battery", + comment: "Singular form describing mismatched battery count" + ) + : String( + format: NSLocalizedString( + "battery.bank.status.multiple.batteries", + bundle: .main, + value: "%d batteries", + comment: "Plural form describing mismatched battery count" + ), + mismatchedCount + ) + let expected = formattedValue(target, unit: "Ah") + let message = String( + format: NSLocalizedString( + "battery.bank.status.capacity.message", + bundle: .main, + value: "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear.", + comment: "Explanation for capacity mismatch in battery bank" + ), + countText, + expected + ) + return ( + NSLocalizedString( + "battery.bank.status.capacity.title", + bundle: .main, + value: "Capacity mismatch", + comment: "Title for capacity mismatch alert" + ), + message + ) + } + } } private enum BatteriesViewPreviewData { diff --git a/Cable/BatteryEditorView.swift b/Cable/BatteryEditorView.swift index e7aeab1..4281ef2 100644 --- a/Cable/BatteryEditorView.swift +++ b/Cable/BatteryEditorView.swift @@ -3,10 +3,93 @@ import SwiftUI struct BatteryEditorView: View { @Environment(\.dismiss) private var dismiss @State private var configuration: BatteryConfiguration + @State private var editingField: EditingField? let onSave: (BatteryConfiguration) -> Void let onCancel: () -> Void + private enum EditingField { + case voltage + case capacity + } + + private let voltageSnapValues: [Double] = [6, 12, 12.8, 24, 25.6, 36, 48, 51.2] + private let capacitySnapValues: [Double] = [10, 20, 50, 75, 100, 125, 150, 200, 300, 400, 600, 800, 1000] + private let voltageSnapTolerance: Double = 0.5 + private let capacitySnapTolerance: Double = 10.0 + + private var nameFieldLabel: String { + String( + localized: "battery.editor.field.name", + bundle: .main, + comment: "Label for the battery name text field" + ) + } + + private var namePlaceholder: String { + String( + localized: "battery.editor.placeholder.name", + bundle: .main, + comment: "Placeholder example for the battery name field" + ) + } + + private var chemistryLabel: String { + String( + localized: "battery.editor.field.chemistry", + bundle: .main, + comment: "Label describing the chemistry menu" + ) + } + + private var summaryLabel: String { + String( + localized: "battery.editor.section.summary", + bundle: .main, + comment: "Label for the summary section in the editor" + ) + } + + private var sliderVoltageTitle: String { + String( + localized: "battery.editor.slider.voltage", + bundle: .main, + comment: "Title for the nominal voltage slider" + ) + } + + private var sliderCapacityTitle: String { + String( + localized: "battery.editor.slider.capacity", + bundle: .main, + comment: "Title for the capacity slider" + ) + } + + private var summaryVoltageLabel: String { + String( + localized: "battery.bank.badge.voltage", + bundle: .main, + comment: "Label used for voltage values" + ) + } + + private var summaryCapacityLabel: String { + String( + localized: "battery.bank.badge.capacity", + bundle: .main, + comment: "Label used for capacity values" + ) + } + + private var summaryEnergyLabel: String { + String( + localized: "battery.bank.badge.energy", + bundle: .main, + comment: "Label used for energy values" + ) + } + init(configuration: BatteryConfiguration, onSave: @escaping (BatteryConfiguration) -> Void, onCancel: @escaping () -> Void) { _configuration = State(initialValue: configuration) self.onSave = onSave @@ -59,15 +142,131 @@ struct BatteryEditorView: View { .disabled(configuration.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } + .alert( + NSLocalizedString( + "battery.editor.alert.voltage.title", + bundle: .main, + value: "Edit Nominal Voltage", + comment: "Title for the voltage edit alert" + ), + isPresented: Binding( + get: { editingField == .voltage }, + set: { if !$0 { editingField = nil } } + ) + ) { + TextField( + NSLocalizedString( + "battery.editor.alert.voltage.placeholder", + bundle: .main, + value: "Voltage", + comment: "Placeholder for voltage text field" + ), + value: $configuration.nominalVoltage, + format: .number + ) + .keyboardType(.decimalPad) + + Button( + NSLocalizedString( + "battery.editor.alert.cancel", + bundle: .main, + value: "Cancel", + comment: "Cancel button title for edit alerts" + ), + role: .cancel + ) { editingField = nil } + + Button( + NSLocalizedString( + "battery.editor.alert.save", + bundle: .main, + value: "Save", + comment: "Save button title for edit alerts" + ) + ) { + editingField = nil + let normalized = normalizedVoltage(for: configuration.nominalVoltage) + if abs(normalized - configuration.nominalVoltage) > 0.000001 { + configuration.nominalVoltage = normalized + } + } + } message: { + Text( + NSLocalizedString( + "battery.editor.alert.voltage.message", + bundle: .main, + value: "Enter voltage in volts (V)", + comment: "Message for the voltage edit alert" + ) + ) + } + .alert( + NSLocalizedString( + "battery.editor.alert.capacity.title", + bundle: .main, + value: "Edit Capacity", + comment: "Title for the capacity edit alert" + ), + isPresented: Binding( + get: { editingField == .capacity }, + set: { if !$0 { editingField = nil } } + ) + ) { + TextField( + NSLocalizedString( + "battery.editor.alert.capacity.placeholder", + bundle: .main, + value: "Capacity", + comment: "Placeholder for capacity text field" + ), + value: $configuration.capacityAmpHours, + format: .number + ) + .keyboardType(.decimalPad) + + Button( + NSLocalizedString( + "battery.editor.alert.cancel", + bundle: .main, + value: "Cancel", + comment: "Cancel button title for edit alerts" + ), + role: .cancel + ) { editingField = nil } + + Button( + NSLocalizedString( + "battery.editor.alert.save", + bundle: .main, + value: "Save", + comment: "Save button title for edit alerts" + ) + ) { + editingField = nil + let normalized = normalizedCapacity(for: configuration.capacityAmpHours) + if abs(normalized - configuration.capacityAmpHours) > 0.000001 { + configuration.capacityAmpHours = normalized + } + } + } message: { + Text( + NSLocalizedString( + "battery.editor.alert.capacity.message", + bundle: .main, + value: "Enter capacity in amp-hours (Ah)", + comment: "Message for the capacity edit alert" + ) + ) + } } private var headerCard: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { - Text("Name") + Text(nameFieldLabel) .font(.caption) .foregroundStyle(.secondary) - TextField("House Bank", text: $configuration.name) + TextField(namePlaceholder, text: $configuration.name) .textInputAutocapitalization(.words) .padding(.vertical, 10) .padding(.horizontal, 12) @@ -78,7 +277,7 @@ struct BatteryEditorView: View { } VStack(alignment: .leading, spacing: 8) { - Text("Chemistry") + Text(chemistryLabel) .font(.caption) .foregroundStyle(.secondary) Menu { @@ -113,23 +312,23 @@ struct BatteryEditorView: View { } VStack(alignment: .leading, spacing: 6) { - Text("Summary") + Text(summaryLabel) .font(.caption) .foregroundStyle(.secondary) ViewThatFits(in: .horizontal) { HStack(spacing: 16) { summaryBadge( - title: "Voltage", + title: summaryVoltageLabel, value: formattedValue(configuration.nominalVoltage, unit: "V"), symbol: "bolt" ) summaryBadge( - title: "Capacity", + title: summaryCapacityLabel, value: formattedValue(configuration.capacityAmpHours, unit: "Ah"), symbol: "gauge.medium" ) summaryBadge( - title: "Energy", + title: summaryEnergyLabel, value: formattedValue(configuration.energyWattHours, unit: "Wh"), symbol: "battery.100.bolt" ) @@ -137,17 +336,17 @@ struct BatteryEditorView: View { VStack(spacing: 12) { summaryBadge( - title: "Voltage", + title: summaryVoltageLabel, value: formattedValue(configuration.nominalVoltage, unit: "V"), symbol: "bolt" ) summaryBadge( - title: "Capacity", + title: summaryCapacityLabel, value: formattedValue(configuration.capacityAmpHours, unit: "Ah"), symbol: "gauge.medium" ) summaryBadge( - title: "Energy", + title: summaryEnergyLabel, value: formattedValue(configuration.energyWattHours, unit: "Wh"), symbol: "battery.100.bolt" ) @@ -165,19 +364,34 @@ struct BatteryEditorView: View { private var slidersSection: some View { VStack(spacing: 30) { SliderSection( - title: "Nominal Voltage", + title: sliderVoltageTitle, value: $configuration.nominalVoltage, range: 6...60, unit: "V", - snapValues: [6, 12, 12.8, 24, 25.6, 36, 48, 51.2] + tapAction: { editingField = .voltage }, + snapValues: voltageSnapValues ) + .onChange(of: configuration.nominalVoltage) { _, newValue in + let normalized = normalizedVoltage(for: newValue) + if abs(normalized - newValue) > 0.000001 { + configuration.nominalVoltage = normalized + } + } + SliderSection( - title: "Capacity", + title: sliderCapacityTitle, value: $configuration.capacityAmpHours, range: 5...1000, unit: "Ah", - snapValues: [10, 20, 50, 75, 100, 125, 150, 200, 300, 400, 600, 800, 1000] + tapAction: { editingField = .capacity }, + snapValues: capacitySnapValues ) + .onChange(of: configuration.capacityAmpHours) { _, newValue in + let normalized = normalizedCapacity(for: newValue) + if abs(normalized - newValue) > 0.000001 { + configuration.capacityAmpHours = normalized + } + } } .padding() .background( @@ -186,6 +400,27 @@ struct BatteryEditorView: View { ) } + private func normalizedVoltage(for value: Double) -> Double { + let rounded = (value * 10).rounded() / 10 + if let snapped = nearestValue(to: rounded, in: voltageSnapValues, tolerance: voltageSnapTolerance) { + return snapped + } + return rounded + } + + private func normalizedCapacity(for value: Double) -> Double { + let rounded = (value * 10).rounded() / 10 + if let snapped = nearestValue(to: rounded, in: capacitySnapValues, tolerance: capacitySnapTolerance) { + return snapped + } + 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 summaryBadge(title: String, value: String, symbol: String) -> some View { VStack(spacing: 4) { Image(systemName: symbol) diff --git a/Cable/ChargersView.swift b/Cable/ChargersView.swift index 11204c1..f5e876b 100644 --- a/Cable/ChargersView.swift +++ b/Cable/ChargersView.swift @@ -3,17 +3,34 @@ import SwiftUI struct ChargersView: View { let system: ElectricalSystem + private var titleText: String { + let format = NSLocalizedString( + "chargers.title", + bundle: .main, + comment: "Title describing chargers belonging to a system" + ) + return String(format: format, system.name) + } + + private var subtitleText: String { + String( + localized: "chargers.subtitle", + bundle: .main, + comment: "Subtitle shown while chargers tab is under construction" + ) + } + var body: some View { VStack(spacing: 16) { Image(systemName: "bolt.fill") .font(.largeTitle) .foregroundStyle(.secondary) - Text("Chargers for \(system.name)") + Text(titleText) .font(.title3) .fontWeight(.semibold) - Text("Charger components will be available soon.") + Text(subtitleText) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) diff --git a/Cable/LoadsView.swift b/Cable/LoadsView.swift index 968fe0d..55e998e 100644 --- a/Cable/LoadsView.swift +++ b/Cable/LoadsView.swift @@ -50,7 +50,14 @@ struct LoadsView: View { componentsTab .tag(ComponentTab.components) .tabItem { - Label("Components", systemImage: "square.stack.3d.up") + Label( + String( + localized: "tab.components", + bundle: .main, + comment: "Tab title for components list" + ), + systemImage: "square.stack.3d.up" + ) } BatteriesView( system: system, @@ -60,12 +67,26 @@ struct LoadsView: View { ) .tag(ComponentTab.batteries) .tabItem { - Label("Batteries", systemImage: "battery.100") + Label( + String( + localized: "tab.batteries", + bundle: .main, + comment: "Tab title for battery configurations" + ), + systemImage: "battery.100" + ) } ChargersView(system: system) .tag(ComponentTab.chargers) .tabItem { - Label("Chargers", systemImage: "bolt.fill") + Label( + String( + localized: "tab.chargers", + bundle: .main, + comment: "Tab title for chargers view" + ), + systemImage: "bolt.fill" + ) } } } diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index 08328d0..36a3cc6 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -33,7 +33,7 @@ "slider.length.title" = "Kabellänge (%@)"; "slider.power.title" = "Leistung"; "slider.voltage.title" = "Spannung"; -"system.list.no.components" = "Noch keine Komponenten"; +"system.list.no.components" = "Noch keine Verbraucher"; "units.imperial.display" = "Imperial (AWG, ft)"; "units.metric.display" = "Metrisch (mm², m)"; "sample.system.rv.name" = "Abenteuer-Van"; @@ -78,14 +78,14 @@ "Create your first system" = "Erstelle dein erstes System"; "Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ob \"**Mein System**\", \"**Motoryacht LARGO**\" oder \"**Camper Luigi**\". Dein System braucht einen Namen."; "Add your first component" = "Erstelle deine erste Komponente"; -"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Komponenten sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest."; +"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Verbraucher sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest."; "Create Component" = "Komponente erstellen"; "Browse Library" = "Bibliothek durchsuchen"; "Browse" = "Durchsuchen"; -"Browse electrical components from VoltPlan" = "Elektrische Komponenten von VoltPlan durchstöbern"; +"Browse electrical components from VoltPlan" = "Elektrische Verbraucher von VoltPlan durchstöbern"; "Component Library" = "Komponentenbibliothek"; "Details coming soon" = "Details folgen in Kürze"; -"Components" = "Komponenten"; +"Components" = "Verbraucher"; "FUSE" = "SICHERUNG"; "WIRE" = "KABEL"; "Current" = "Strom"; @@ -135,3 +135,48 @@ "Color" = "Farbe"; "VoltPlan Library" = "VoltPlan-Bibliothek"; "New Load" = "Neuer Verbraucher"; + +"tab.components" = "Verbraucher"; +"tab.batteries" = "Batterien"; +"tab.chargers" = "Ladegeräte"; + +"battery.bank.header.title" = "Batteriebank"; +"battery.bank.metric.count" = "Batterien"; +"battery.bank.metric.capacity" = "Kapazität"; +"battery.bank.metric.energy" = "Energie"; +"battery.bank.badge.voltage" = "Spannung"; +"battery.bank.badge.capacity" = "Kapazität"; +"battery.bank.badge.energy" = "Energie"; +"battery.bank.banner.voltage" = "Spannungsabweichung erkannt"; +"battery.bank.banner.capacity" = "Kapazitätsabweichung erkannt"; +"battery.bank.empty.title" = "Noch keine Batterien"; +"battery.bank.empty.subtitle" = "Tippe auf Plus, um eine Batterie für %@ zu konfigurieren."; +"battery.bank.status.dismiss" = "Verstanden"; +"battery.bank.status.single.battery" = "Eine Batterie"; +"battery.bank.status.multiple.batteries" = "%d Batterien"; +"battery.bank.status.voltage.title" = "Spannungsabweichung"; +"battery.bank.status.voltage.message" = "%@ weicht vom Bank-Basiswert %@ ab. Unterschiedliche Nennspannungen führen zu ungleichmäßigem Laden und können Ladegeräte oder Wechselrichter beschädigen."; +"battery.bank.status.capacity.title" = "Kapazitätsabweichung"; +"battery.bank.status.capacity.message" = "%@ nutzt eine andere Kapazität als der dominante Bankwert %@. Unterschiedliche Kapazitäten verursachen ungleichmäßige Entladung und vorzeitige Alterung."; + +"battery.editor.title" = "Batterie einrichten"; +"battery.editor.cancel" = "Abbrechen"; +"battery.editor.save" = "Speichern"; +"battery.editor.field.name" = "Name"; +"battery.editor.placeholder.name" = "Hausbank"; +"battery.editor.field.chemistry" = "Chemie"; +"battery.editor.section.summary" = "Übersicht"; +"battery.editor.slider.voltage" = "Nennspannung"; +"battery.editor.slider.capacity" = "Kapazität"; +"battery.editor.alert.voltage.title" = "Nennspannung bearbeiten"; +"battery.editor.alert.voltage.placeholder" = "Spannung"; +"battery.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben"; +"battery.editor.alert.capacity.title" = "Kapazität bearbeiten"; +"battery.editor.alert.capacity.placeholder" = "Kapazität"; +"battery.editor.alert.capacity.message" = "Kapazität in Amperestunden (Ah) eingeben"; +"battery.editor.alert.cancel" = "Abbrechen"; +"battery.editor.alert.save" = "Speichern"; +"battery.editor.default_name" = "Neue Batterie"; + +"chargers.title" = "Ladegeräte für %@"; +"chargers.subtitle" = "Ladegeräte-Komponenten sind bald verfügbar."; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index aafda08..2232b55 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -134,3 +134,48 @@ "Color" = "Color"; "VoltPlan Library" = "Biblioteca de VoltPlan"; "New Load" = "Carga nueva"; + +"tab.components" = "Componentes"; +"tab.batteries" = "Baterías"; +"tab.chargers" = "Cargadores"; + +"battery.bank.header.title" = "Banco de baterías"; +"battery.bank.metric.count" = "Baterías"; +"battery.bank.metric.capacity" = "Capacidad"; +"battery.bank.metric.energy" = "Energía"; +"battery.bank.badge.voltage" = "Voltaje"; +"battery.bank.badge.capacity" = "Capacidad"; +"battery.bank.badge.energy" = "Energía"; +"battery.bank.banner.voltage" = "Se detectó un desajuste de voltaje"; +"battery.bank.banner.capacity" = "Se detectó un desajuste de capacidad"; +"battery.bank.empty.title" = "Sin baterías todavía"; +"battery.bank.empty.subtitle" = "Toca el botón más para configurar una batería para %@."; +"battery.bank.status.dismiss" = "Entendido"; +"battery.bank.status.single.battery" = "Una batería"; +"battery.bank.status.multiple.batteries" = "%d baterías"; +"battery.bank.status.voltage.title" = "Desajuste de voltaje"; +"battery.bank.status.voltage.message" = "%@ se desvía del voltaje base del banco %@. Mezclar voltajes nominales provoca carga desigual y puede dañar cargadores o inversores conectados."; +"battery.bank.status.capacity.title" = "Desajuste de capacidad"; +"battery.bank.status.capacity.message" = "%@ usa una capacidad distinta del valor dominante del banco %@. Las capacidades desiguales provocan descargas irregulares y desgaste prematuro."; + +"battery.editor.title" = "Configuración de batería"; +"battery.editor.cancel" = "Cancelar"; +"battery.editor.save" = "Guardar"; +"battery.editor.field.name" = "Nombre"; +"battery.editor.placeholder.name" = "Banco principal"; +"battery.editor.field.chemistry" = "Química"; +"battery.editor.section.summary" = "Resumen"; +"battery.editor.slider.voltage" = "Voltaje nominal"; +"battery.editor.slider.capacity" = "Capacidad"; +"battery.editor.alert.voltage.title" = "Editar voltaje nominal"; +"battery.editor.alert.voltage.placeholder" = "Voltaje"; +"battery.editor.alert.voltage.message" = "Introduce el voltaje en voltios (V)"; +"battery.editor.alert.capacity.title" = "Editar capacidad"; +"battery.editor.alert.capacity.placeholder" = "Capacidad"; +"battery.editor.alert.capacity.message" = "Introduce la capacidad en amperios-hora (Ah)"; +"battery.editor.alert.cancel" = "Cancelar"; +"battery.editor.alert.save" = "Guardar"; +"battery.editor.default_name" = "Nueva batería"; + +"chargers.title" = "Cargadores para %@"; +"chargers.subtitle" = "Los componentes de carga estarán disponibles pronto."; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index 1ab2cd2..29a0590 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -134,3 +134,48 @@ "Color" = "Couleur"; "VoltPlan Library" = "Bibliothèque VoltPlan"; "New Load" = "Nouvelle charge"; + +"tab.components" = "Composants"; +"tab.batteries" = "Batteries"; +"tab.chargers" = "Chargeurs"; + +"battery.bank.header.title" = "Banque de batteries"; +"battery.bank.metric.count" = "Batteries"; +"battery.bank.metric.capacity" = "Capacité"; +"battery.bank.metric.energy" = "Énergie"; +"battery.bank.badge.voltage" = "Tension"; +"battery.bank.badge.capacity" = "Capacité"; +"battery.bank.badge.energy" = "Énergie"; +"battery.bank.banner.voltage" = "Écart de tension détecté"; +"battery.bank.banner.capacity" = "Écart de capacité détecté"; +"battery.bank.empty.title" = "Aucune batterie pour l'instant"; +"battery.bank.empty.subtitle" = "Touchez le bouton plus pour configurer une batterie pour %@."; +"battery.bank.status.dismiss" = "Compris"; +"battery.bank.status.single.battery" = "Une batterie"; +"battery.bank.status.multiple.batteries" = "%d batteries"; +"battery.bank.status.voltage.title" = "Écart de tension"; +"battery.bank.status.voltage.message" = "%@ s'écarte de la valeur de référence %@ du banc. Mélanger des tensions nominales entraîne une charge inégale et peut endommager les chargeurs ou onduleurs connectés."; +"battery.bank.status.capacity.title" = "Écart de capacité"; +"battery.bank.status.capacity.message" = "%@ utilise une capacité différente de la valeur dominante %@ du banc. Des capacités différentes provoquent des décharges inégales et une usure prématurée."; + +"battery.editor.title" = "Configuration de la batterie"; +"battery.editor.cancel" = "Annuler"; +"battery.editor.save" = "Enregistrer"; +"battery.editor.field.name" = "Nom"; +"battery.editor.placeholder.name" = "Banque principale"; +"battery.editor.field.chemistry" = "Chimie"; +"battery.editor.section.summary" = "Résumé"; +"battery.editor.slider.voltage" = "Tension nominale"; +"battery.editor.slider.capacity" = "Capacité"; +"battery.editor.alert.voltage.title" = "Modifier la tension nominale"; +"battery.editor.alert.voltage.placeholder" = "Tension"; +"battery.editor.alert.voltage.message" = "Saisissez la tension en volts (V)"; +"battery.editor.alert.capacity.title" = "Modifier la capacité"; +"battery.editor.alert.capacity.placeholder" = "Capacité"; +"battery.editor.alert.capacity.message" = "Saisissez la capacité en ampères-heures (Ah)"; +"battery.editor.alert.cancel" = "Annuler"; +"battery.editor.alert.save" = "Enregistrer"; +"battery.editor.default_name" = "Nouvelle batterie"; + +"chargers.title" = "Chargeurs pour %@"; +"chargers.subtitle" = "Les chargeurs seront bientôt disponibles ici."; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index 3ec1db3..baec5b8 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -134,3 +134,48 @@ "Color" = "Kleur"; "VoltPlan Library" = "VoltPlan-bibliotheek"; "New Load" = "Nieuwe last"; + +"tab.components" = "Componenten"; +"tab.batteries" = "Batterijen"; +"tab.chargers" = "Laders"; + +"battery.bank.header.title" = "Accubank"; +"battery.bank.metric.count" = "Batterijen"; +"battery.bank.metric.capacity" = "Capaciteit"; +"battery.bank.metric.energy" = "Energie"; +"battery.bank.badge.voltage" = "Spanning"; +"battery.bank.badge.capacity" = "Capaciteit"; +"battery.bank.badge.energy" = "Energie"; +"battery.bank.banner.voltage" = "Spanningsafwijking gedetecteerd"; +"battery.bank.banner.capacity" = "Capaciteitsafwijking gedetecteerd"; +"battery.bank.empty.title" = "Nog geen batterijen"; +"battery.bank.empty.subtitle" = "Tik op de plusknop om een batterij voor %@ te configureren."; +"battery.bank.status.dismiss" = "Begrepen"; +"battery.bank.status.single.battery" = "Eén batterij"; +"battery.bank.status.multiple.batteries" = "%d batterijen"; +"battery.bank.status.voltage.title" = "Spanningsafwijking"; +"battery.bank.status.voltage.message" = "%@ wijkt af van de basiswaarde %@ van de bank. Verschillende nominale spanningen zorgen voor ongelijk laden en kunnen aangesloten laders of omvormers beschadigen."; +"battery.bank.status.capacity.title" = "Capaciteitsafwijking"; +"battery.bank.status.capacity.message" = "%@ gebruikt een andere capaciteit dan de dominante bankwaarde %@. Verschillende capaciteiten zorgen voor ongelijk ontladen en vroegtijdige slijtage."; + +"battery.editor.title" = "Batterij configureren"; +"battery.editor.cancel" = "Annuleren"; +"battery.editor.save" = "Opslaan"; +"battery.editor.field.name" = "Naam"; +"battery.editor.placeholder.name" = "Huishoudbank"; +"battery.editor.field.chemistry" = "Chemie"; +"battery.editor.section.summary" = "Overzicht"; +"battery.editor.slider.voltage" = "Nominale spanning"; +"battery.editor.slider.capacity" = "Capaciteit"; +"battery.editor.alert.voltage.title" = "Nominale spanning bewerken"; +"battery.editor.alert.voltage.placeholder" = "Spanning"; +"battery.editor.alert.voltage.message" = "Voer de spanning in volt (V) in"; +"battery.editor.alert.capacity.title" = "Capaciteit bewerken"; +"battery.editor.alert.capacity.placeholder" = "Capaciteit"; +"battery.editor.alert.capacity.message" = "Voer de capaciteit in ampère-uur (Ah) in"; +"battery.editor.alert.cancel" = "Annuleren"; +"battery.editor.alert.save" = "Opslaan"; +"battery.editor.default_name" = "Nieuwe batterij"; + +"chargers.title" = "Laders voor %@"; +"chargers.subtitle" = "Ladercomponenten zijn binnenkort beschikbaar.";