diff --git a/CLAUDE.md b/CLAUDE.md index 93b9b8a..e0cbd0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,21 +4,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build & Test Commands -```bash -# Build -xcodebuild -scheme Cable -destination 'platform=iOS Simulator,name=iPhone 15' build +Use the **Xcode MCP server** tools instead of `xcodebuild` CLI: -# Run all tests (unit + UI) -xcodebuild -scheme Cable -destination 'platform=iOS Simulator,name=iPhone 15' test +- **List windows first**: Always run `mcp__xcode__XcodeListWindows` to find the correct `tabIdentifier` — it changes depending on which Xcode windows are open. +- **Build**: `mcp__xcode__BuildProject` +- **Run all tests**: `mcp__xcode__RunAllTests` +- **Check errors**: `mcp__xcode__GetBuildLog` (severity: `error`) or `mcp__xcode__XcodeListNavigatorIssues` -# Run only unit tests -xcodebuild -scheme Cable -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:CableTests test - -# Open in Xcode (preferred for UI iteration with previews) -open Cable.xcodeproj -``` - -Requires Xcode 15+ and iOS 17 simulator. No external dependencies beyond the Xcode toolchain. +No external dependencies beyond the Xcode toolchain. ## Architecture @@ -56,7 +49,17 @@ Each feature has its own directory under `Cable/`: `Loads/`, `Systems/`, `Batter ## Localization -5 languages: English (base), German, Spanish, French, Dutch. UI strings use `String(localized:)`. Translation files are in `*.lproj/Localizable.strings` and `Localizable.stringsdict`. +5 languages: English (base), German, Spanish, French, Dutch. Translation files are in `*.lproj/Localizable.strings` and `Localizable.stringsdict`. + +- Use `String(localized:defaultValue:)` — **not** `NSLocalizedString`. The `defaultValue` serves as English fallback and avoids showing raw keys when a Localizable.strings entry is missing. +- When adding new user-facing strings, add translations to **all 5** Localizable.strings files immediately. + +## PDF Export Pattern + +PDF exports use `UIGraphicsPDFRenderer` with A4 portrait format. The pattern is: +- **Exporter struct** (e.g. `SystemOverviewPDFExporter`, `SystemBillOfMaterialsPDFExporter`) with snapshot data types — keeps Core Graphics rendering isolated from SwiftUI/SwiftData. +- **ShareSheet** triggered via `@State` item binding in the parent view. +- **Toolbar button** (not inline content) for the export action. ## StoreKit diff --git a/Cable.xcodeproj/project.pbxproj b/Cable.xcodeproj/project.pbxproj index 2ccfc96..3ff0a08 100644 --- a/Cable.xcodeproj/project.pbxproj +++ b/Cable.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + 3EAA00032F0000000000AA03 /* Aptabase in Frameworks */ = {isa = PBXBuildFile; productRef = 3EAA00022F0000000000AA02 /* Aptabase */; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 3E37F65B2E93FB6F00836187 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -30,10 +34,6 @@ }; /* End PBXContainerItemProxy section */ -/* Begin PBXBuildFile section */ - 3EAA00032F0000000000AA03 /* Aptabase in Frameworks */ = {isa = PBXBuildFile; productRef = 3EAA00022F0000000000AA02 /* Aptabase */; }; -/* End PBXBuildFile section */ - /* Begin PBXFileReference section */ 3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableUITestsScreenshot.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cable.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -418,7 +418,7 @@ CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 41; + CURRENT_PROJECT_VERSION = 84; DEVELOPMENT_TEAM = RE4FXQ754N; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; @@ -436,7 +436,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.1; + MARKETING_VERSION = 1.7.0; PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -454,7 +454,7 @@ CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 41; + CURRENT_PROJECT_VERSION = 84; DEVELOPMENT_TEAM = RE4FXQ754N; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; @@ -472,7 +472,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.5.1; + MARKETING_VERSION = 1.7.0; PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Cable/AppDelegate.swift b/Cable/AppDelegate.swift index 8f14f6f..4bcb246 100644 --- a/Cable/AppDelegate.swift +++ b/Cable/AppDelegate.swift @@ -16,6 +16,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { appKey: "A-SH-4260269603", with: InitOptions(host: "https://apta.yuzuhub.com") ) + let isFirstLaunch = !UserDefaults.standard.bool(forKey: "hasLaunchedBefore") + if isFirstLaunch { + UserDefaults.standard.set(true, forKey: "hasLaunchedBefore") + AnalyticsTracker.log("First Launch") + } AnalyticsTracker.log("App Launched") return true } diff --git a/Cable/Assets.xcassets/AppIcon.appiconset/AppIcon-iOS-Default-1024x1024@1x.png b/Cable/Assets.xcassets/AppIcon.appiconset/AppIcon-iOS-Default-1024x1024@1x.png new file mode 100644 index 0000000..9bfab4e Binary files /dev/null and b/Cable/Assets.xcassets/AppIcon.appiconset/AppIcon-iOS-Default-1024x1024@1x.png differ diff --git a/Cable/Assets.xcassets/AppIcon.appiconset/Cable-iOS-Default-1024x1024@1x.png b/Cable/Assets.xcassets/AppIcon.appiconset/Cable-iOS-Default-1024x1024@1x.png deleted file mode 100644 index 4cb6954..0000000 Binary files a/Cable/Assets.xcassets/AppIcon.appiconset/Cable-iOS-Default-1024x1024@1x.png and /dev/null differ diff --git a/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json b/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json index fc4e57c..b0ec05d 100644 --- a/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Cable-iOS-Default-1024x1024@1x.png", + "filename" : "AppIcon-iOS-Default-1024x1024@1x.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Cable/Assets.xcassets/AppIconWatermark.imageset/AppIcon-iOS-Default-1024x1024@1x.png b/Cable/Assets.xcassets/AppIconWatermark.imageset/AppIcon-iOS-Default-1024x1024@1x.png new file mode 100644 index 0000000..9bfab4e Binary files /dev/null and b/Cable/Assets.xcassets/AppIconWatermark.imageset/AppIcon-iOS-Default-1024x1024@1x.png differ diff --git a/Cable/Assets.xcassets/AppIconWatermark.imageset/Contents.json b/Cable/Assets.xcassets/AppIconWatermark.imageset/Contents.json new file mode 100644 index 0000000..23e9651 --- /dev/null +++ b/Cable/Assets.xcassets/AppIconWatermark.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "AppIcon-iOS-Default-1024x1024@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index ec18114..82b817a 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -330,3 +330,55 @@ "cable.pro.trial.duration.month.plural" = "%@-month"; "cable.pro.trial.duration.year.singular" = "%@-year"; "cable.pro.trial.duration.year.plural" = "%@-year"; + +// MARK: - PDF Overview Export +"overview.pdf.loads.section" = "Loads"; +"overview.pdf.batteries.section" = "Batteries"; +"overview.pdf.chargers.section" = "Chargers"; +"overview.pdf.diagram.chargers" = "CHARGERS"; +"overview.pdf.diagram.batteries" = "BATTERY BANK"; +"overview.pdf.diagram.loads" = "LOADS"; +"overview.pdf.diagram.no.batteries" = "No batteries"; +"overview.pdf.diagram.usable" = "Usable"; +"overview.pdf.summary.title" = "System Summary"; +"overview.pdf.summary.runtime" = "Estimated Runtime"; +"overview.pdf.summary.chargetime" = "Charge Time"; +"overview.pdf.summary.totalpower" = "Total Power"; +"overview.pdf.summary.totalcurrent" = "Total Current"; +"overview.pdf.summary.batterycapacity" = "Battery Capacity"; +"overview.pdf.summary.chargerpower" = "Charger Power"; +"overview.pdf.load.voltage" = "Voltage"; +"overview.pdf.load.current" = "Current"; +"overview.pdf.load.power" = "Power"; +"overview.pdf.load.length" = "Cable Length"; +"overview.pdf.load.cable" = "Cable Size"; +"overview.pdf.load.vdrop" = "Voltage Drop"; +"overview.pdf.load.ploss" = "Power Loss"; +"overview.pdf.load.fuse" = "Fuse"; +"overview.pdf.battery.chemistry" = "Chemistry"; +"overview.pdf.battery.voltage" = "Voltage"; +"overview.pdf.battery.capacity" = "Capacity"; +"overview.pdf.battery.usable" = "Usable Capacity"; +"overview.pdf.battery.energy" = "Energy"; +"overview.pdf.battery.usableenergy" = "Usable Energy"; +"overview.pdf.charger.input" = "Input Voltage"; +"overview.pdf.charger.output" = "Output Voltage"; +"overview.pdf.charger.current" = "Max Current"; +"overview.pdf.charger.power" = "Power"; +"overview.pdf.continued" = "Continued"; +"overview.pdf.page.number" = "Page %d"; +"overview.share.button.title" = "Share System Overview"; +"overview.share.button.subtitle" = "Export as PDF"; +"overview.share.error.title" = "Export Failed"; + +// MARK: - Charger Power Source Type +"charger.editor.source.type" = "Power Source"; +"charger.source.shore" = "Shore Power"; +"charger.source.solar" = "Solar"; +"charger.source.wind" = "Wind"; +"charger.source.generator" = "Generator"; + +// MARK: - Share Menu +"overview.share.diagram" = "Wiring Diagram"; +"overview.share.pdf" = "Full Report (PDF)"; +"overview.share.diagram.error" = "Could not generate diagram. Check your internet connection."; diff --git a/Cable/Chargers/ChargerConfiguration.swift b/Cable/Chargers/ChargerConfiguration.swift index 722f534..d0c9855 100644 --- a/Cable/Chargers/ChargerConfiguration.swift +++ b/Cable/Chargers/ChargerConfiguration.swift @@ -11,6 +11,7 @@ struct ChargerConfiguration: Identifiable, Hashable { var iconName: String var colorName: String var system: ElectricalSystem + var powerSourceType: SavedCharger.PowerSourceType init( id: UUID = UUID(), @@ -21,7 +22,8 @@ struct ChargerConfiguration: Identifiable, Hashable { maxPowerWatts: Double = 0.0, iconName: String = "bolt.fill", colorName: String = "orange", - system: ElectricalSystem + system: ElectricalSystem, + powerSourceType: SavedCharger.PowerSourceType = .shore ) { self.id = id self.name = name @@ -32,6 +34,7 @@ struct ChargerConfiguration: Identifiable, Hashable { self.iconName = iconName self.colorName = colorName self.system = system + self.powerSourceType = powerSourceType } init(savedCharger: SavedCharger, system: ElectricalSystem) { @@ -44,6 +47,7 @@ struct ChargerConfiguration: Identifiable, Hashable { self.iconName = savedCharger.iconName self.colorName = savedCharger.colorName self.system = system + self.powerSourceType = savedCharger.sourceType } var effectivePowerWatts: Double { @@ -62,6 +66,7 @@ struct ChargerConfiguration: Identifiable, Hashable { savedCharger.iconName = iconName savedCharger.colorName = colorName savedCharger.system = system + savedCharger.powerSourceType = powerSourceType.rawValue savedCharger.timestamp = Date() } } diff --git a/Cable/Chargers/ChargerEditorView.swift b/Cable/Chargers/ChargerEditorView.swift index 1fd4ae3..cbec7c0 100644 --- a/Cable/Chargers/ChargerEditorView.swift +++ b/Cable/Chargers/ChargerEditorView.swift @@ -346,6 +346,7 @@ struct ChargerEditorView: View { VStack(spacing: 0) { headerInfoBar List { + powerSourceSection electricalSection chargingSection } @@ -597,6 +598,21 @@ struct ChargerEditorView: View { .accessibilityLabel(appearanceAccessibilityLabel) } + private var powerSourceSection: some View { + Section { + Picker( + String(localized: "charger.editor.source.type", defaultValue: "Power Source"), + selection: $configuration.powerSourceType + ) { + ForEach(SavedCharger.PowerSourceType.allCases) { type in + Label(type.displayName, systemImage: type.iconName) + .tag(type) + } + } + .pickerStyle(.menu) + } + } + private var electricalSection: some View { Section { SliderSection( diff --git a/Cable/Chargers/SavedCharger.swift b/Cable/Chargers/SavedCharger.swift index 4959149..39afc59 100644 --- a/Cable/Chargers/SavedCharger.swift +++ b/Cable/Chargers/SavedCharger.swift @@ -18,6 +18,39 @@ final class SavedCharger { var affiliateCountryCode: String? var bomCompletedItemIDs: [String] = [] var identifier: String + var powerSourceType: String = "shore" + + enum PowerSourceType: String, CaseIterable, Identifiable { + case shore = "shore" + case solar = "solar" + case wind = "wind" + case generator = "generator" + + var id: Self { self } + + var displayName: String { + switch self { + case .shore: return String(localized: "charger.source.shore", defaultValue: "Shore Power") + case .solar: return String(localized: "charger.source.solar", defaultValue: "Solar") + case .wind: return String(localized: "charger.source.wind", defaultValue: "Wind") + case .generator: return String(localized: "charger.source.generator", defaultValue: "Generator") + } + } + + var iconName: String { + switch self { + case .shore: return "powerplug" + case .solar: return "sun.max.fill" + case .wind: return "wind" + case .generator: return "engine.combustion.fill" + } + } + } + + var sourceType: PowerSourceType { + get { PowerSourceType(rawValue: powerSourceType) ?? .shore } + set { powerSourceType = newValue.rawValue } + } init( id: UUID = UUID(), @@ -34,7 +67,8 @@ final class SavedCharger { affiliateURLString: String? = nil, affiliateCountryCode: String? = nil, bomCompletedItemIDs: [String] = [], - identifier: String = UUID().uuidString + identifier: String = UUID().uuidString, + powerSourceType: String = "shore" ) { self.id = id self.name = name @@ -51,6 +85,7 @@ final class SavedCharger { self.affiliateCountryCode = affiliateCountryCode self.bomCompletedItemIDs = bomCompletedItemIDs self.identifier = identifier + self.powerSourceType = powerSourceType } var effectivePowerWatts: Double { diff --git a/Cable/Loads/LoadsView.swift b/Cable/Loads/LoadsView.swift index b756499..cda9a97 100644 --- a/Cable/Loads/LoadsView.swift +++ b/Cable/Loads/LoadsView.swift @@ -26,7 +26,12 @@ struct LoadsView: View { @State private var chargerDraft: ChargerConfiguration? @State private var activeStatus: LoadConfigurationStatus? @State private var editMode: EditMode = .inactive - + @State private var overviewExportRequested = false + @State private var diagramExportRequested = false + @State private var isExportingOverview = false + @State private var overviewShareItem: OverviewShareItem? + @State private var overviewExportError: OverviewExportError? + let system: ElectricalSystem private let presentSystemEditorOnAppear: Bool private let loadToOpenOnAppear: SavedLoad? @@ -165,7 +170,34 @@ struct LoadsView: View { let showEditBatteries = selectedComponentTab == .batteries && !savedBatteries.isEmpty let showEditChargers = selectedComponentTab == .chargers && !savedChargers.isEmpty - if showPrimary || showEditLoads || showEditBatteries || showEditChargers { + if selectedComponentTab == .overview { + if isExportingOverview { + ProgressView() + .progressViewStyle(.circular) + } else { + Menu { + Button { + diagramExportRequested = true + } label: { + Label( + String(localized: "overview.share.diagram", defaultValue: "Wiring Diagram"), + systemImage: "bolt.horizontal" + ) + } + Button { + overviewExportRequested = true + } label: { + Label( + String(localized: "overview.share.pdf", defaultValue: "Full Report (PDF)"), + systemImage: "doc.richtext" + ) + } + } label: { + Image(systemName: "square.and.arrow.up") + } + .accessibilityIdentifier("system-overview-share-button") + } + } else if showPrimary || showEditLoads || showEditBatteries || showEditChargers { HStack { if showPrimary { Button(action: { @@ -209,6 +241,36 @@ struct LoadsView: View { } ) } + .sheet(item: $overviewShareItem, onDismiss: cleanupOverviewShareItem) { item in + ShareSheet(items: item.shareItems) + } + .alert( + String(localized: "overview.share.error.title", defaultValue: "Export Failed"), + isPresented: Binding( + get: { overviewExportError != nil }, + set: { if !$0 { overviewExportError = nil } } + ) + ) { + Button(String(localized: "generic.ok", defaultValue: "OK")) { + overviewExportError = nil + } + } message: { + if let error = overviewExportError { + Text(error.message) + } + } + .onChange(of: overviewExportRequested) { _, requested in + if requested { + overviewExportRequested = false + exportOverviewPDF() + } + } + .onChange(of: diagramExportRequested) { _, requested in + if requested { + diagramExportRequested = false + exportDiagramImage() + } + } .sheet(isPresented: $showingComponentLibrary) { ComponentLibraryView { item in addComponent(item) @@ -1004,4 +1066,205 @@ struct LoadsView: View { case batteries case chargers } + + // MARK: - PDF Export + + private struct OverviewShareItem: Identifiable { + let id = UUID() + let shareItems: [Any] + let tempURL: URL? + } + + private struct OverviewExportError: Identifiable { + let message: String + var id: String { message } + } + + private func exportOverviewPDF() { + guard !isExportingOverview else { return } + isExportingOverview = true + + let snapshot = buildSnapshot() + + Task { + do { + // Fetch diagram image from VoltPlan API (falls back to Core Graphics if unavailable) + let diagramImage = await SystemOverviewPDFExporter.fetchDiagramImage(snapshot: snapshot) + + let exporter = SystemOverviewPDFExporter() + let url = try exporter.export(snapshot: snapshot, diagramImage: diagramImage) + AnalyticsTracker.log("Overview PDF Shared", properties: [ + "system": snapshot.systemName, + ]) + await MainActor.run { + overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url) + isExportingOverview = false + } + } catch { + await MainActor.run { + overviewExportError = OverviewExportError(message: error.localizedDescription) + isExportingOverview = false + } + } + } + } + + private func exportDiagramImage() { + guard !isExportingOverview else { return } + isExportingOverview = true + + let snapshot = buildSnapshot() + + Task { + let diagramImage = await SystemOverviewPDFExporter.fetchDiagramImage(snapshot: snapshot) + await MainActor.run { + if let image = diagramImage, + let opaqueImage = Self.imageWithWhiteBackground(image), + let pngData = opaqueImage.pngData() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("\(snapshot.systemName)-Diagram.png") + try? pngData.write(to: url, options: .atomic) + AnalyticsTracker.log("Diagram Image Shared", properties: [ + "system": snapshot.systemName, + ]) + overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url) + } else { + overviewExportError = OverviewExportError( + message: String(localized: "overview.share.diagram.error", defaultValue: "Could not generate diagram. Check your internet connection.") + ) + } + isExportingOverview = false + } + } + } + + private static func imageWithWhiteBackground(_ image: UIImage) -> UIImage? { + let size = image.size + UIGraphicsBeginImageContextWithOptions(size, true, image.scale) + defer { UIGraphicsEndImageContext() } + UIColor.white.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + image.draw(at: .zero) + + // Draw app icon as subtle watermark in bottom-right corner + if let appIcon = UIImage(named: "AppIconWatermark") { + let watermarkSize: CGFloat = min(size.width, size.height) * 0.08 + let margin: CGFloat = watermarkSize * 0.4 + let watermarkRect = CGRect( + x: size.width - watermarkSize - margin, + y: size.height - watermarkSize - margin, + width: watermarkSize, + height: watermarkSize + ) + appIcon.draw(in: watermarkRect, blendMode: .normal, alpha: 0.15) + } + + return UIGraphicsGetImageFromCurrentImageContext() + } + + private func buildSnapshot() -> SystemOverviewPDFExporter.SystemSnapshot { + let currentUnitSystem = unitSettings.unitSystem + let loads = savedLoads + let batteries = savedBatteries + let chargers = savedChargers + + let loadSnapshots = loads.map { load in + SystemOverviewPDFExporter.LoadSnapshot( + name: load.name, + voltage: load.voltage, + current: load.current, + power: load.power, + length: load.length, + crossSection: load.crossSection, + dutyCyclePercent: load.dutyCyclePercent, + dailyUsageHours: load.dailyUsageHours, + recommendedCrossSection: ElectricalCalculations.recommendedCrossSection( + length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem + ), + voltageDrop: ElectricalCalculations.voltageDrop( + length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem + ), + voltageDropPercent: ElectricalCalculations.voltageDropPercentage( + length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem + ), + powerLoss: ElectricalCalculations.powerLoss( + length: load.length, current: load.current, voltage: load.voltage, unitSystem: currentUnitSystem + ), + recommendedFuse: ElectricalCalculations.recommendedFuse(forCurrent: load.current), + iconUrl: load.remoteIconURLString + ) + } + + let batterySnapshots = batteries.map { battery in + SystemOverviewPDFExporter.BatterySnapshot( + name: battery.name, + chemistry: battery.chemistry.rawValue, + nominalVoltage: battery.nominalVoltage, + capacityAmpHours: battery.capacityAmpHours, + usableCapacityAmpHours: battery.usableCapacityAmpHours, + energyWattHours: battery.energyWattHours, + usableEnergyWattHours: battery.usableEnergyWattHours, + iconUrl: nil + ) + } + + let chargerSnapshots = chargers.map { charger in + SystemOverviewPDFExporter.ChargerSnapshot( + name: charger.name, + inputVoltage: charger.inputVoltage, + outputVoltage: charger.outputVoltage, + maxCurrentAmps: charger.maxCurrentAmps, + effectivePowerWatts: charger.effectivePowerWatts, + sourceType: charger.sourceType.rawValue, + iconUrl: charger.remoteIconURLString + ) + } + + let totalPower = loads.reduce(0.0) { $0 + $1.power } + let totalCurrent = loads.reduce(0.0) { $0 + $1.current } + let totalCapacity = batteries.reduce(0.0) { $0 + $1.capacityAmpHours } + let totalEnergy = batteries.reduce(0.0) { $0 + $1.energyWattHours } + let totalUsableCapacity = batteries.reduce(0.0) { $0 + $1.usableCapacityAmpHours } + let totalUsableEnergy = batteries.reduce(0.0) { $0 + $1.usableEnergyWattHours } + let totalChargerPower = chargers.reduce(0.0) { $0 + $1.effectivePowerWatts } + let totalChargerCurrent = chargers.reduce(0.0) { $0 + $1.maxCurrentAmps } + + var runtimeFormatted: String? = nil + if totalPower > 0 && totalUsableEnergy > 0 { + let hours = totalUsableEnergy / totalPower + runtimeFormatted = String(format: "%.1f h", hours) + } + + var chargeTimeFormatted: String? = nil + if totalChargerPower > 0 && totalEnergy > 0 { + let hours = totalEnergy / totalChargerPower + chargeTimeFormatted = String(format: "%.1f h", hours) + } + + return SystemOverviewPDFExporter.SystemSnapshot( + systemName: system.name, + unitSystem: currentUnitSystem, + loads: loadSnapshots, + batteries: batterySnapshots, + chargers: chargerSnapshots, + estimatedRuntimeFormatted: runtimeFormatted, + estimatedChargeTimeFormatted: chargeTimeFormatted, + totalPower: totalPower, + totalCurrent: totalCurrent, + totalBatteryCapacity: totalCapacity, + totalBatteryEnergy: totalEnergy, + totalUsableCapacity: totalUsableCapacity, + totalUsableEnergy: totalUsableEnergy, + totalChargerPower: totalChargerPower, + totalChargerCurrent: totalChargerCurrent + ) + } + + private func cleanupOverviewShareItem() { + guard let item = overviewShareItem else { return } + overviewShareItem = nil + if let url = item.tempURL { + try? FileManager.default.removeItem(at: url) + } + } } diff --git a/Cable/Overview/SystemOverviewPDFExporter.swift b/Cable/Overview/SystemOverviewPDFExporter.swift new file mode 100644 index 0000000..f4798f0 --- /dev/null +++ b/Cable/Overview/SystemOverviewPDFExporter.swift @@ -0,0 +1,1060 @@ +import Foundation +import UIKit + +struct SystemOverviewPDFExporter { + private let pageRect = CGRect(x: 0, y: 0, width: 595, height: 842) // A4 portrait + private let margin: CGFloat = 40 + private let primaryTextColor = UIColor.black + private let secondaryTextColor = UIColor.darkGray + private let tertiaryTextColor = UIColor.gray + + // Brand colors for the diagram + private let chargerColor = UIColor(red: 0.20, green: 0.55, blue: 0.87, alpha: 1.0) // Blue + private let batteryColor = UIColor(red: 0.30, green: 0.69, blue: 0.31, alpha: 1.0) // Green + private let loadColor = UIColor(red: 0.93, green: 0.55, blue: 0.14, alpha: 1.0) // Orange + private let accentColor = UIColor(red: 0.45, green: 0.34, blue: 0.86, alpha: 1.0) // Purple + private let cardBackground = UIColor(red: 0.96, green: 0.96, blue: 0.97, alpha: 1.0) + private let tableHeaderBg = UIColor(red: 0.93, green: 0.93, blue: 0.95, alpha: 1.0) + + struct SystemSnapshot { + let systemName: String + let unitSystem: UnitSystem + let loads: [LoadSnapshot] + let batteries: [BatterySnapshot] + let chargers: [ChargerSnapshot] + let estimatedRuntimeFormatted: String? + let estimatedChargeTimeFormatted: String? + let totalPower: Double + let totalCurrent: Double + let totalBatteryCapacity: Double + let totalBatteryEnergy: Double + let totalUsableCapacity: Double + let totalUsableEnergy: Double + let totalChargerPower: Double + let totalChargerCurrent: Double + } + + struct LoadSnapshot { + let name: String + let voltage: Double + let current: Double + let power: Double + let length: Double + let crossSection: Double + let dutyCyclePercent: Double + let dailyUsageHours: Double + let recommendedCrossSection: Double + let voltageDrop: Double + let voltageDropPercent: Double + let powerLoss: Double + let recommendedFuse: Int + let iconUrl: String? + } + + struct BatterySnapshot { + let name: String + let chemistry: String + let nominalVoltage: Double + let capacityAmpHours: Double + let usableCapacityAmpHours: Double + let energyWattHours: Double + let usableEnergyWattHours: Double + let iconUrl: String? + } + + struct ChargerSnapshot { + let name: String + let inputVoltage: Double + let outputVoltage: Double + let maxCurrentAmps: Double + let effectivePowerWatts: Double + let sourceType: String + let iconUrl: String? + } + + // MARK: - API Diagram Fetching + + static func fetchDiagramImage(snapshot: SystemSnapshot) async -> UIImage? { + let payload: [String: Any] = [ + "systemName": snapshot.systemName, + "unitSystem": snapshot.unitSystem == .metric ? "metric" : "imperial", + "loads": snapshot.loads.map { load in + var dict: [String: Any] = [ + "name": load.name, + "power": load.power, + "voltage": load.voltage, + "current": load.current, + ] + if let iconUrl = load.iconUrl { dict["iconUrl"] = iconUrl } + return dict + }, + "batteries": snapshot.batteries.map { battery in + var dict: [String: Any] = [ + "name": battery.name, + "voltage": battery.nominalVoltage, + "capacityAh": battery.capacityAmpHours, + "energyWh": battery.energyWattHours, + ] + if let iconUrl = battery.iconUrl { dict["iconUrl"] = iconUrl } + return dict + }, + "chargers": snapshot.chargers.map { charger in + var dict: [String: Any] = [ + "name": charger.name, + "inputVoltage": charger.inputVoltage, + "outputVoltage": charger.outputVoltage, + "power": charger.effectivePowerWatts, + "sourceType": charger.sourceType, + ] + if let iconUrl = charger.iconUrl { dict["iconUrl"] = iconUrl } + return dict + }, + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: payload), + let url = URL(string: "https://voltplan.app/api/diagram/generate") else { + return nil + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("image/png", forHTTPHeaderField: "Accept") + request.httpBody = jsonData + request.timeoutInterval = 15 + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + return nil + } + return UIImage(data: data) + } catch { + return nil + } + } + + func export(snapshot: SystemSnapshot, diagramImage: UIImage? = nil) throws -> URL { + let format = UIGraphicsPDFRendererFormat() + let renderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format) + var pageIndex = 1 + + let data = renderer.pdfData { context in + // === PAGE 1: Title + Summary === + context.beginPage() + var cursorY = drawTitleHeader(snapshot: snapshot, in: context.cgContext) + cursorY = drawSystemSummary(snapshot: snapshot, at: cursorY, in: context.cgContext) + drawFooter(pageIndex: pageIndex, in: context.cgContext) + + // === PAGE 2: Full-page Diagram === + if diagramImage != nil || !snapshot.loads.isEmpty || !snapshot.batteries.isEmpty || !snapshot.chargers.isEmpty { + pageIndex += 1 + context.beginPage() + drawFullPageDiagram(snapshot: snapshot, diagramImage: diagramImage, in: context.cgContext) + drawFooter(pageIndex: pageIndex, in: context.cgContext) + } + + // === PAGE 2+: Detail Tables === + if !snapshot.loads.isEmpty { + pageIndex += 1 + context.beginPage() + cursorY = drawPageHeader( + title: snapshot.systemName, + subtitle: String(localized: "overview.pdf.loads.section", defaultValue: "Loads"), + in: context.cgContext + ) + cursorY = drawLoadsTable( + loads: snapshot.loads, + unitSystem: snapshot.unitSystem, + at: cursorY, + in: context, + pageIndex: &pageIndex, + systemName: snapshot.systemName + ) + drawFooter(pageIndex: pageIndex, in: context.cgContext) + } + + if !snapshot.batteries.isEmpty { + pageIndex += 1 + context.beginPage() + cursorY = drawPageHeader( + title: snapshot.systemName, + subtitle: String(localized: "overview.pdf.batteries.section", defaultValue: "Batteries"), + in: context.cgContext + ) + cursorY = drawBatteriesTable( + batteries: snapshot.batteries, + at: cursorY, + in: context, + pageIndex: &pageIndex, + systemName: snapshot.systemName + ) + drawFooter(pageIndex: pageIndex, in: context.cgContext) + } + + if !snapshot.chargers.isEmpty { + pageIndex += 1 + context.beginPage() + cursorY = drawPageHeader( + title: snapshot.systemName, + subtitle: String(localized: "overview.pdf.chargers.section", defaultValue: "Chargers"), + in: context.cgContext + ) + cursorY = drawChargersTable( + chargers: snapshot.chargers, + at: cursorY, + in: context, + pageIndex: &pageIndex, + systemName: snapshot.systemName + ) + drawFooter(pageIndex: pageIndex, in: context.cgContext) + } + } + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("System-Overview-\(UUID().uuidString).pdf") + try data.write(to: url, options: .atomic) + return url + } + + // MARK: - Title Header + + private func drawTitleHeader(snapshot: SystemSnapshot, in context: CGContext) -> CGFloat { + let availableWidth = pageRect.width - (margin * 2) + + let titleFont = UIFont.systemFont(ofSize: 28, weight: .bold) + let titleRect = CGRect(x: margin, y: margin, width: availableWidth, height: titleFont.lineHeight + 4) + snapshot.systemName.draw(in: titleRect, withAttributes: [ + .font: titleFont, + .foregroundColor: primaryTextColor, + ]) + + let subtitleFont = UIFont.systemFont(ofSize: 14, weight: .medium) + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + let subtitle = "\(dateFormatter.string(from: Date())) · \(snapshot.unitSystem.displayName)" + let subtitleRect = CGRect( + x: margin, + y: titleRect.maxY + 4, + width: availableWidth, + height: subtitleFont.lineHeight + 2 + ) + subtitle.draw(in: subtitleRect, withAttributes: [ + .font: subtitleFont, + .foregroundColor: secondaryTextColor, + ]) + + // Divider line + let dividerY = subtitleRect.maxY + 12 + context.setStrokeColor(UIColor.separator.cgColor) + context.setLineWidth(0.5) + context.move(to: CGPoint(x: margin, y: dividerY)) + context.addLine(to: CGPoint(x: pageRect.width - margin, y: dividerY)) + context.strokePath() + + return dividerY + 16 + } + + // MARK: - System Diagram + + private func drawFullPageDiagram(snapshot: SystemSnapshot, diagramImage: UIImage?, in context: CGContext) { + let availableWidth = pageRect.width - (margin * 2) + let availableHeight = pageRect.height - (margin * 2) - 30 // leave room for footer + + if let diagramImage, let cgImage = diagramImage.cgImage { + let imageAspect = diagramImage.size.width / diagramImage.size.height + let rectAspect = availableWidth / availableHeight + var drawRect: CGRect + if imageAspect > rectAspect { + let drawHeight = availableWidth / imageAspect + drawRect = CGRect( + x: margin, + y: margin + (availableHeight - drawHeight) / 2, + width: availableWidth, + height: drawHeight + ) + } else { + let drawWidth = availableHeight * imageAspect + drawRect = CGRect( + x: margin + (availableWidth - drawWidth) / 2, + y: margin, + width: drawWidth, + height: availableHeight + ) + } + + context.saveGState() + context.translateBy(x: 0, y: drawRect.minY + drawRect.maxY) + context.scaleBy(x: 1, y: -1) + context.draw(cgImage, in: drawRect) + context.restoreGState() + } else { + // Fallback: draw the block diagram centered on the page + _ = drawSystemDiagram(snapshot: snapshot, at: margin + (availableHeight - 220) / 2, in: context) + } + } + + private func drawSystemDiagram(snapshot: SystemSnapshot, diagramImage: UIImage? = nil, at yPosition: CGFloat, in context: CGContext) -> CGFloat { + let availableWidth = pageRect.width - (margin * 2) + let diagramHeight: CGFloat = 220 + let diagramRect = CGRect(x: margin, y: yPosition, width: availableWidth, height: diagramHeight) + + // If an API-fetched diagram image is available, draw it and return + if let diagramImage, let cgImage = diagramImage.cgImage { + // Draw diagram background card + let bgPath = UIBezierPath(roundedRect: diagramRect, cornerRadius: 12) + context.setFillColor(cardBackground.cgColor) + context.addPath(bgPath.cgPath) + context.fillPath() + + // Scale the image to fit within the diagram rect while maintaining aspect ratio + let imageAspect = diagramImage.size.width / diagramImage.size.height + let rectAspect = diagramRect.width / diagramRect.height + var drawRect: CGRect + if imageAspect > rectAspect { + // Image is wider than rect — fit to width + let drawHeight = diagramRect.width / imageAspect + drawRect = CGRect( + x: diagramRect.minX, + y: diagramRect.minY + (diagramRect.height - drawHeight) / 2, + width: diagramRect.width, + height: drawHeight + ) + } else { + // Image is taller than rect — fit to height + let drawWidth = diagramRect.height * imageAspect + drawRect = CGRect( + x: diagramRect.minX + (diagramRect.width - drawWidth) / 2, + y: diagramRect.minY, + width: drawWidth, + height: diagramRect.height + ) + } + + // CGContext draws images with flipped Y axis, so we need to flip + context.saveGState() + context.translateBy(x: 0, y: drawRect.minY + drawRect.maxY) + context.scaleBy(x: 1, y: -1) + context.draw(cgImage, in: drawRect) + context.restoreGState() + + return diagramRect.maxY + 20 + } + + // Fallback: draw diagram with Core Graphics + + // Draw diagram background card + let bgPath = UIBezierPath(roundedRect: diagramRect, cornerRadius: 12) + context.setFillColor(cardBackground.cgColor) + context.addPath(bgPath.cgPath) + context.fillPath() + + // Layout: 3 columns — Chargers | Battery Bank | Loads + let columnWidth = availableWidth / 3 + let chargerX = margin + let batteryX = margin + columnWidth + let loadX = margin + columnWidth * 2 + let blockInset: CGFloat = 12 + let topPadding: CGFloat = 16 + + // Column headers + let headerFont = UIFont.systemFont(ofSize: 11, weight: .bold) + let headerAttrs: [NSAttributedString.Key: Any] = [.font: headerFont, .foregroundColor: tertiaryTextColor] + + let chargersLabel = String(localized: "overview.pdf.diagram.chargers", defaultValue: "CHARGERS") + let batteriesLabel = String(localized: "overview.pdf.diagram.batteries", defaultValue: "BATTERY BANK") + let loadsLabel = String(localized: "overview.pdf.diagram.loads", defaultValue: "LOADS") + + drawCenteredText(chargersLabel, in: CGRect(x: chargerX, y: yPosition + 10, width: columnWidth, height: 14), attributes: headerAttrs) + drawCenteredText(batteriesLabel, in: CGRect(x: batteryX, y: yPosition + 10, width: columnWidth, height: 14), attributes: headerAttrs) + drawCenteredText(loadsLabel, in: CGRect(x: loadX, y: yPosition + 10, width: columnWidth, height: 14), attributes: headerAttrs) + + let contentTop = yPosition + topPadding + 16 + + // Draw charger blocks (left column) + let maxBlocks = 4 + let chargerBlockHeight: CGFloat = 36 + let chargerSpacing: CGFloat = 6 + let chargersToShow = Array(snapshot.chargers.prefix(maxBlocks)) + var chargerCenters: [CGFloat] = [] + + for (i, charger) in chargersToShow.enumerated() { + let blockY = contentTop + CGFloat(i) * (chargerBlockHeight + chargerSpacing) + let blockRect = CGRect( + x: chargerX + blockInset, + y: blockY, + width: columnWidth - blockInset * 2, + height: chargerBlockHeight + ) + drawComponentBlock( + name: charger.name, + detail: formatPower(charger.effectivePowerWatts), + color: chargerColor, + rect: blockRect, + in: context + ) + chargerCenters.append(blockY + chargerBlockHeight / 2) + } + + if snapshot.chargers.count > maxBlocks { + let moreY = contentTop + CGFloat(maxBlocks) * (chargerBlockHeight + chargerSpacing) + let moreText = "+\(snapshot.chargers.count - maxBlocks)" + let moreFont = UIFont.systemFont(ofSize: 11, weight: .medium) + drawCenteredText( + moreText, + in: CGRect(x: chargerX + blockInset, y: moreY, width: columnWidth - blockInset * 2, height: 16), + attributes: [.font: moreFont, .foregroundColor: tertiaryTextColor] + ) + } + + // Draw battery bank (center — one large block) + let batteryBlockHeight: CGFloat = min( + CGFloat(max(chargersToShow.count, min(snapshot.loads.count, maxBlocks))) * (chargerBlockHeight + chargerSpacing) - chargerSpacing, + diagramHeight - topPadding - 36 + ) + let batteryBlockRect = CGRect( + x: batteryX + blockInset, + y: contentTop, + width: columnWidth - blockInset * 2, + height: max(batteryBlockHeight, 60) + ) + drawBatteryBlock(snapshot: snapshot, rect: batteryBlockRect, in: context) + let batteryCenterY = batteryBlockRect.midY + + // Draw load blocks (right column) + let loadsToShow = Array(snapshot.loads.prefix(maxBlocks)) + var loadCenters: [CGFloat] = [] + + for (i, load) in loadsToShow.enumerated() { + let blockY = contentTop + CGFloat(i) * (chargerBlockHeight + chargerSpacing) + let blockRect = CGRect( + x: loadX + blockInset, + y: blockY, + width: columnWidth - blockInset * 2, + height: chargerBlockHeight + ) + drawComponentBlock( + name: load.name, + detail: formatPower(load.power), + color: loadColor, + rect: blockRect, + in: context + ) + loadCenters.append(blockY + chargerBlockHeight / 2) + } + + if snapshot.loads.count > maxBlocks { + let moreY = contentTop + CGFloat(maxBlocks) * (chargerBlockHeight + chargerSpacing) + let moreText = "+\(snapshot.loads.count - maxBlocks)" + let moreFont = UIFont.systemFont(ofSize: 11, weight: .medium) + drawCenteredText( + moreText, + in: CGRect(x: loadX + blockInset, y: moreY, width: columnWidth - blockInset * 2, height: 16), + attributes: [.font: moreFont, .foregroundColor: tertiaryTextColor] + ) + } + + // Draw connection arrows: chargers → battery + let arrowLineWidth: CGFloat = 1.5 + for centerY in chargerCenters { + let startX = chargerX + columnWidth - blockInset + let endX = batteryBlockRect.minX + drawArrow( + from: CGPoint(x: startX, y: centerY), + to: CGPoint(x: endX, y: batteryCenterY), + color: chargerColor, + lineWidth: arrowLineWidth, + in: context + ) + } + + // Draw connection arrows: battery → loads + for centerY in loadCenters { + let startX = batteryBlockRect.maxX + let endX = loadX + blockInset + drawArrow( + from: CGPoint(x: startX, y: batteryCenterY), + to: CGPoint(x: endX, y: centerY), + color: loadColor, + lineWidth: arrowLineWidth, + in: context + ) + } + + // Handle empty states + if snapshot.chargers.isEmpty { + let emptyFont = UIFont.italicSystemFont(ofSize: 11) + let emptyText = "–" + drawCenteredText( + emptyText, + in: CGRect(x: chargerX + blockInset, y: contentTop + 20, width: columnWidth - blockInset * 2, height: 16), + attributes: [.font: emptyFont, .foregroundColor: tertiaryTextColor] + ) + } + if snapshot.loads.isEmpty { + let emptyFont = UIFont.italicSystemFont(ofSize: 11) + let emptyText = "–" + drawCenteredText( + emptyText, + in: CGRect(x: loadX + blockInset, y: contentTop + 20, width: columnWidth - blockInset * 2, height: 16), + attributes: [.font: emptyFont, .foregroundColor: tertiaryTextColor] + ) + } + + return diagramRect.maxY + 20 + } + + private func drawComponentBlock(name: String, detail: String, color: UIColor, rect: CGRect, in context: CGContext) { + // Rounded rect with colored left border + let path = UIBezierPath(roundedRect: rect, cornerRadius: 6) + context.setFillColor(UIColor.white.cgColor) + context.addPath(path.cgPath) + context.fillPath() + + // Left accent bar + let barRect = CGRect(x: rect.minX, y: rect.minY, width: 4, height: rect.height) + let barPath = UIBezierPath( + roundedRect: barRect, + byRoundingCorners: [.topLeft, .bottomLeft], + cornerRadii: CGSize(width: 6, height: 6) + ) + context.setFillColor(color.cgColor) + context.addPath(barPath.cgPath) + context.fillPath() + + // Subtle border + context.setStrokeColor(UIColor(white: 0.85, alpha: 1).cgColor) + context.setLineWidth(0.5) + context.addPath(path.cgPath) + context.strokePath() + + // Name + let nameFont = UIFont.systemFont(ofSize: 10, weight: .semibold) + let textX = rect.minX + 10 + let textWidth = rect.width - 14 + let truncatedName = truncateText(name, font: nameFont, maxWidth: textWidth) + truncatedName.draw( + in: CGRect(x: textX, y: rect.minY + 4, width: textWidth, height: nameFont.lineHeight), + withAttributes: [.font: nameFont, .foregroundColor: primaryTextColor] + ) + + // Detail + let detailFont = UIFont.systemFont(ofSize: 9, weight: .regular) + detail.draw( + in: CGRect(x: textX, y: rect.minY + 4 + nameFont.lineHeight, width: textWidth, height: detailFont.lineHeight), + withAttributes: [.font: detailFont, .foregroundColor: secondaryTextColor] + ) + } + + private func drawBatteryBlock(snapshot: SystemSnapshot, rect: CGRect, in context: CGContext) { + // Large rounded block + let path = UIBezierPath(roundedRect: rect, cornerRadius: 10) + context.setFillColor(batteryColor.withAlphaComponent(0.08).cgColor) + context.addPath(path.cgPath) + context.fillPath() + + // Border + context.setStrokeColor(batteryColor.withAlphaComponent(0.4).cgColor) + context.setLineWidth(1.5) + context.addPath(path.cgPath) + context.strokePath() + + let textX = rect.minX + 12 + let textWidth = rect.width - 24 + + if snapshot.batteries.isEmpty { + let emptyFont = UIFont.italicSystemFont(ofSize: 11) + let emptyText = String(localized: "overview.pdf.diagram.no.batteries", defaultValue: "No batteries") + drawCenteredText( + emptyText, + in: CGRect(x: textX, y: rect.midY - 8, width: textWidth, height: 16), + attributes: [.font: emptyFont, .foregroundColor: tertiaryTextColor] + ) + return + } + + // Battery icon text + let iconFont = UIFont.systemFont(ofSize: 22, weight: .light) + let iconText = "⚡" + drawCenteredText( + iconText, + in: CGRect(x: textX, y: rect.minY + 10, width: textWidth, height: 26), + attributes: [.font: iconFont, .foregroundColor: batteryColor] + ) + + // Capacity + let capacityFont = UIFont.systemFont(ofSize: 14, weight: .bold) + let capacityText = "\(formatNumber(snapshot.totalBatteryCapacity)) Ah" + drawCenteredText( + capacityText, + in: CGRect(x: textX, y: rect.minY + 38, width: textWidth, height: 18), + attributes: [.font: capacityFont, .foregroundColor: primaryTextColor] + ) + + // Energy + let energyFont = UIFont.systemFont(ofSize: 11, weight: .medium) + let energyText = "\(formatNumber(snapshot.totalBatteryEnergy)) Wh" + drawCenteredText( + energyText, + in: CGRect(x: textX, y: rect.minY + 56, width: textWidth, height: 14), + attributes: [.font: energyFont, .foregroundColor: secondaryTextColor] + ) + + // Usable + let usableFont = UIFont.systemFont(ofSize: 10, weight: .regular) + let usableLabel = String(localized: "overview.pdf.diagram.usable", defaultValue: "Usable") + let usableText = "\(usableLabel): \(formatNumber(snapshot.totalUsableCapacity)) Ah" + drawCenteredText( + usableText, + in: CGRect(x: textX, y: rect.minY + 72, width: textWidth, height: 14), + attributes: [.font: usableFont, .foregroundColor: tertiaryTextColor] + ) + + // Count + if snapshot.batteries.count > 1 { + let countFont = UIFont.systemFont(ofSize: 10, weight: .medium) + let countText = "\(snapshot.batteries.count)×" + drawCenteredText( + countText, + in: CGRect(x: textX, y: rect.maxY - 22, width: textWidth, height: 14), + attributes: [.font: countFont, .foregroundColor: batteryColor] + ) + } + } + + private func drawArrow(from start: CGPoint, to end: CGPoint, color: UIColor, lineWidth: CGFloat, in context: CGContext) { + context.setStrokeColor(color.withAlphaComponent(0.5).cgColor) + context.setLineWidth(lineWidth) + context.setLineCap(.round) + + // Draw a curved line + let controlX = (start.x + end.x) / 2 + context.move(to: start) + context.addCurve( + to: end, + control1: CGPoint(x: controlX, y: start.y), + control2: CGPoint(x: controlX, y: end.y) + ) + context.strokePath() + + // Arrowhead + let arrowSize: CGFloat = 5 + let angle = atan2(end.y - start.y, end.x - start.x) + context.setFillColor(color.withAlphaComponent(0.5).cgColor) + context.move(to: end) + context.addLine(to: CGPoint( + x: end.x - arrowSize * cos(angle - .pi / 6), + y: end.y - arrowSize * sin(angle - .pi / 6) + )) + context.addLine(to: CGPoint( + x: end.x - arrowSize * cos(angle + .pi / 6), + y: end.y - arrowSize * sin(angle + .pi / 6) + )) + context.closePath() + context.fillPath() + } + + // MARK: - System Summary + + private func drawSystemSummary(snapshot: SystemSnapshot, at yPosition: CGFloat, in context: CGContext) -> CGFloat { + let availableWidth = pageRect.width - (margin * 2) + + // Section title + let sectionFont = UIFont.systemFont(ofSize: 16, weight: .bold) + let sectionTitle = String(localized: "overview.pdf.summary.title", defaultValue: "System Summary") + sectionTitle.draw( + in: CGRect(x: margin, y: yPosition, width: availableWidth, height: sectionFont.lineHeight + 4), + withAttributes: [.font: sectionFont, .foregroundColor: primaryTextColor] + ) + var cursorY = yPosition + sectionFont.lineHeight + 12 + + // Summary grid: 2 columns × 3 rows + let colWidth = (availableWidth - 16) / 2 + let rowHeight: CGFloat = 52 + + let runtimeLabel = String(localized: "overview.pdf.summary.runtime", defaultValue: "Estimated Runtime") + let chargeTimeLabel = String(localized: "overview.pdf.summary.chargetime", defaultValue: "Charge Time") + let totalPowerLabel = String(localized: "overview.pdf.summary.totalpower", defaultValue: "Total Power") + let totalCurrentLabel = String(localized: "overview.pdf.summary.totalcurrent", defaultValue: "Total Current") + let batteryCapacityLabel = String(localized: "overview.pdf.summary.batterycapacity", defaultValue: "Battery Capacity") + let chargerPowerLabel = String(localized: "overview.pdf.summary.chargerpower", defaultValue: "Charger Power") + + let summaryItems: [(String, String, UIColor)] = [ + (runtimeLabel, snapshot.estimatedRuntimeFormatted ?? "–", loadColor), + (chargeTimeLabel, snapshot.estimatedChargeTimeFormatted ?? "–", chargerColor), + (totalPowerLabel, formatPower(snapshot.totalPower), loadColor), + (totalCurrentLabel, "\(formatNumber(snapshot.totalCurrent)) A", loadColor), + (batteryCapacityLabel, "\(formatNumber(snapshot.totalUsableEnergy)) Wh", batteryColor), + (chargerPowerLabel, formatPower(snapshot.totalChargerPower), chargerColor), + ] + + for (i, item) in summaryItems.enumerated() { + let col = i % 2 + let row = i / 2 + let x = margin + CGFloat(col) * (colWidth + 16) + let y = cursorY + CGFloat(row) * rowHeight + + drawSummaryCard(label: item.0, value: item.1, color: item.2, rect: CGRect(x: x, y: y, width: colWidth, height: rowHeight - 8), in: context) + } + + return cursorY + CGFloat((summaryItems.count + 1) / 2) * rowHeight + 8 + } + + private func drawSummaryCard(label: String, value: String, color: UIColor, rect: CGRect, in context: CGContext) { + // Card background + let path = UIBezierPath(roundedRect: rect, cornerRadius: 8) + context.setFillColor(color.withAlphaComponent(0.06).cgColor) + context.addPath(path.cgPath) + context.fillPath() + + // Left accent bar + let barRect = CGRect(x: rect.minX, y: rect.minY, width: 3, height: rect.height) + let barPath = UIBezierPath( + roundedRect: barRect, + byRoundingCorners: [.topLeft, .bottomLeft], + cornerRadii: CGSize(width: 8, height: 8) + ) + context.setFillColor(color.cgColor) + context.addPath(barPath.cgPath) + context.fillPath() + + let labelFont = UIFont.systemFont(ofSize: 10, weight: .medium) + let valueFont = UIFont.systemFont(ofSize: 16, weight: .bold) + + label.draw( + in: CGRect(x: rect.minX + 10, y: rect.minY + 8, width: rect.width - 14, height: labelFont.lineHeight), + withAttributes: [.font: labelFont, .foregroundColor: tertiaryTextColor] + ) + value.draw( + in: CGRect(x: rect.minX + 10, y: rect.minY + 8 + labelFont.lineHeight + 2, width: rect.width - 14, height: valueFont.lineHeight), + withAttributes: [.font: valueFont, .foregroundColor: primaryTextColor] + ) + } + + // MARK: - Detail Tables + + private func drawLoadsTable( + loads: [LoadSnapshot], + unitSystem: UnitSystem, + at yPosition: CGFloat, + in context: UIGraphicsPDFRendererContext, + pageIndex: inout Int, + systemName: String + ) -> CGFloat { + var cursorY = yPosition + + let wireUnit = unitSystem.wireAreaUnit + let lengthUnit = unitSystem.lengthUnit + + for load in loads { + let requiredHeight: CGFloat = 130 + cursorY = ensureSpace(requiredHeight: requiredHeight, cursorY: cursorY, context: context, pageIndex: &pageIndex, systemName: systemName) + + // Load name header + let nameFont = UIFont.systemFont(ofSize: 14, weight: .bold) + load.name.draw( + in: CGRect(x: margin, y: cursorY, width: pageRect.width - margin * 2, height: nameFont.lineHeight + 2), + withAttributes: [.font: nameFont, .foregroundColor: loadColor] + ) + cursorY += nameFont.lineHeight + 6 + + // Two-column detail grid + let colWidth = (pageRect.width - margin * 2 - 16) / 2 + let labelFont = UIFont.systemFont(ofSize: 10, weight: .medium) + let valueFont = UIFont.systemFont(ofSize: 11, weight: .semibold) + + let voltageLabel = String(localized: "overview.pdf.load.voltage", defaultValue: "Voltage") + let currentLabel = String(localized: "overview.pdf.load.current", defaultValue: "Current") + let powerLabel = String(localized: "overview.pdf.load.power", defaultValue: "Power") + let lengthLabel = String(localized: "overview.pdf.load.length", defaultValue: "Cable Length") + let cableLabel = String(localized: "overview.pdf.load.cable", defaultValue: "Cable Size") + let vDropLabel = String(localized: "overview.pdf.load.vdrop", defaultValue: "Voltage Drop") + let pLossLabel = String(localized: "overview.pdf.load.ploss", defaultValue: "Power Loss") + let fuseLabel = String(localized: "overview.pdf.load.fuse", defaultValue: "Fuse") + + let rows: [(String, String, String, String)] = [ + (voltageLabel, "\(formatNumber(load.voltage)) V", currentLabel, "\(formatNumber(load.current)) A"), + (powerLabel, formatPower(load.power), lengthLabel, "\(formatNumber(load.length)) \(lengthUnit)"), + (cableLabel, "\(formatNumber(load.recommendedCrossSection)) \(wireUnit)", vDropLabel, "\(formatNumber(load.voltageDropPercent))%"), + (pLossLabel, formatPower(load.powerLoss), fuseLabel, "\(load.recommendedFuse) A"), + ] + + for row in rows { + // Left column + row.0.draw( + in: CGRect(x: margin, y: cursorY, width: colWidth, height: labelFont.lineHeight), + withAttributes: [.font: labelFont, .foregroundColor: tertiaryTextColor] + ) + row.1.draw( + in: CGRect(x: margin, y: cursorY + labelFont.lineHeight, width: colWidth, height: valueFont.lineHeight), + withAttributes: [.font: valueFont, .foregroundColor: primaryTextColor] + ) + // Right column + row.2.draw( + in: CGRect(x: margin + colWidth + 16, y: cursorY, width: colWidth, height: labelFont.lineHeight), + withAttributes: [.font: labelFont, .foregroundColor: tertiaryTextColor] + ) + row.3.draw( + in: CGRect(x: margin + colWidth + 16, y: cursorY + labelFont.lineHeight, width: colWidth, height: valueFont.lineHeight), + withAttributes: [.font: valueFont, .foregroundColor: primaryTextColor] + ) + + cursorY += labelFont.lineHeight + valueFont.lineHeight + 4 + } + + // Divider + cursorY += 4 + context.cgContext.setStrokeColor(UIColor.separator.cgColor) + context.cgContext.setLineWidth(0.5) + context.cgContext.move(to: CGPoint(x: margin, y: cursorY)) + context.cgContext.addLine(to: CGPoint(x: pageRect.width - margin, y: cursorY)) + context.cgContext.strokePath() + cursorY += 12 + } + + return cursorY + } + + private func drawBatteriesTable( + batteries: [BatterySnapshot], + at yPosition: CGFloat, + in context: UIGraphicsPDFRendererContext, + pageIndex: inout Int, + systemName: String + ) -> CGFloat { + var cursorY = yPosition + + for battery in batteries { + let requiredHeight: CGFloat = 90 + cursorY = ensureSpace(requiredHeight: requiredHeight, cursorY: cursorY, context: context, pageIndex: &pageIndex, systemName: systemName) + + let nameFont = UIFont.systemFont(ofSize: 14, weight: .bold) + battery.name.draw( + in: CGRect(x: margin, y: cursorY, width: pageRect.width - margin * 2, height: nameFont.lineHeight + 2), + withAttributes: [.font: nameFont, .foregroundColor: batteryColor] + ) + cursorY += nameFont.lineHeight + 6 + + let colWidth = (pageRect.width - margin * 2 - 16) / 2 + let labelFont = UIFont.systemFont(ofSize: 10, weight: .medium) + let valueFont = UIFont.systemFont(ofSize: 11, weight: .semibold) + + let chemistryLabel = String(localized: "overview.pdf.battery.chemistry", defaultValue: "Chemistry") + let voltageLabel = String(localized: "overview.pdf.battery.voltage", defaultValue: "Voltage") + let capacityLabel = String(localized: "overview.pdf.battery.capacity", defaultValue: "Capacity") + let usableLabel = String(localized: "overview.pdf.battery.usable", defaultValue: "Usable Capacity") + let energyLabel = String(localized: "overview.pdf.battery.energy", defaultValue: "Energy") + let usableEnergyLabel = String(localized: "overview.pdf.battery.usableenergy", defaultValue: "Usable Energy") + + let rows: [(String, String, String, String)] = [ + (chemistryLabel, battery.chemistry, voltageLabel, "\(formatNumber(battery.nominalVoltage)) V"), + (capacityLabel, "\(formatNumber(battery.capacityAmpHours)) Ah", usableLabel, "\(formatNumber(battery.usableCapacityAmpHours)) Ah"), + (energyLabel, "\(formatNumber(battery.energyWattHours)) Wh", usableEnergyLabel, "\(formatNumber(battery.usableEnergyWattHours)) Wh"), + ] + + for row in rows { + row.0.draw( + in: CGRect(x: margin, y: cursorY, width: colWidth, height: labelFont.lineHeight), + withAttributes: [.font: labelFont, .foregroundColor: tertiaryTextColor] + ) + row.1.draw( + in: CGRect(x: margin, y: cursorY + labelFont.lineHeight, width: colWidth, height: valueFont.lineHeight), + withAttributes: [.font: valueFont, .foregroundColor: primaryTextColor] + ) + row.2.draw( + in: CGRect(x: margin + colWidth + 16, y: cursorY, width: colWidth, height: labelFont.lineHeight), + withAttributes: [.font: labelFont, .foregroundColor: tertiaryTextColor] + ) + row.3.draw( + in: CGRect(x: margin + colWidth + 16, y: cursorY + labelFont.lineHeight, width: colWidth, height: valueFont.lineHeight), + withAttributes: [.font: valueFont, .foregroundColor: primaryTextColor] + ) + cursorY += labelFont.lineHeight + valueFont.lineHeight + 4 + } + + cursorY += 4 + context.cgContext.setStrokeColor(UIColor.separator.cgColor) + context.cgContext.setLineWidth(0.5) + context.cgContext.move(to: CGPoint(x: margin, y: cursorY)) + context.cgContext.addLine(to: CGPoint(x: pageRect.width - margin, y: cursorY)) + context.cgContext.strokePath() + cursorY += 12 + } + + return cursorY + } + + private func drawChargersTable( + chargers: [ChargerSnapshot], + at yPosition: CGFloat, + in context: UIGraphicsPDFRendererContext, + pageIndex: inout Int, + systemName: String + ) -> CGFloat { + var cursorY = yPosition + + for charger in chargers { + let requiredHeight: CGFloat = 70 + cursorY = ensureSpace(requiredHeight: requiredHeight, cursorY: cursorY, context: context, pageIndex: &pageIndex, systemName: systemName) + + let nameFont = UIFont.systemFont(ofSize: 14, weight: .bold) + charger.name.draw( + in: CGRect(x: margin, y: cursorY, width: pageRect.width - margin * 2, height: nameFont.lineHeight + 2), + withAttributes: [.font: nameFont, .foregroundColor: chargerColor] + ) + cursorY += nameFont.lineHeight + 6 + + let colWidth = (pageRect.width - margin * 2 - 16) / 2 + let labelFont = UIFont.systemFont(ofSize: 10, weight: .medium) + let valueFont = UIFont.systemFont(ofSize: 11, weight: .semibold) + + let inputLabel = String(localized: "overview.pdf.charger.input", defaultValue: "Input Voltage") + let outputLabel = String(localized: "overview.pdf.charger.output", defaultValue: "Output Voltage") + let currentLabel = String(localized: "overview.pdf.charger.current", defaultValue: "Max Current") + let powerLabel = String(localized: "overview.pdf.charger.power", defaultValue: "Power") + + let rows: [(String, String, String, String)] = [ + (inputLabel, "\(formatNumber(charger.inputVoltage)) V", outputLabel, "\(formatNumber(charger.outputVoltage)) V"), + (currentLabel, "\(formatNumber(charger.maxCurrentAmps)) A", powerLabel, formatPower(charger.effectivePowerWatts)), + ] + + for row in rows { + row.0.draw( + in: CGRect(x: margin, y: cursorY, width: colWidth, height: labelFont.lineHeight), + withAttributes: [.font: labelFont, .foregroundColor: tertiaryTextColor] + ) + row.1.draw( + in: CGRect(x: margin, y: cursorY + labelFont.lineHeight, width: colWidth, height: valueFont.lineHeight), + withAttributes: [.font: valueFont, .foregroundColor: primaryTextColor] + ) + row.2.draw( + in: CGRect(x: margin + colWidth + 16, y: cursorY, width: colWidth, height: labelFont.lineHeight), + withAttributes: [.font: labelFont, .foregroundColor: tertiaryTextColor] + ) + row.3.draw( + in: CGRect(x: margin + colWidth + 16, y: cursorY + labelFont.lineHeight, width: colWidth, height: valueFont.lineHeight), + withAttributes: [.font: valueFont, .foregroundColor: primaryTextColor] + ) + cursorY += labelFont.lineHeight + valueFont.lineHeight + 4 + } + + cursorY += 4 + context.cgContext.setStrokeColor(UIColor.separator.cgColor) + context.cgContext.setLineWidth(0.5) + context.cgContext.move(to: CGPoint(x: margin, y: cursorY)) + context.cgContext.addLine(to: CGPoint(x: pageRect.width - margin, y: cursorY)) + context.cgContext.strokePath() + cursorY += 12 + } + + return cursorY + } + + // MARK: - Page Layout Helpers + + private func drawPageHeader(title: String, subtitle: String, in context: CGContext) -> CGFloat { + let availableWidth = pageRect.width - (margin * 2) + + let titleFont = UIFont.systemFont(ofSize: 18, weight: .bold) + let titleRect = CGRect(x: margin, y: margin, width: availableWidth, height: titleFont.lineHeight + 4) + title.draw(in: titleRect, withAttributes: [ + .font: titleFont, + .foregroundColor: primaryTextColor, + ]) + + let subtitleFont = UIFont.systemFont(ofSize: 14, weight: .semibold) + let subtitleRect = CGRect( + x: margin, + y: titleRect.maxY + 4, + width: availableWidth, + height: subtitleFont.lineHeight + 2 + ) + subtitle.draw(in: subtitleRect, withAttributes: [ + .font: subtitleFont, + .foregroundColor: accentColor, + ]) + + return subtitleRect.maxY + 16 + } + + private func ensureSpace( + requiredHeight: CGFloat, + cursorY: CGFloat, + context: UIGraphicsPDFRendererContext, + pageIndex: inout Int, + systemName: String + ) -> CGFloat { + if cursorY + requiredHeight <= pageRect.height - margin - 20 { + return cursorY + } + drawFooter(pageIndex: pageIndex, in: context.cgContext) + pageIndex += 1 + context.beginPage() + return drawPageHeader( + title: systemName, + subtitle: String(localized: "overview.pdf.continued", defaultValue: "Continued"), + in: context.cgContext + ) + } + + private func drawFooter(pageIndex: Int, in context: CGContext) { + let footerFont = UIFont.systemFont(ofSize: 9, weight: .regular) + let attributes: [NSAttributedString.Key: Any] = [ + .font: footerFont, + .foregroundColor: tertiaryTextColor, + ] + + // Page number + let format = String(localized: "overview.pdf.page.number", defaultValue: "Page %d") + let pageText = String(format: format, locale: Locale.current, pageIndex) + let pageSize = pageText.size(withAttributes: attributes) + pageText.draw( + at: CGPoint(x: (pageRect.width - pageSize.width) / 2, y: pageRect.height - margin + 10), + withAttributes: attributes + ) + + // Branding + let brandText = "Generated by Cable" + let brandSize = brandText.size(withAttributes: attributes) + brandText.draw( + at: CGPoint(x: pageRect.width - margin - brandSize.width, y: pageRect.height - margin + 10), + withAttributes: attributes + ) + } + + // MARK: - Formatting Helpers + + private func drawCenteredText(_ text: String, in rect: CGRect, attributes: [NSAttributedString.Key: Any]) { + let size = text.size(withAttributes: attributes) + let x = rect.origin.x + (rect.width - size.width) / 2 + text.draw(at: CGPoint(x: x, y: rect.origin.y), withAttributes: attributes) + } + + private func formatNumber(_ value: Double) -> String { + if value == 0 { return "0" } + if value == value.rounded() && abs(value) < 10000 { + return String(format: "%.0f", value) + } + return String(format: "%.1f", value) + } + + private func formatPower(_ watts: Double) -> String { + "\(formatNumber(watts)) W" + } + + private func truncateText(_ text: String, font: UIFont, maxWidth: CGFloat) -> String { + let attrs: [NSAttributedString.Key: Any] = [.font: font] + if text.size(withAttributes: attrs).width <= maxWidth { return text } + var truncated = text + while truncated.count > 1 { + truncated = String(truncated.dropLast()) + let candidate = truncated + "…" + if candidate.size(withAttributes: attrs).width <= maxWidth { + return candidate + } + } + return "…" + } +} + diff --git a/Cable/Overview/SystemOverviewView.swift b/Cable/Overview/SystemOverviewView.swift index 7fdb926..9ddf615 100644 --- a/Cable/Overview/SystemOverviewView.swift +++ b/Cable/Overview/SystemOverviewView.swift @@ -162,7 +162,8 @@ struct SystemOverviewView: View { action: onShowBillOfMaterials, accessibilityIdentifier: "system-bom-button" ) - + + } .padding(.top, 4) } @@ -1420,6 +1421,7 @@ struct SystemOverviewView: View { } } } + } private struct GoalEditorSheet: View { diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index 96bcebe..a0f3114 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -395,3 +395,55 @@ "• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen"; "• The app developers assume no liability for electrical installations" = "• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen"; "• These calculations may not account for all environmental factors" = "• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren"; + +// MARK: - PDF Overview Export +"overview.pdf.loads.section" = "Verbraucher"; +"overview.pdf.batteries.section" = "Batterien"; +"overview.pdf.chargers.section" = "Ladegeräte"; +"overview.pdf.diagram.chargers" = "LADEGERÄTE"; +"overview.pdf.diagram.batteries" = "BATTERIEBANK"; +"overview.pdf.diagram.loads" = "VERBRAUCHER"; +"overview.pdf.diagram.no.batteries" = "Keine Batterien"; +"overview.pdf.diagram.usable" = "Nutzbar"; +"overview.pdf.summary.title" = "Systemübersicht"; +"overview.pdf.summary.runtime" = "Geschätzte Laufzeit"; +"overview.pdf.summary.chargetime" = "Ladezeit"; +"overview.pdf.summary.totalpower" = "Gesamtleistung"; +"overview.pdf.summary.totalcurrent" = "Gesamtstrom"; +"overview.pdf.summary.batterycapacity" = "Batteriekapazität"; +"overview.pdf.summary.chargerpower" = "Ladeleistung"; +"overview.pdf.load.voltage" = "Spannung"; +"overview.pdf.load.current" = "Strom"; +"overview.pdf.load.power" = "Leistung"; +"overview.pdf.load.length" = "Kabellänge"; +"overview.pdf.load.cable" = "Kabelquerschnitt"; +"overview.pdf.load.vdrop" = "Spannungsabfall"; +"overview.pdf.load.ploss" = "Leistungsverlust"; +"overview.pdf.load.fuse" = "Sicherung"; +"overview.pdf.battery.chemistry" = "Chemie"; +"overview.pdf.battery.voltage" = "Spannung"; +"overview.pdf.battery.capacity" = "Kapazität"; +"overview.pdf.battery.usable" = "Nutzbare Kapazität"; +"overview.pdf.battery.energy" = "Energie"; +"overview.pdf.battery.usableenergy" = "Nutzbare Energie"; +"overview.pdf.charger.input" = "Eingangsspannung"; +"overview.pdf.charger.output" = "Ausgangsspannung"; +"overview.pdf.charger.current" = "Max. Strom"; +"overview.pdf.charger.power" = "Leistung"; +"overview.pdf.continued" = "Fortsetzung"; +"overview.pdf.page.number" = "Seite %d"; +"overview.share.button.title" = "Systemübersicht teilen"; +"overview.share.button.subtitle" = "Als PDF exportieren"; +"overview.share.error.title" = "Export fehlgeschlagen"; + +// MARK: - Charger Power Source Type +"charger.editor.source.type" = "Stromquelle"; +"charger.source.shore" = "Landstrom"; +"charger.source.solar" = "Solar"; +"charger.source.wind" = "Wind"; +"charger.source.generator" = "Generator"; + +// MARK: - Share Menu +"overview.share.diagram" = "Schaltplan"; +"overview.share.pdf" = "Vollständiger Bericht (PDF)"; +"overview.share.diagram.error" = "Schaltplan konnte nicht erstellt werden. Überprüfe deine Internetverbindung."; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index 59a2ffd..61d7465 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -357,3 +357,55 @@ "cable.pro.feature.batteryCapacity" = "Configura la capacidad utilizable de la batería"; "cable.pro.feature.usageBased" = "Cálculos basados en el uso"; "generic.ok" = "Aceptar"; + +// MARK: - PDF Overview Export +"overview.pdf.loads.section" = "Cargas"; +"overview.pdf.batteries.section" = "Baterías"; +"overview.pdf.chargers.section" = "Cargadores"; +"overview.pdf.diagram.chargers" = "CARGADORES"; +"overview.pdf.diagram.batteries" = "BANCO DE BATERÍAS"; +"overview.pdf.diagram.loads" = "CARGAS"; +"overview.pdf.diagram.no.batteries" = "Sin baterías"; +"overview.pdf.diagram.usable" = "Utilizable"; +"overview.pdf.summary.title" = "Resumen del sistema"; +"overview.pdf.summary.runtime" = "Autonomía estimada"; +"overview.pdf.summary.chargetime" = "Tiempo de carga"; +"overview.pdf.summary.totalpower" = "Potencia total"; +"overview.pdf.summary.totalcurrent" = "Corriente total"; +"overview.pdf.summary.batterycapacity" = "Capacidad de batería"; +"overview.pdf.summary.chargerpower" = "Potencia de carga"; +"overview.pdf.load.voltage" = "Tensión"; +"overview.pdf.load.current" = "Corriente"; +"overview.pdf.load.power" = "Potencia"; +"overview.pdf.load.length" = "Longitud del cable"; +"overview.pdf.load.cable" = "Sección del cable"; +"overview.pdf.load.vdrop" = "Caída de tensión"; +"overview.pdf.load.ploss" = "Pérdida de potencia"; +"overview.pdf.load.fuse" = "Fusible"; +"overview.pdf.battery.chemistry" = "Química"; +"overview.pdf.battery.voltage" = "Tensión"; +"overview.pdf.battery.capacity" = "Capacidad"; +"overview.pdf.battery.usable" = "Capacidad utilizable"; +"overview.pdf.battery.energy" = "Energía"; +"overview.pdf.battery.usableenergy" = "Energía utilizable"; +"overview.pdf.charger.input" = "Tensión de entrada"; +"overview.pdf.charger.output" = "Tensión de salida"; +"overview.pdf.charger.current" = "Corriente máx."; +"overview.pdf.charger.power" = "Potencia"; +"overview.pdf.continued" = "Continuación"; +"overview.pdf.page.number" = "Página %d"; +"overview.share.button.title" = "Compartir resumen del sistema"; +"overview.share.button.subtitle" = "Exportar como PDF"; +"overview.share.error.title" = "Error en la exportación"; + +// MARK: - Charger Power Source Type +"charger.editor.source.type" = "Fuente de energía"; +"charger.source.shore" = "Corriente de tierra"; +"charger.source.solar" = "Solar"; +"charger.source.wind" = "Eólica"; +"charger.source.generator" = "Generador"; + +// MARK: - Share Menu +"overview.share.diagram" = "Diagrama de cableado"; +"overview.share.pdf" = "Informe completo (PDF)"; +"overview.share.diagram.error" = "No se pudo generar el diagrama. Comprueba tu conexión a Internet."; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index c43ce5d..18bcae5 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -357,3 +357,55 @@ "cable.pro.feature.batteryCapacity" = "Configurez la capacité utilisable de la batterie"; "cable.pro.feature.usageBased" = "Calculs basés sur l’utilisation"; "generic.ok" = "OK"; + +// MARK: - PDF Overview Export +"overview.pdf.loads.section" = "Charges"; +"overview.pdf.batteries.section" = "Batteries"; +"overview.pdf.chargers.section" = "Chargeurs"; +"overview.pdf.diagram.chargers" = "CHARGEURS"; +"overview.pdf.diagram.batteries" = "BANC DE BATTERIES"; +"overview.pdf.diagram.loads" = "CHARGES"; +"overview.pdf.diagram.no.batteries" = "Aucune batterie"; +"overview.pdf.diagram.usable" = "Utilisable"; +"overview.pdf.summary.title" = "Résumé du système"; +"overview.pdf.summary.runtime" = "Autonomie estimée"; +"overview.pdf.summary.chargetime" = "Temps de charge"; +"overview.pdf.summary.totalpower" = "Puissance totale"; +"overview.pdf.summary.totalcurrent" = "Courant total"; +"overview.pdf.summary.batterycapacity" = "Capacité de batterie"; +"overview.pdf.summary.chargerpower" = "Puissance de charge"; +"overview.pdf.load.voltage" = "Tension"; +"overview.pdf.load.current" = "Courant"; +"overview.pdf.load.power" = "Puissance"; +"overview.pdf.load.length" = "Longueur du câble"; +"overview.pdf.load.cable" = "Section du câble"; +"overview.pdf.load.vdrop" = "Chute de tension"; +"overview.pdf.load.ploss" = "Perte de puissance"; +"overview.pdf.load.fuse" = "Fusible"; +"overview.pdf.battery.chemistry" = "Chimie"; +"overview.pdf.battery.voltage" = "Tension"; +"overview.pdf.battery.capacity" = "Capacité"; +"overview.pdf.battery.usable" = "Capacité utilisable"; +"overview.pdf.battery.energy" = "Énergie"; +"overview.pdf.battery.usableenergy" = "Énergie utilisable"; +"overview.pdf.charger.input" = "Tension d'entrée"; +"overview.pdf.charger.output" = "Tension de sortie"; +"overview.pdf.charger.current" = "Courant max."; +"overview.pdf.charger.power" = "Puissance"; +"overview.pdf.continued" = "Suite"; +"overview.pdf.page.number" = "Page %d"; +"overview.share.button.title" = "Partager le résumé du système"; +"overview.share.button.subtitle" = "Exporter en PDF"; +"overview.share.error.title" = "Échec de l'exportation"; + +// MARK: - Charger Power Source Type +"charger.editor.source.type" = "Source d'énergie"; +"charger.source.shore" = "Courant de quai"; +"charger.source.solar" = "Solaire"; +"charger.source.wind" = "Éolienne"; +"charger.source.generator" = "Groupe électrogène"; + +// MARK: - Share Menu +"overview.share.diagram" = "Schéma de câblage"; +"overview.share.pdf" = "Rapport complet (PDF)"; +"overview.share.diagram.error" = "Impossible de générer le schéma. Vérifiez votre connexion Internet."; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index 1462605..6ae0fe8 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -357,3 +357,55 @@ "cable.pro.feature.batteryCapacity" = "Configureer bruikbare batterijcapaciteit"; "cable.pro.feature.usageBased" = "Gebruiksgestuurde berekeningen"; "generic.ok" = "OK"; + +// MARK: - PDF Overview Export +"overview.pdf.loads.section" = "Verbruikers"; +"overview.pdf.batteries.section" = "Accu's"; +"overview.pdf.chargers.section" = "Laders"; +"overview.pdf.diagram.chargers" = "LADERS"; +"overview.pdf.diagram.batteries" = "ACCUBANK"; +"overview.pdf.diagram.loads" = "VERBRUIKERS"; +"overview.pdf.diagram.no.batteries" = "Geen accu's"; +"overview.pdf.diagram.usable" = "Bruikbaar"; +"overview.pdf.summary.title" = "Systeemoverzicht"; +"overview.pdf.summary.runtime" = "Geschatte looptijd"; +"overview.pdf.summary.chargetime" = "Laadtijd"; +"overview.pdf.summary.totalpower" = "Totaal vermogen"; +"overview.pdf.summary.totalcurrent" = "Totale stroom"; +"overview.pdf.summary.batterycapacity" = "Accucapaciteit"; +"overview.pdf.summary.chargerpower" = "Laadvermogen"; +"overview.pdf.load.voltage" = "Spanning"; +"overview.pdf.load.current" = "Stroom"; +"overview.pdf.load.power" = "Vermogen"; +"overview.pdf.load.length" = "Kabellengte"; +"overview.pdf.load.cable" = "Kabeldoorsnede"; +"overview.pdf.load.vdrop" = "Spanningsval"; +"overview.pdf.load.ploss" = "Vermogensverlies"; +"overview.pdf.load.fuse" = "Zekering"; +"overview.pdf.battery.chemistry" = "Chemie"; +"overview.pdf.battery.voltage" = "Spanning"; +"overview.pdf.battery.capacity" = "Capaciteit"; +"overview.pdf.battery.usable" = "Bruikbare capaciteit"; +"overview.pdf.battery.energy" = "Energie"; +"overview.pdf.battery.usableenergy" = "Bruikbare energie"; +"overview.pdf.charger.input" = "Ingangsspanning"; +"overview.pdf.charger.output" = "Uitgangsspanning"; +"overview.pdf.charger.current" = "Max. stroom"; +"overview.pdf.charger.power" = "Vermogen"; +"overview.pdf.continued" = "Vervolg"; +"overview.pdf.page.number" = "Pagina %d"; +"overview.share.button.title" = "Systeemoverzicht delen"; +"overview.share.button.subtitle" = "Exporteren als PDF"; +"overview.share.error.title" = "Export mislukt"; + +// MARK: - Charger Power Source Type +"charger.editor.source.type" = "Stroombron"; +"charger.source.shore" = "Walstroom"; +"charger.source.solar" = "Zonne-energie"; +"charger.source.wind" = "Wind"; +"charger.source.generator" = "Generator"; + +// MARK: - Share Menu +"overview.share.diagram" = "Bedradingsschema"; +"overview.share.pdf" = "Volledig rapport (PDF)"; +"overview.share.diagram.error" = "Diagram kon niet worden gegenereerd. Controleer je internetverbinding.";