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