Add analytics tracking, overview PDF export, and charger updates
Add first launch event, share tracking for overview PDF and diagram exports. Add SystemOverviewPDFExporter for A4 PDF generation. Update charger model with new configuration fields and localization for all 5 languages. Refresh app icon assets and CLAUDE.md build instructions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
31
CLAUDE.md
31
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
|
||||
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB |
@@ -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"
|
||||
|
||||
BIN
Cable/Assets.xcassets/AppIconWatermark.imageset/AppIcon-iOS-Default-1024x1024@1x.png
vendored
Normal file
BIN
Cable/Assets.xcassets/AppIconWatermark.imageset/AppIcon-iOS-Default-1024x1024@1x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
21
Cable/Assets.xcassets/AppIconWatermark.imageset/Contents.json
vendored
Normal file
21
Cable/Assets.xcassets/AppIconWatermark.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.";
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Bool>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1060
Cable/Overview/SystemOverviewPDFExporter.swift
Normal file
1060
Cable/Overview/SystemOverviewPDFExporter.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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.";
|
||||
|
||||
Reference in New Issue
Block a user