german and spanish translation

This commit is contained in:
Stefan Lange-Hegermann
2025-10-03 00:15:52 +02:00
parent 2f0cebceed
commit 03aa843f26
17 changed files with 627 additions and 55 deletions

View File

@@ -205,6 +205,8 @@
knownRegions = (
en,
Base,
de,
es,
);
mainGroup = 3E5C0BC32E72C0FD00247EC8;
minimizedProjectReferenceProxies = 1;

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -0,0 +1,37 @@
"affiliate.button.review_parts" = "Review parts";
"affiliate.description.with_link" = "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.";
"affiliate.description.without_link" = "Tapping above shows a full bill of materials with shopping searches to help you source parts.";
"affiliate.disclaimer" = "Purchases through affiliate links may support VoltPlan.";
"bom.accessibility.mark.complete" = "Mark %@ complete";
"bom.accessibility.mark.incomplete" = "Mark %@ incomplete";
"bom.fuse.detail" = "Inline holder and %dA fuse";
"bom.item.cable.black" = "Power Cable (Black)";
"bom.item.cable.red" = "Power Cable (Red)";
"bom.item.fuse" = "Fuse & Holder";
"bom.item.terminals" = "Cable Shoes / Terminals";
"bom.navigation.title" = "Bill of Materials";
"bom.navigation.title.system" = "BOM %@";
"bom.size.unknown" = "Size TBD";
"bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
"component.fallback.name" = "Component";
"default.load.library" = "Library Load";
"default.load.name" = "My Load";
"default.load.unnamed" = "Unnamed Load";
"default.load.new" = "New Load";
"default.system.name" = "My System";
"default.system.new" = "New System";
"editor.load.name_field" = "Load name";
"editor.load.preview" = "Preview";
"editor.load.title" = "Edit Load";
"editor.system.location.optional" = "Location (optional)";
"editor.system.name_field" = "System name";
"editor.system.title" = "Edit System";
"slider.button.ampere" = "Ampere";
"slider.button.watt" = "Watt";
"slider.current.title" = "Current";
"slider.length.title" = "Cable Length (%@)";
"slider.power.title" = "Power";
"slider.voltage.title" = "Voltage";
"system.list.no.components" = "No components yet";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Metric (mm², m)";

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>system.list.component.summary</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@component_count@ • %@</string>
<key>component_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>%d component</string>
<key>other</key>
<string>%d components</string>
</dict>
</dict>
</dict>
</plist>

View File

