diff --git a/Cable.xcodeproj/project.pbxproj b/Cable.xcodeproj/project.pbxproj
index 59e27dd..1576ad6 100644
--- a/Cable.xcodeproj/project.pbxproj
+++ b/Cable.xcodeproj/project.pbxproj
@@ -205,6 +205,8 @@
knownRegions = (
en,
Base,
+ de,
+ es,
);
mainGroup = 3E5C0BC32E72C0FD00247EC8;
minimizedProjectReferenceProxies = 1;
diff --git a/Cable/AppIcon.icon/Assets/box-2.png b/Cable/AppIcon.icon/Assets/box-2.png
new file mode 100644
index 0000000..ce339e7
Binary files /dev/null and b/Cable/AppIcon.icon/Assets/box-2.png differ
diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings
new file mode 100644
index 0000000..85a205d
--- /dev/null
+++ b/Cable/Base.lproj/Localizable.strings
@@ -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)";
diff --git a/Cable/Base.lproj/Localizable.stringsdict b/Cable/Base.lproj/Localizable.stringsdict
new file mode 100644
index 0000000..8b1c7ce
--- /dev/null
+++ b/Cable/Base.lproj/Localizable.stringsdict
@@ -0,0 +1,22 @@
+
+
+
+
+ system.list.component.summary
+
+ NSStringLocalizedFormatKey
+ %#@component_count@ • %@
+ component_count
+
+ NSStringFormatSpecTypeKey
+ NSStringPluralRuleType
+ NSStringFormatValueTypeKey
+ d
+ one
+ %d component
+ other
+ %d components
+
+
+
+
diff --git a/Cable/CableCalculator.swift b/Cable/CableCalculator.swift
index df99621..6c8e270 100644
--- a/Cable/CableCalculator.swift
+++ b/Cable/CableCalculator.swift
@@ -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
diff --git a/Cable/CalculatorView.swift b/Cable/CalculatorView.swift
index affc8ec..6e0e0fa 100644
--- a/Cable/CalculatorView.swift
+++ b/Cable/CalculatorView.swift
@@ -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) {
diff --git a/Cable/ComponentsOnboardingView.swift b/Cable/ComponentsOnboardingView.swift
new file mode 100644
index 0000000..a0d9ce4
--- /dev/null
+++ b/Cable/ComponentsOnboardingView.swift
@@ -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: {})
+}
diff --git a/Cable/ContentView.swift b/Cable/ContentView.swift
index 5567cd6..3f9df20 100644
--- a/Cable/ContentView.swift
+++ b/Cable/ContentView.swift
@@ -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 {
diff --git a/Cable/LoadEditorView.swift b/Cable/LoadEditorView.swift
index c481498..7e2310c 100644
--- a/Cable/LoadEditorView.swift
+++ b/Cable/LoadEditorView.swift
@@ -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,
diff --git a/Cable/OnboardingCarouselView.swift b/Cable/OnboardingCarouselView.swift
new file mode 100644
index 0000000..478ae40
--- /dev/null
+++ b/Cable/OnboardingCarouselView.swift
@@ -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()
+ }
+ }
+}
diff --git a/Cable/SystemEditorView.swift b/Cable/SystemEditorView.swift
index 9de0bb5..98f7924 100644
--- a/Cable/SystemEditorView.swift
+++ b/Cable/SystemEditorView.swift
@@ -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
diff --git a/Cable/SystemsOnboardingView.swift b/Cable/SystemsOnboardingView.swift
index 904bcbc..7146fe8 100644
--- a/Cable/SystemsOnboardingView.swift
+++ b/Cable/SystemsOnboardingView.swift
@@ -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
}
diff --git a/Cable/UnitSystem.swift b/Cable/UnitSystem.swift
index 1336221..beec220 100644
--- a/Cable/UnitSystem.swift
+++ b/Cable/UnitSystem.swift
@@ -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
}
-}
\ No newline at end of file
+}
diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings
new file mode 100644
index 0000000..28ff559
--- /dev/null
+++ b/Cable/de.lproj/Localizable.strings
@@ -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";
diff --git a/Cable/de.lproj/Localizable.stringsdict b/Cable/de.lproj/Localizable.stringsdict
new file mode 100644
index 0000000..eea5ef1
--- /dev/null
+++ b/Cable/de.lproj/Localizable.stringsdict
@@ -0,0 +1,22 @@
+
+
+
+
+ system.list.component.summary
+
+ NSStringLocalizedFormatKey
+ %#@component_count@ • %@
+ component_count
+
+ NSStringFormatSpecTypeKey
+ NSStringPluralRuleType
+ NSStringFormatValueTypeKey
+ d
+ one
+ %d Komponente
+ other
+ %d Komponenten
+
+
+
+
diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings
new file mode 100644
index 0000000..a2e18e4
--- /dev/null
+++ b/Cable/es.lproj/Localizable.strings
@@ -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";
diff --git a/Cable/es.lproj/Localizable.stringsdict b/Cable/es.lproj/Localizable.stringsdict
new file mode 100644
index 0000000..0ec9174
--- /dev/null
+++ b/Cable/es.lproj/Localizable.stringsdict
@@ -0,0 +1,22 @@
+
+
+
+
+ system.list.component.summary
+
+ NSStringLocalizedFormatKey
+ %#@component_count@ • %@
+ component_count
+
+ NSStringFormatSpecTypeKey
+ NSStringPluralRuleType
+ NSStringFormatValueTypeKey
+ d
+ one
+ %d componente
+ other
+ %d componentes
+
+
+
+