@@ -13,7 +13,7 @@ class CableCalculator: ObservableObject {
@Published var current: Double = 5.0
@Published var power: Double = 60.0
@Published var length: Double = 10.0
@Published var loadName: String = "My Load"
@Published var loadName: String = String(localized: "default.load.name", comment: "Default placeholder name for a load")
var calculatedPower: Double {
voltage * current

View File

@@ -241,7 +241,7 @@ struct CalculatorView: View {
let countryCode = rawCountryCode?.uppercased()
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 }
let buttonTitle = "Review parts"
let buttonTitle = String(localized: "affiliate.button.review_parts", comment: "Button title to review a bill of materials before shopping")
let identifier = "bom-\(savedLoad.name)-\(savedLoad.timestamp.timeIntervalSince1970)"
return AffiliateLinkInfo(
@@ -520,7 +520,14 @@ struct CalculatorView: View {
}
.buttonStyle(.plain)
Text(info.affiliateURL != nil ? "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan." : "Tapping above shows a full bill of materials with shopping searches to help you source parts.")
let descriptionKey = info.affiliateURL != nil
? "affiliate.description.with_link"
: "affiliate.description.without_link"
let description = NSLocalizedString(
descriptionKey,
comment: "Explanation text beneath the affiliate button"
)
Text(description)
.font(.caption2)
.foregroundColor(.secondary)
}
@@ -537,19 +544,36 @@ struct CalculatorView: View {
let crossSectionValue = calculator.crossSection(for: unitSystem)
let crossSectionLabel: String
let unknownSizeLabel = String(localized: "bom.size.unknown", comment: "Fallback label when the cable size is unknown")
if unitSystem == .imperial {
crossSectionLabel = String(format: "AWG %.0f", crossSectionValue)
if crossSectionValue > 0 {
crossSectionLabel = String(format: "AWG %.0f", crossSectionValue)
} else {
crossSectionLabel = unknownSizeLabel
}
} else {
crossSectionLabel = String(format: "%.1f mm²", crossSectionValue)
if crossSectionValue > 0 {
crossSectionLabel = String(format: "%.1f mm²", crossSectionValue)
} else {
crossSectionLabel = unknownSizeLabel
}
}
let cableDetail = "\(lengthLabel)\(crossSectionLabel)"
let powerDetail = String(format: "%.0f W @ %.1f V", calculator.calculatedPower, calculator.voltage)
let fuseRating = calculator.recommendedFuse
let fuseDetail = "Inline holder and \(fuseRating)A fuse"
let fuseDetailFormat = NSLocalizedString(
"bom.fuse.detail",
comment: "Description for the fuse entry in the calculator BOM"
)
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
let cableShoesDetail = "Ring or spade terminals sized for \(crossSectionLabel) wiring"
let cableShoesDetailFormat = NSLocalizedString(
"bom.terminals.detail",
comment: "Description for the cable terminals entry in the calculator BOM"
)
let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased())
let cableGaugeQuery: String
if unitSystem == .imperial {
@@ -568,10 +592,12 @@ struct CalculatorView: View {
var items: [BOMItem] = []
let fallbackComponentTitle = String(localized: "component.fallback.name", comment: "Fallback name for a component when no custom name is provided")
items.append(
BOMItem(
id: "component",
title: calculator.loadName.isEmpty ? "Component" : calculator.loadName,
title: calculator.loadName.isEmpty ? fallbackComponentTitle : calculator.loadName,
detail: powerDetail,
iconSystemName: "bolt.fill",
destination: deviceLink.map { .affiliate($0) } ?? .amazonSearch(deviceQueryBase),
@@ -582,7 +608,7 @@ struct CalculatorView: View {
items.append(
BOMItem(
id: "cable-red",
title: "Power Cable (Red)",
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
detail: cableDetail,
iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(redCableQuery),
@@ -593,7 +619,7 @@ struct CalculatorView: View {
items.append(
BOMItem(
id: "cable-black",
title: "Power Cable (Black)",
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
detail: cableDetail,
iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(blackCableQuery),
@@ -604,7 +630,7 @@ struct CalculatorView: View {
items.append(
BOMItem(
id: "fuse",
title: "Fuse & Holder",
title: String(localized: "bom.item.fuse", comment: "Title for the fuse item"),
detail: fuseDetail,
iconSystemName: "bolt.shield",
destination: .amazonSearch(fuseQuery),
@@ -615,7 +641,7 @@ struct CalculatorView: View {
items.append(
BOMItem(
id: "terminals",
title: "Cable Shoes / Terminals",
title: String(localized: "bom.item.terminals", comment: "Title for the terminals item"),
detail: cableShoesDetail,
iconSystemName: "wrench.and.screwdriver",
destination: .amazonSearch(terminalQuery),
@@ -636,7 +662,7 @@ struct CalculatorView: View {
}
private var voltageSlider: some View {
SliderSection(title: "Voltage",
SliderSection(title: String(localized: "slider.voltage.title", comment: "Title for the voltage slider"),
value: $calculator.voltage,
range: 3...48,
unit: "V",
@@ -656,11 +682,11 @@ struct CalculatorView: View {
@ViewBuilder
private var currentPowerSlider: some View {
if isWattMode {
SliderSection(title: "Power",
SliderSection(title: String(localized: "slider.power.title", comment: "Title for the power slider"),
value: $calculator.power,
range: 0...2000,
unit: "W",
buttonText: "Watt",
buttonText: String(localized: "slider.button.watt", comment: "Button label when showing power control"),
buttonAction: {
isWattMode = false
calculator.updateFromPower()
@@ -674,11 +700,11 @@ struct CalculatorView: View {
autoUpdateSavedLoad()
}
} else {
SliderSection(title: "Current",
SliderSection(title: String(localized: "slider.current.title", comment: "Title for the current slider"),
value: $calculator.current,
range: 0...100,
unit: "A",
buttonText: "Ampere",
buttonText: String(localized: "slider.button.ampere", comment: "Button label when showing current control"),
buttonAction: {
isWattMode = true
calculator.updateFromCurrent()
@@ -695,7 +721,11 @@ struct CalculatorView: View {
}
private var lengthSlider: some View {
SliderSection(title: "Cable Length (\(unitSettings.unitSystem.lengthUnit))",
let lengthTitleFormat = NSLocalizedString(
"slider.length.title",
comment: "Title format for the cable length slider"
)
return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit),
value: $calculator.length,
range: 0...20,
unit: unitSettings.unitSystem.lengthUnit,
@@ -711,8 +741,9 @@ struct CalculatorView: View {
private func saveCurrentLoad() {
let fallbackName = String(localized: "default.load.unnamed", comment: "Fallback name for a load when no name is provided")
let savedLoad = SavedLoad(
name: calculator.loadName.isEmpty ? "Unnamed Load" : calculator.loadName,
name: calculator.loadName.isEmpty ? fallbackName : calculator.loadName,
voltage: calculator.voltage,
current: calculator.current,
power: calculator.power,
@@ -740,7 +771,8 @@ struct CalculatorView: View {
private func autoUpdateSavedLoad() {
guard let savedLoad = savedLoad else { return }
savedLoad.name = calculator.loadName.isEmpty ? "Unnamed Load" : calculator.loadName
let fallbackName = String(localized: "default.load.unnamed", comment: "Fallback name for a load when no name is provided")
savedLoad.name = calculator.loadName.isEmpty ? fallbackName : calculator.loadName
savedLoad.voltage = calculator.voltage
savedLoad.current = calculator.current
savedLoad.power = calculator.power
@@ -776,6 +808,21 @@ private struct BillOfMaterialsView: View {
ForEach(items) { item in
let isCompleted = completedItemIDs.contains(item.id)
let destinationURL = destinationURL(for: item)
let accessibilityLabel: String = {
if isCompleted {
let format = NSLocalizedString(
"bom.accessibility.mark.incomplete",
comment: "Accessibility label to mark a BOM item incomplete"
)
return String.localizedStringWithFormat(format, item.title)
} else {
let format = NSLocalizedString(
"bom.accessibility.mark.complete",
comment: "Accessibility label to mark a BOM item complete"
)
return String.localizedStringWithFormat(format, item.title)
}
}()
HStack(spacing: 12) {
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
@@ -789,7 +836,7 @@ private struct BillOfMaterialsView: View {
}
suppressRowTapForID = item.id
}
.accessibilityLabel(isCompleted ? "Mark \(item.title) incomplete" : "Mark \(item.title) complete")
.accessibilityLabel(accessibilityLabel)
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
@@ -798,7 +845,7 @@ private struct BillOfMaterialsView: View {
.strikethrough(isCompleted, color: .accentColor.opacity(0.6))
if item.isPrimaryComponent {
Text("Component")
Text(String(localized: "component.fallback.name", comment: "Tag label marking a BOM entry as the main component"))
.font(.caption2.weight(.medium))
.foregroundColor(.accentColor)
.padding(.horizontal, 6)
@@ -840,14 +887,24 @@ private struct BillOfMaterialsView: View {
}
Section {
Text("Purchases through affiliate links may support VoltPlan.")
Text(
String(
localized: "affiliate.disclaimer",
comment: "Footer note reminding users that affiliate purchases may support the app"
)
)
.font(.footnote)
.foregroundColor(.secondary)
.padding(.vertical, 4)
}
}
.listStyle(.insetGrouped)
.navigationTitle("Bill of Materials")
.navigationTitle(
String(
localized: "bom.navigation.title",
comment: "Navigation title for the bill of materials view"
)
)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {

View File

@@ -0,0 +1,120 @@
import SwiftUI
struct ComponentsOnboardingView: View {
@State private var carouselStep = 0
let onCreate: () -> Void
let onBrowse: () -> Void
private let imageNames = [
"fridge-onboarding",
"coffee-onboarding",
"light-onboarding"
]
private let timer = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
private let animationDuration = 0.8
private var loopingImages: [String] {
guard let first = imageNames.first else { return [] }
return imageNames + [first]
}
var body: some View {
VStack {
Spacer(minLength: 32)
OnboardingCarouselView(images: loopingImages, step: carouselStep)
.frame(minHeight: 80, maxHeight: 240)
.padding(.horizontal, 0)
VStack(spacing: 12) {
Text("Add your first component")
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
Text("Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations.")
.font(.body)
.foregroundStyle(Color.secondary)
.multilineTextAlignment(.center)
.frame(minHeight: 72)
.padding(.horizontal, 12)
}
.padding(.horizontal, 24)
Spacer()
VStack(spacing: 12) {
Button(action: createComponent) {
HStack(spacing: 8) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 16))
Text("Create Component")
.font(.headline.weight(.semibold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(Color.blue)
.cornerRadius(12)
}
.buttonStyle(.plain)
Button(action: onBrowse) {
HStack(spacing: 8) {
Image(systemName: "books.vertical")
.font(.system(size: 16))
Text("Browse Library")
.font(.headline.weight(.semibold))
}
.foregroundColor(.blue)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.blue.opacity(0.12))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.blue.opacity(0.24), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 24)
}
.padding(.bottom, 32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.onAppear(perform: resetState)
.onReceive(timer) { _ in advanceCarousel() }
}
private func resetState() {
carouselStep = 0
}
private func createComponent() {
onCreate()
}
private func advanceCarousel() {
guard imageNames.count > 1 else { return }
let next = carouselStep + 1
withAnimation(.easeInOut(duration: animationDuration)) {
carouselStep = next
}
if next == imageNames.count {
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
withAnimation(.none) {
carouselStep = 0
}
}
}
}
}
#Preview {
ComponentsOnboardingView(onCreate: {}, onBrowse: {})
}

View File

@@ -192,7 +192,7 @@ struct SystemsView: View {
private func makeSystem(preferredName: String? = nil, colorName: String? = nil, iconName: String? = nil) -> ElectricalSystem {
let existingNames = Set(systems.map { $0.name })
let trimmedPreferred = preferredName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let baseName = trimmedPreferred.isEmpty ? "New System" : trimmedPreferred
let baseName = trimmedPreferred.isEmpty ? String(localized: "default.system.new", comment: "Default name for a newly created system") : trimmedPreferred
var systemName = baseName
var counter = 2
@@ -221,7 +221,7 @@ struct SystemsView: View {
}
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
let baseName = item.name.isEmpty ? "Library Load" : item.name
let baseName = item.name.isEmpty ? String(localized: "default.load.library", comment: "Default name when importing a library load") : item.name
let loadName = uniqueLoadName(for: system, startingWith: baseName)
let voltage = item.displayVoltage ?? 12.0
@@ -310,7 +310,9 @@ struct SystemsView: View {
private func componentSummary(for system: ElectricalSystem) -> String {
let systemLoads = loads(for: system)
guard !systemLoads.isEmpty else { return "No components yet" }
guard !systemLoads.isEmpty else {
return String(localized: "system.list.no.components", comment: "Message shown when a system has no components yet")
}
let count = systemLoads.count
let totalPower = systemLoads.reduce(0.0) { $0 + $1.power }
@@ -322,7 +324,11 @@ struct SystemsView: View {
formattedPower = String(format: "%.0fW", totalPower)
}
return "\(count) component\(count == 1 ? "" : "s")\(formattedPower) total"
let format = NSLocalizedString(
"system.list.component.summary",
comment: "Summary showing number of components and the total power"
)
return String.localizedStringWithFormat(format, count, formattedPower)
}
private func randomSystemColorName() -> String {
@@ -622,7 +628,8 @@ struct LoadsView: View {
}
private func createNewLoad() {
let loadName = uniqueLoadName(startingWith: "New Load")
let defaultName = String(localized: "default.load.new", comment: "Default name when creating a new load from system view")
let loadName = uniqueLoadName(startingWith: defaultName)
let newLoad = SavedLoad(
name: loadName,
voltage: 12.0,
@@ -903,6 +910,22 @@ private struct SystemBillOfMaterialsView: View {
let destinationURL = destinationURL(for: item.destination, load: load)
HStack(spacing: 12) {
let accessibilityLabel: String = {
if isCompleted {
let format = NSLocalizedString(
"bom.accessibility.mark.incomplete",
comment: "Accessibility label instructing VoiceOver to mark an item incomplete"
)
return String.localizedStringWithFormat(format, item.title)
} else {
let format = NSLocalizedString(
"bom.accessibility.mark.complete",
comment: "Accessibility label instructing VoiceOver to mark an item complete"
)
return String.localizedStringWithFormat(format, item.title)
}
}()
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(isCompleted ? .accentColor : .secondary)
.imageScale(.large)
@@ -910,7 +933,7 @@ private struct SystemBillOfMaterialsView: View {
setCompletion(!isCompleted, for: load, item: item)
suppressRowTapForID = item.id
}
.accessibilityLabel(isCompleted ? "Mark \(item.title) incomplete" : "Mark \(item.title) complete")
.accessibilityLabel(accessibilityLabel)
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
@@ -919,7 +942,7 @@ private struct SystemBillOfMaterialsView: View {
.strikethrough(isCompleted, color: .accentColor.opacity(0.6))
if item.isPrimaryComponent {
Text("Component")
Text(String(localized: "component.fallback.name", comment: "Tag label marking an item as the component itself"))
.font(.caption2.weight(.medium))
.foregroundColor(.accentColor)
.padding(.horizontal, 6)
@@ -971,7 +994,16 @@ private struct SystemBillOfMaterialsView: View {
}
}
.listStyle(.insetGrouped)
.navigationTitle("BOM \(systemName)")
.navigationTitle(
String(
format: NSLocalizedString(
"bom.navigation.title.system",
comment: "Navigation title for the bill of materials view"
),
locale: Locale.current,
systemName
)
)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
@@ -995,7 +1027,8 @@ private struct SystemBillOfMaterialsView: View {
private func sectionHeader(for load: SavedLoad) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(load.name.isEmpty ? "Component" : load.name)
let fallbackTitle = String(localized: "component.fallback.name", comment: "Fallback title for a component that lacks a name")
Text(load.name.isEmpty ? fallbackTitle : load.name)
.font(.headline)
Text(dateFormatter.string(from: load.timestamp))
.font(.caption)
@@ -1014,13 +1047,15 @@ private struct SystemBillOfMaterialsView: View {
let crossSectionLabel: String
let gaugeQuery: String
let unknownSizeLabel = String(localized: "bom.size.unknown", comment: "Fallback label when the cable size is not yet determined")
if unitSystem == .imperial {
let awg = awgFromCrossSection(load.crossSection)
if awg > 0 {
crossSectionLabel = String(format: "AWG %.0f", awg)
gaugeQuery = String(format: "AWG %.0f", awg)
} else {
crossSectionLabel = "Size TBD"
crossSectionLabel = unknownSizeLabel
gaugeQuery = "battery cable"
}
} else {
@@ -1028,7 +1063,7 @@ private struct SystemBillOfMaterialsView: View {
crossSectionLabel = String(format: "%.1f mm²", load.crossSection)
gaugeQuery = String(format: "%.1f mm2", load.crossSection)
} else {
crossSectionLabel = "Size TBD"
crossSectionLabel = unknownSizeLabel
gaugeQuery = "battery cable"
}
}
@@ -1039,9 +1074,17 @@ private struct SystemBillOfMaterialsView: View {
let powerDetail = String(format: "%.0f W @ %.1f V", calculatedPower, load.voltage)
let fuseRating = recommendedFuse(for: load)
let fuseDetail = "Inline holder and \(fuseRating)A fuse"
let fuseDetailFormat = NSLocalizedString(
"bom.fuse.detail",
comment: "Description for the fuse item in the BOM list"
)
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
let cableShoesDetail = "Ring or spade terminals sized for \(crossSectionLabel.lowercased()) wiring"
let cableShoesDetailFormat = NSLocalizedString(
"bom.terminals.detail",
comment: "Description for the cable terminals item in the BOM list"
)
let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased())
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
let deviceQuery = load.name.isEmpty
@@ -1057,7 +1100,7 @@ private struct SystemBillOfMaterialsView: View {
Item(
id: Self.storageKey(for: load, itemID: "component"),
logicalID: "component",
title: load.name.isEmpty ? "Component" : load.name,
title: load.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : load.name,
detail: powerDetail,
iconSystemName: "bolt.fill",
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(deviceQuery),
@@ -1066,7 +1109,7 @@ private struct SystemBillOfMaterialsView: View {
Item(
id: Self.storageKey(for: load, itemID: "cable-red"),
logicalID: "cable-red",
title: "Power Cable (Red)",
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
detail: cableDetail,
iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(redCableQuery),
@@ -1075,7 +1118,7 @@ private struct SystemBillOfMaterialsView: View {
Item(
id: Self.storageKey(for: load, itemID: "cable-black"),
logicalID: "cable-black",
title: "Power Cable (Black)",
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
detail: cableDetail,
iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(blackCableQuery),
@@ -1084,7 +1127,7 @@ private struct SystemBillOfMaterialsView: View {
Item(
id: Self.storageKey(for: load, itemID: "fuse"),
logicalID: "fuse",
title: "Fuse & Holder",
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
detail: fuseDetail,
iconSystemName: "bolt.shield",
destination: .amazonSearch(fuseQuery),
@@ -1093,7 +1136,7 @@ private struct SystemBillOfMaterialsView: View {
Item(
id: Self.storageKey(for: load, itemID: "terminals"),
logicalID: "terminals",
title: "Cable Shoes / Terminals",
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
detail: cableShoesDetail,
iconSystemName: "wrench.and.screwdriver",
destination: .amazonSearch(terminalQuery),
@@ -1171,7 +1214,10 @@ private struct SystemBillOfMaterialsView: View {
}
private var footerMessage: String {
return "Purchases through affiliate links may support VoltPlan."
NSLocalizedString(
"affiliate.disclaimer",
comment: "Footer note reminding users that affiliate purchases may support the app"
)
}
private var dateFormatter: DateFormatter {

View File

@@ -23,9 +23,9 @@ struct LoadEditorView: View {
var body: some View {
ItemEditorView(
title: "Edit Load",
nameFieldLabel: "Load name",
previewSubtitle: "Preview",
title: String(localized: "editor.load.title", comment: "Title for the load editor"),
nameFieldLabel: String(localized: "editor.load.name_field", comment: "Label for the load name text field"),
previewSubtitle: String(localized: "editor.load.preview", comment: "Placeholder subtitle in the load editor preview"),
icons: loadIcons,
name: $loadName,
iconName: $iconName,

View File

@@ -0,0 +1,32 @@
import SwiftUI
struct OnboardingCarouselView: View {
let images: [String]
let step: Int
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let height = geometry.size.height
ZStack {
if images.isEmpty {
Image(systemName: "photo")
.font(.largeTitle)
.foregroundStyle(Color.secondary)
} else {
HStack(spacing: 0) {
ForEach(Array(images.enumerated()), id: \.offset) { _, name in
Image(name)
.resizable()
.scaledToFit()
.frame(width: width, height: height)
}
}
.offset(x: -CGFloat(step) * width)
}
}
.clipped()
}
}
}

View File

@@ -32,17 +32,21 @@ struct SystemEditorView: View {
}
var body: some View {
let editorTitle = String(localized: "editor.system.title", comment: "Title for the system editor")
let namePlaceholder = String(localized: "editor.system.name_field", comment: "Label for the system name text field")
let locationPlaceholder = String(localized: "editor.system.location.optional", comment: "Placeholder text shown when no location is specified")
ItemEditorView(
title: "Edit System",
nameFieldLabel: "System name",
previewSubtitle: tempLocation.isEmpty ? "Location (optional)" : tempLocation,
title: editorTitle,
nameFieldLabel: namePlaceholder,
previewSubtitle: tempLocation.isEmpty ? locationPlaceholder : tempLocation,
icons: systemIcons,
name: $systemName,
iconName: $iconName,
colorName: $colorName,
additionalFields: {
AnyView(
TextField("Location (optional)", text: $tempLocation)
TextField(locationPlaceholder, text: $tempLocation)
.autocapitalization(.words)
.onChange(of: tempLocation) { _, newValue in
location = newValue

View File

@@ -1,7 +1,7 @@
import SwiftUI
struct SystemsOnboardingView: View {
@State private var systemName: String = "My System"
@State private var systemName: String = String(localized: "default.system.name", comment: "Default placeholder name for a system")
@State private var carouselStep = 0
@FocusState private var isFieldFocused: Bool
let onCreate: (String) -> Void
@@ -94,7 +94,7 @@ struct SystemsOnboardingView: View {
}
private func resetState() {
systemName = "My System"
systemName = String(localized: "default.system.name", comment: "Default placeholder name for a system")
carouselStep = 0
}

View File

@@ -14,9 +14,9 @@ enum UnitSystem: String, CaseIterable {
var displayName: String {
switch self {
case .metric:
return "Metric (mm², m)"
return String(localized: "units.metric.display", comment: "Display name for the metric unit system")
case .imperial:
return "Imperial (AWG, ft)"
return String(localized: "units.imperial.display", comment: "Display name for the imperial unit system")
}
}
@@ -51,4 +51,4 @@ class UnitSystemSettings: ObservableObject {
let savedSystem = UserDefaults.standard.string(forKey: "unitSystem") ?? UnitSystem.metric.rawValue
self.unitSystem = UnitSystem(rawValue: savedSystem) ?? .metric
}
}
}

View File

@@ -0,0 +1,104 @@
// Keys
"affiliate.button.review_parts" = "Teile prüfen";
"affiliate.description.with_link" = "Tippen oben zeigt eine vollständige Stückliste, bevor der Affiliate-Link geöffnet wird. Käufe können VoltPlan unterstützen.";
"affiliate.description.without_link" = "Tippen oben zeigt eine vollständige Stückliste mit Einkaufssuchen, die dir bei der Beschaffung helfen.";
"affiliate.disclaimer" = "Käufe über Affiliate-Links können VoltPlan unterstützen.";
"bom.accessibility.mark.complete" = "Markiere %@ als erledigt";
"bom.accessibility.mark.incomplete" = "Markiere %@ als unerledigt";
"bom.fuse.detail" = "Inline-Halter und %dA-Sicherung";
"bom.item.cable.black" = "Stromkabel (schwarz)";
"bom.item.cable.red" = "Stromkabel (rot)";
"bom.item.fuse" = "Sicherung & Halter";
"bom.item.terminals" = "Kabelschuhe / Klemmen";
"bom.navigation.title" = "Stückliste";
"bom.navigation.title.system" = "Stückliste %@";
"bom.size.unknown" = "Größe offen";
"bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
"component.fallback.name" = "Komponente";
"default.load.library" = "Bibliothekslast";
"default.load.name" = "Mein Verbraucher";
"default.load.unnamed" = "Unbenannter Verbraucher";
"default.load.new" = "Neuer Verbraucher";
"default.system.name" = "Mein System";
"default.system.new" = "Neues System";
"editor.load.name_field" = "Name des Verbrauchers";
"editor.load.preview" = "Vorschau";
"editor.load.title" = "Verbraucher bearbeiten";
"editor.system.location.optional" = "Standort (optional)";
"editor.system.name_field" = "Name des Systems";
"editor.system.title" = "System bearbeiten";
"slider.button.ampere" = "Ampere";
"slider.button.watt" = "Watt";
"slider.current.title" = "Strom";
"slider.length.title" = "Kabellänge (%@)";
"slider.power.title" = "Leistung";
"slider.voltage.title" = "Spannung";
"system.list.no.components" = "Noch keine Komponenten";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Metrisch (mm², m)";
// Direct strings
"Systems" = "Systeme";
"System" = "System";
"System View" = "Systemansicht";
"System Name" = "Systemname";
"Create System" = "System erstellen";
"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." = "Gib deinem Aufbau einen Namen, damit **Cable by VoltPlan** Verbraucher, Leitungen und Empfehlungen an einem Ort organisiert.";
"Add your first component" = "Füge deine erste Komponente hinzu";
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Erwecke dein System mit Komponenten zum Leben und überlasse **Cable by VoltPlan** die Kabel- und Sicherungsempfehlungen.";
"Create Component" = "Komponente erstellen";
"Browse Library" = "Bibliothek durchsuchen";
"Browse" = "Durchsuchen";
"Browse electrical components from VoltPlan" = "Elektrische Komponenten von VoltPlan durchstöbern";
"Component Library" = "Komponentenbibliothek";
"Details coming soon" = "Details folgen in Kürze";
"Components" = "Komponenten";
"FUSE" = "SICHERUNG";
"WIRE" = "KABEL";
"Current" = "Strom";
"Power" = "Leistung";
"Voltage" = "Spannung";
"Length" = "Länge";
"Length:" = "Länge:";
"Wire Cross-Section:" = "Kabelquerschnitt:";
"Current Units" = "Aktuelle Einheiten";
"Unit System" = "Einheitensystem";
"Units" = "Einheiten";
"Settings" = "Einstellungen";
"Close" = "Schließen";
"Cancel" = "Abbrechen";
"Save" = "Speichern";
"Retry" = "Erneut versuchen";
"Loading components" = "Komponenten werden geladen";
"Unable to load components" = "Komponenten konnten nicht geladen werden";
"No components available" = "Keine Komponenten verfügbar";
"No matches" = "Keine Treffer";
"Check back soon for new loads from VoltPlan." = "Schau bald wieder vorbei, um neue Verbraucher von VoltPlan zu finden.";
"Try searching for a different name." = "Versuche, nach einem anderen Namen zu suchen.";
"Search components" = "Komponenten suchen";
"No loads saved in this system yet." = "In diesem System sind noch keine Verbraucher gespeichert.";
"Coming soon - manage your electrical systems and panels here." = "Demnächst verfügbar verwalte hier deine elektrischen Systeme und Verteilungen.";
"Load Library" = "Verbraucher-bibliothek";
"Safety Disclaimer" = "Sicherheitshinweis";
"This application provides electrical calculations for educational and estimation purposes only." = "Diese Anwendung stellt elektrische Berechnungen nur zu Schulungs- und Schätzungszwecken bereit.";
"Important:" = "Wichtig:";
"• Always consult qualified electricians for actual installations" = "• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu";
"• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen";
"• Electrical work should only be performed by licensed professionals" = "• Elektroarbeiten sollten nur von zugelassenen Fachkräften ausgeführt werden";
"• These calculations may not account for all environmental factors" = "• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren";
"• The app developers assume no liability for electrical installations" = "• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen";
"Enter length in %@" = "Gib die Länge in %@ ein";
"Enter voltage in volts (V)" = "Gib die Spannung in Volt (V) ein";
"Enter current in amperes (A)" = "Gib den Strom in Ampere (A) ein";
"Enter power in watts (W)" = "Gib die Leistung in Watt (W) ein";
"Edit Length" = "Länge bearbeiten";
"Edit Voltage" = "Spannung bearbeiten";
"Edit Current" = "Strom bearbeiten";
"Edit Power" = "Leistung bearbeiten";
"Preview" = "Vorschau";
"Details" = "Details";
"Icon" = "Symbol";
"Color" = "Farbe";
"VoltPlan Library" = "VoltPlan-Bibliothek";
"New Load" = "Neuer Verbraucher";

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>system.list.component.summary</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@component_count@ • %@</string>
<key>component_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>%d Komponente</string>
<key>other</key>
<string>%d Komponenten</string>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,104 @@
// Keys
"affiliate.button.review_parts" = "Revisar componentes";
"affiliate.description.with_link" = "Al tocar arriba se muestra una lista completa de materiales antes de abrir el enlace de afiliado. Las compras pueden ayudar a VoltPlan.";
"affiliate.description.without_link" = "Al tocar arriba se muestra una lista completa de materiales con búsquedas de compra para ayudarte a conseguir piezas.";
"affiliate.disclaimer" = "Las compras a través de enlaces de afiliados pueden ayudar a VoltPlan.";
"bom.accessibility.mark.complete" = "Marcar %@ como completado";
"bom.accessibility.mark.incomplete" = "Marcar %@ como pendiente";
"bom.fuse.detail" = "Portafusibles en línea y fusible de %d A";
"bom.item.cable.black" = "Cable de alimentación (negro)";
"bom.item.cable.red" = "Cable de alimentación (rojo)";
"bom.item.fuse" = "Fusible y portafusibles";
"bom.item.terminals" = "Terminales / Zapatas";
"bom.navigation.title" = "Lista de materiales";
"bom.navigation.title.system" = "Lista de materiales %@";
"bom.size.unknown" = "Tamaño por definir";
"bom.terminals.detail" = "Terminales de anillo o horquilla para cables de %@";
"component.fallback.name" = "Componente";
"default.load.library" = "Carga de biblioteca";
"default.load.name" = "Mi carga";
"default.load.unnamed" = "Carga sin nombre";
"default.load.new" = "Carga nueva";
"default.system.name" = "Mi sistema";
"default.system.new" = "Sistema nuevo";
"editor.load.name_field" = "Nombre de la carga";
"editor.load.preview" = "Vista previa";
"editor.load.title" = "Editar carga";
"editor.system.location.optional" = "Ubicación (opcional)";
"editor.system.name_field" = "Nombre del sistema";
"editor.system.title" = "Editar sistema";
"slider.button.ampere" = "Amperios";
"slider.button.watt" = "Vatios";
"slider.current.title" = "Corriente";
"slider.length.title" = "Longitud del cable (%@)";
"slider.power.title" = "Potencia";
"slider.voltage.title" = "Voltaje";
"system.list.no.components" = "Aún no hay componentes";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Métrico (mm², m)";
// Direct strings
"Systems" = "Sistemas";
"System" = "Sistema";
"System View" = "Vista del sistema";
"System Name" = "Nombre del sistema";
"Create System" = "Crear sistema";
"Create your first system" = "Crea tu primer sistema";
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ponle un nombre a tu instalación para que **Cable by VoltPlan** organice cargas, cableado y recomendaciones en un solo lugar.";
"Add your first component" = "Añade tu primer componente";
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Da vida a tu sistema con componentes y deja que **Cable by VoltPlan** se encargue de recomendar cables y fusibles.";
"Create Component" = "Crear componente";
"Browse Library" = "Explorar biblioteca";
"Browse" = "Explorar";
"Browse electrical components from VoltPlan" = "Explora los componentes eléctricos de VoltPlan";
"Component Library" = "Biblioteca de componentes";
"Details coming soon" = "Detalles próximamente";
"Components" = "Componentes";
"FUSE" = "FUSIBLE";
"WIRE" = "CABLE";
"Current" = "Corriente";
"Power" = "Potencia";
"Voltage" = "Voltaje";
"Length" = "Longitud";
"Length:" = "Longitud:";
"Wire Cross-Section:" = "Sección del cable:";
"Current Units" = "Unidades actuales";
"Unit System" = "Sistema de unidades";
"Units" = "Unidades";
"Settings" = "Ajustes";
"Close" = "Cerrar";
"Cancel" = "Cancelar";
"Save" = "Guardar";
"Retry" = "Reintentar";
"Loading components" = "Cargando componentes";
"Unable to load components" = "No se pudieron cargar los componentes";
"No components available" = "No hay componentes disponibles";
"No matches" = "Sin coincidencias";
"Check back soon for new loads from VoltPlan." = "Vuelve pronto para encontrar nuevas cargas de VoltPlan.";
"Try searching for a different name." = "Prueba a buscar otro nombre.";
"Search components" = "Buscar componentes";
"No loads saved in this system yet." = "Todavía no hay cargas guardadas en este sistema.";
"Coming soon - manage your electrical systems and panels here." = "Próximamente: gestiona aquí tus sistemas y paneles eléctricos.";
"Load Library" = "Biblioteca de cargas";
"Safety Disclaimer" = "Aviso de seguridad";
"This application provides electrical calculations for educational and estimation purposes only." = "Esta aplicación proporciona cálculos eléctricos únicamente con fines educativos y de estimación.";
"Important:" = "Importante:";
"• Always consult qualified electricians for actual installations" = "• Consulta siempre a electricistas cualificados para las instalaciones reales";
"• Follow all local electrical codes and regulations" = "• Cumple todas las normativas y códigos eléctricos locales";
"• Electrical work should only be performed by licensed professionals" = "• Los trabajos eléctricos solo deben realizarlos profesionales autorizados";
"• These calculations may not account for all environmental factors" = "• Estos cálculos pueden no tener en cuenta todos los factores ambientales";
"• The app developers assume no liability for electrical installations" = "• Los desarrolladores de la app no asumen responsabilidad por las instalaciones eléctricas";
"Enter length in %@" = "Introduce la longitud en %@";
"Enter voltage in volts (V)" = "Introduce el voltaje en voltios (V)";
"Enter current in amperes (A)" = "Introduce la corriente en amperios (A)";
"Enter power in watts (W)" = "Introduce la potencia en vatios (W)";
"Edit Length" = "Editar longitud";
"Edit Voltage" = "Editar voltaje";
"Edit Current" = "Editar corriente";
"Edit Power" = "Editar potencia";
"Preview" = "Vista previa";
"Details" = "Detalles";
"Icon" = "Icono";
"Color" = "Color";
"VoltPlan Library" = "Biblioteca de VoltPlan";
"New Load" = "Carga nueva";

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>system.list.component.summary</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@component_count@ • %@</string>
<key>component_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>%d componente</string>
<key>other</key>
<string>%d componentes</string>
</dict>
</dict>
</dict>
</plist>