Files
Cable/Cable/Loads/LoadsView.swift
Stefan Lange-Hegermann ea3b60d75c Fix AWG notation, add alternator type, migrate to String(localized:)
- Fix AWG 0/00/000/0000 bug (all resolved to 0 in Swift) using negative
  int convention (-1 through -4) with formatAWG() for 1/0–4/0 display
- Add 7.5A fuse size and change fuse type from Int to Double
- Add alternator power source type with distinct bolt.car.fill icon
- Migrate all NSLocalizedString calls to String(localized:defaultValue:)
- Update translations for runtime subtitle (ES/FR/NL: current→maximum),
  usable capacity footer text, and NL override wording
- Store length always in meters, convert at display time in CalculatorView
- Add preview-friendly inits for ComponentLibraryView and LoadsView
- Expand test coverage for calculations, fuses, AWG, and edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:37:53 +01:00

1221 lines
44 KiB
Swift

//
// LoadsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct LoadsView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var unitSettings: UnitSystemSettings
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
@Query(sort: \SavedBattery.timestamp, order: .reverse) private var allBatteries: [SavedBattery]
@Query(sort: \SavedCharger.timestamp, order: .reverse) private var allChargers: [SavedCharger]
@State private var newLoadToEdit: SavedLoad?
@State private var showingSystemEditor = false
@State private var hasPresentedSystemEditorOnAppear = false
@State private var hasOpenedLoadOnAppear = false
@State private var showingComponentLibrary = false
@State private var showingSystemBOM = false
@State private var selectedComponentTab: ComponentTab
@State private var batteryDraft: BatteryConfiguration?
@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?
init(system: ElectricalSystem, presentSystemEditorOnAppear: Bool = false, loadToOpenOnAppear: SavedLoad? = nil, initialTab: ComponentTab = .overview) {
self.system = system
self.presentSystemEditorOnAppear = presentSystemEditorOnAppear
self.loadToOpenOnAppear = loadToOpenOnAppear
self._selectedComponentTab = State(initialValue: initialTab)
}
private var savedLoads: [SavedLoad] {
allLoads.filter { $0.system == system }
}
private var savedBatteries: [SavedBattery] {
allBatteries.filter { $0.system == system }
}
private var savedChargers: [SavedCharger] {
allChargers.filter { $0.system == system }
}
var body: some View {
VStack(spacing: 0) {
TabView(selection: $selectedComponentTab) {
overviewTab
.tag(ComponentTab.overview)
.tabItem {
Label(
String(
localized: "tab.overview",
bundle: .main,
comment: "Tab title for system overview"
),
systemImage: "rectangle.3.group"
)
.accessibilityIdentifier("overview-tab")
}
componentsTab
.tag(ComponentTab.components)
.tabItem {
Label(
String(
localized: "tab.components",
bundle: .main,
comment: "Tab title for components list"
),
systemImage: "square.stack.3d.up"
)
.accessibilityIdentifier("components-tab")
}
Group {
if savedBatteries.isEmpty {
OnboardingInfoView(
configuration: .battery(),
onPrimaryAction: { startBatteryConfiguration() }
)
} else {
BatteriesView(
system: system,
batteries: savedBatteries,
editMode: $editMode,
onEdit: { editBattery($0) },
onDelete: deleteBatteries
)
.environment(\.editMode, $editMode)
}
}
.tag(ComponentTab.batteries)
.tabItem {
Label(
String(
localized: "tab.batteries",
bundle: .main,
comment: "Tab title for battery configurations"
),
systemImage: "battery.100"
)
.accessibilityIdentifier("batteries-tab")
}
.environment(\.editMode, $editMode)
ChargersView(
system: system,
chargers: savedChargers,
editMode: $editMode,
onAdd: { startChargerConfiguration() },
onEdit: { editCharger($0) },
onDelete: deleteChargers
)
.tag(ComponentTab.chargers)
.tabItem {
Label(
String(
localized: "tab.chargers",
bundle: .main,
comment: "Tab title for chargers view"
),
systemImage: "bolt.fill"
)
.accessibilityIdentifier("chargers-tab")
}
.environment(\.editMode, $editMode)
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Button(action: {
presentSystemEditor(source: "toolbar")
}) {
HStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(Color.componentColor(named: system.colorName))
.frame(width: 24, height: 24)
Image(systemName: system.iconName)
.font(.system(size: 12))
.foregroundColor(.white)
}
Text(system.name)
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
let showPrimary = selectedComponentTab != .overview
let showEditLoads = selectedComponentTab == .components && !savedLoads.isEmpty
let showEditBatteries = selectedComponentTab == .batteries && !savedBatteries.isEmpty
let showEditChargers = selectedComponentTab == .chargers && !savedChargers.isEmpty
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: {
handlePrimaryAction()
}) {
Image(systemName: "plus")
}
}
if showEditLoads {
EditButton()
.disabled(savedLoads.isEmpty)
} else if showEditBatteries {
EditButton()
.disabled(savedBatteries.isEmpty)
} else if showEditChargers {
EditButton()
.disabled(savedChargers.isEmpty)
}
}
}
}
}
.navigationDestination(item: $newLoadToEdit) { load in
CalculatorView(savedLoad: load)
}
.navigationDestination(item: $batteryDraft) { draft in
BatteryEditorView(
configuration: draft,
onSave: { configuration in
saveBattery(configuration)
batteryDraft = nil
}
)
}
.navigationDestination(item: $chargerDraft) { draft in
ChargerEditorView(
configuration: draft,
onSave: { configuration in
saveCharger(configuration)
chargerDraft = nil
}
)
}
.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)
}
}
.sheet(isPresented: $showingSystemBOM) {
SystemBillOfMaterialsView(
systemName: system.name,
loads: savedLoads,
batteries: savedBatteries,
chargers: savedChargers,
unitSystem: unitSettings.unitSystem
)
}
.sheet(isPresented: $showingSystemEditor) {
SystemEditorView(
systemName: Binding(
get: { system.name },
set: { system.name = $0 }
),
location: Binding(
get: { system.location },
set: { system.location = $0 }
),
iconName: Binding(
get: { system.iconName },
set: { system.iconName = $0 }
),
colorName: Binding(
get: { system.colorName },
set: { system.colorName = $0 }
)
)
}
.alert(item: $activeStatus) { status in
let detail = status.detailInfo()
return Alert(
title: Text(detail.title),
message: Text(detail.message),
dismissButton: .default(
Text(
String(localized: "battery.bank.status.dismiss", defaultValue: "Got it")
)
)
)
}
.onAppear {
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
hasPresentedSystemEditorOnAppear = true
DispatchQueue.main.async {
presentSystemEditor(source: "auto")
}
}
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
hasOpenedLoadOnAppear = true
DispatchQueue.main.async {
AnalyticsTracker.log(
"Load Opened",
properties: [
"mode": loadToOpen.isWattMode ? "watt" : "amp",
"system": system.name
]
)
newLoadToEdit = loadToOpen
}
}
}
.onChange(of: selectedComponentTab) { _, newValue in
AnalyticsTracker.log("Tab Changed", properties: [
"tab": "\(newValue)",
"system": system.name,
])
if newValue == .chargers || newValue == .overview {
editMode = .inactive
}
}
.environment(\.editMode, $editMode)
}
private var overviewTab: some View {
SystemOverviewView(
system: system,
loads: savedLoads,
batteries: savedBatteries,
chargers: savedChargers,
onSelectLoads: { selectedComponentTab = .components },
onSelectBatteries: { selectedComponentTab = .batteries },
onSelectChargers: { selectedComponentTab = .chargers },
onCreateLoad: { createNewLoad() },
onBrowseLibrary: { openComponentLibrary(source: "overview") },
onShowBillOfMaterials: { openBillOfMaterials() },
onCreateBattery: { startBatteryConfiguration() },
onCreateCharger: { startChargerConfiguration() }
)
.accessibilityIdentifier("system-overview")
}
private var loadsStatsHeader: some View {
StatsHeaderContainer {
loadsSummaryContent
}
}
private var loadsSummaryContent: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(loadsSummaryTitle)
.font(.headline.weight(.semibold))
Spacer()
}
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) {
summaryMetric(
icon: "square.stack.3d.up",
label: loadsCountLabel,
value: "\(savedLoads.count)",
tint: .blue
)
summaryMetric(
icon: "bolt.fill",
label: loadsCurrentLabel,
value: formattedCurrent(totalCurrent),
tint: .orange
)
summaryMetric(
icon: "gauge.medium",
label: loadsPowerLabel,
value: formattedPower(totalPower),
tint: .green
)
}
VStack(alignment: .leading, spacing: 12) {
summaryMetric(
icon: "square.stack.3d.up",
label: loadsCountLabel,
value: "\(savedLoads.count)",
tint: .blue
)
summaryMetric(
icon: "bolt.fill",
label: loadsCurrentLabel,
value: formattedCurrent(totalCurrent),
tint: .orange
)
summaryMetric(
icon: "gauge.medium",
label: loadsPowerLabel,
value: formattedPower(totalPower),
tint: .green
)
}
}
if let status = loadStatus {
Button {
activeStatus = status
} label: {
statusBanner(for: status)
}
.buttonStyle(.plain)
}
}
}
private var libraryButton: some View {
Button {
openComponentLibrary(source: "library-button")
} label: {
Group {
if #available(iOS 26.0, *) {
libraryButtonLabel
.padding(.horizontal, 18)
.padding(.vertical, 12)
.glassEffect(.regular, in: .capsule)
} else {
libraryButtonLabel
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.ultraThinMaterial, in: Capsule(style: .continuous))
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 8)
}
}
}
.buttonStyle(.plain)
.tint(.accentColor)
}
private var libraryButtonLabel: some View {
Label(
String(
localized: "loads.library.button",
bundle: .main,
comment: "Button title to open component library"
),
systemImage: "books.vertical"
)
.font(.footnote.weight(.semibold))
}
private var componentsTab: some View {
VStack(spacing: 0) {
if savedLoads.isEmpty {
OnboardingInfoView(
configuration: .loads(),
onPrimaryAction: { createNewLoad() },
onSecondaryAction: { openComponentLibrary(source: "components-onboarding") }
)
.padding(.horizontal, 0)
} else {
loadsListWithHeader
}
}
.background(Color(.systemGroupedBackground))
}
@ViewBuilder
private var loadsListWithHeader: some View {
Group {
if #available(iOS 26.0, *) {
baseLoadsList
.scrollEdgeEffectStyle(.soft, for: .top)
.safeAreaInset(edge: .top, spacing: 0) {
loadsStatsHeader
}
} else {
baseLoadsList
.safeAreaInset(edge: .top, spacing: 0) {
loadsStatsHeader
}
}
}
.overlay(alignment: .bottomTrailing) {
libraryButton
.padding(.trailing, 24)
.padding(.bottom, 24)
}
}
private var baseLoadsList: some View {
List {
ForEach(savedLoads) { load in
Button {
selectLoad(load)
} label: {
loadRow(for: load)
}
.buttonStyle(.plain)
.disabled(editMode == .active)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onDelete(perform: deleteLoads)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.accessibilityIdentifier("loads-list")
.environment(\.editMode, $editMode)
}
private func selectLoad(_ load: SavedLoad) {
AnalyticsTracker.log(
"Load Opened",
properties: [
"mode": load.isWattMode ? "watt" : "amp",
"system": system.name
]
)
newLoadToEdit = load
}
private func loadRow(for load: SavedLoad) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
LoadIconView(
remoteIconURLString: load.remoteIconURLString,
fallbackSystemName: load.iconName,
fallbackColor: Color.componentColor(named: load.colorName),
size: 48
)
VStack(alignment: .leading, spacing: 4) {
Text(load.name)
.font(.body.weight(.medium))
.lineLimit(1)
.truncationMode(.tail)
Text(loadSummary(for: load))
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule(style: .continuous)
.fill(Color(.tertiarySystemBackground))
)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
}
metricsSection(for: load)
}
.padding(.vertical, 16)
.padding(.horizontal, 16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(.systemBackground))
)
}
private func loadSummary(for load: SavedLoad) -> String {
let voltageText = String(format: "%.1fV", load.voltage)
let lengthText: String
if unitSettings.unitSystem == .metric {
lengthText = String(format: "%.1f%@", load.length, unitSettings.unitSystem.lengthUnit)
} else {
let imperialLength = load.length * 3.28084
lengthText = String(format: "%.1f%@", imperialLength, unitSettings.unitSystem.lengthUnit)
}
let powerOrCurrent = load.isWattMode
? String(format: "%.0fW", load.power)
: String(format: "%.1fA", load.current)
return [voltageText, powerOrCurrent, lengthText].joined(separator: "")
}
private func wireGaugeString(for load: SavedLoad) -> String {
if unitSettings.unitSystem == .imperial {
let awgValue = awgFromCrossSection(load.crossSection)
return "\(ElectricalCalculations.formatAWG(awgValue)) AWG"
} else {
return String(format: "%.1f mm²", load.crossSection)
}
}
private func lengthString(for load: SavedLoad) -> String {
if unitSettings.unitSystem == .imperial {
let imperialLength = load.length * 3.28084
return String(format: "%.1f ft", imperialLength)
} else {
return String(format: "%.1f m", load.length)
}
}
@ViewBuilder
private func metricsSection(for load: SavedLoad) -> some View {
if editMode == .active {
horizontalMetrics(for: load)
} else {
ViewThatFits(in: .horizontal) {
horizontalMetrics(for: load)
verticalMetrics(for: load)
}
}
}
private func horizontalMetrics(for load: SavedLoad) -> some View {
HStack(spacing: 12) {
metricBadge(
label: fuseMetricLabel,
value: "\(recommendedFuse(for: load)) A",
tint: .pink
)
metricBadge(
label: cableMetricLabel,
value: wireGaugeString(for: load),
tint: .teal
)
metricBadge(
label: lengthMetricLabel,
value: lengthString(for: load),
tint: .orange
)
Spacer(minLength: 0)
}
}
private func verticalMetrics(for load: SavedLoad) -> some View {
VStack(alignment: .leading, spacing: 8) {
metricBadge(
label: fuseMetricLabel,
value: "\(recommendedFuse(for: load)) A",
tint: .pink
)
metricBadge(
label: cableMetricLabel,
value: wireGaugeString(for: load),
tint: .teal
)
metricBadge(
label: lengthMetricLabel,
value: lengthString(for: load),
tint: .orange
)
}
}
private var fuseMetricLabel: String {
String(localized: "loads.metric.fuse", defaultValue: "Fuse")
}
private var cableMetricLabel: String {
String(localized: "loads.metric.cable", defaultValue: "Cable")
}
private var lengthMetricLabel: String {
String(localized: "loads.metric.length", defaultValue: "Length")
}
private var loadsSummaryTitle: String {
String(localized: "loads.overview.header.title", defaultValue: "Load Overview")
}
private var loadsCountLabel: String {
String(localized: "loads.overview.metric.count", defaultValue: "Loads")
}
private var loadsCurrentLabel: String {
String(localized: "loads.overview.metric.current", defaultValue: "Total Current")
}
private var loadsPowerLabel: String {
String(localized: "loads.overview.metric.power", defaultValue: "Total Power")
}
private var totalCurrent: Double {
savedLoads.reduce(0) { result, load in
result + max(load.current, 0)
}
}
private var totalPower: Double {
savedLoads.reduce(0) { result, load in
result + max(load.power, 0)
}
}
private var loadStatus: LoadConfigurationStatus? {
guard !savedLoads.isEmpty else { return nil }
let incompleteLoads = savedLoads.filter { load in
load.length <= 0 || load.crossSection <= 0
}
if !incompleteLoads.isEmpty {
return .missingDetails(count: incompleteLoads.count)
}
return nil
}
private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View {
ComponentSummaryMetricView(
icon: icon,
label: label,
value: value,
tint: tint
)
}
private func statusBanner(for status: LoadConfigurationStatus) -> some View {
HStack(spacing: 10) {
Image(systemName: status.symbol)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(status.tint)
Text(status.bannerText)
.font(.subheadline.weight(.semibold))
.foregroundStyle(status.tint)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(status.tint.opacity(0.12))
)
}
private func formattedCurrent(_ value: Double) -> String {
let numberString = LoadsView.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
return "\(numberString) A"
}
private func formattedPower(_ value: Double) -> String {
let numberString = LoadsView.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
return "\(numberString) W"
}
private static let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = .current
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
return formatter
}()
private func metricBadge(label: String, value: String, tint: Color) -> some View {
ComponentMetricBadgeView(
label: label,
value: value,
tint: tint
)
}
private func handlePrimaryAction() {
switch selectedComponentTab {
case .overview:
createNewLoad()
case .components:
createNewLoad()
case .batteries:
startBatteryConfiguration()
case .chargers:
startChargerConfiguration()
}
}
private func presentSystemEditor(source: String) {
AnalyticsTracker.log(
"System Editor Opened",
properties: [
"source": source,
"system": system.name
]
)
showingSystemEditor = true
}
private func openComponentLibrary(source: String) {
AnalyticsTracker.log(
"Component Library Opened",
properties: [
"source": source,
"system": system.name
]
)
showingComponentLibrary = true
}
private func openBillOfMaterials() {
AnalyticsTracker.log(
"Bill Of Materials Opened",
properties: [
"system": system.name
]
)
showingSystemBOM = true
}
private func deleteLoads(offsets: IndexSet) {
let loadsToDelete = offsets.map { savedLoads[$0] }
withAnimation {
for load in loadsToDelete {
AnalyticsTracker.log(
"Load Deleted",
properties: [
"name": load.name,
"system": system.name
]
)
modelContext.delete(load)
}
}
}
private func createNewLoad() {
let newLoad = SystemComponentsPersistence.createDefaultLoad(
for: system,
in: modelContext,
existingLoads: savedLoads,
existingBatteries: savedBatteries,
existingChargers: savedChargers
)
AnalyticsTracker.log(
"Load Created",
properties: [
"name": newLoad.name,
"system": system.name
]
)
newLoadToEdit = newLoad
}
private func startBatteryConfiguration() {
AnalyticsTracker.log(
"Battery Editor Opened",
properties: [
"source": "create",
"system": system.name
]
)
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
for: system,
existingLoads: savedLoads,
existingBatteries: savedBatteries,
existingChargers: savedChargers
)
}
private func saveBattery(_ configuration: BatteryConfiguration) {
let isExisting = savedBatteries.contains { $0.id == configuration.id }
SystemComponentsPersistence.saveBattery(
configuration,
for: system,
existingBatteries: savedBatteries,
in: modelContext
)
let eventName = isExisting ? "Battery Updated" : "Battery Created"
AnalyticsTracker.log(
eventName,
properties: [
"name": configuration.name,
"system": system.name
]
)
}
private func editBattery(_ battery: SavedBattery) {
AnalyticsTracker.log(
"Battery Editor Opened",
properties: [
"source": "edit",
"system": system.name
]
)
batteryDraft = BatteryConfiguration(savedBattery: battery, system: system)
}
private func deleteBatteries(_ offsets: IndexSet) {
let batteriesToDelete = offsets.map { savedBatteries[$0] }
withAnimation {
for battery in batteriesToDelete {
AnalyticsTracker.log(
"Battery Deleted",
properties: [
"name": battery.name,
"system": system.name
]
)
}
SystemComponentsPersistence.deleteBatteries(
at: offsets,
from: savedBatteries,
in: modelContext
)
}
}
private func startChargerConfiguration() {
AnalyticsTracker.log(
"Charger Editor Opened",
properties: [
"source": "create",
"system": system.name
]
)
chargerDraft = SystemComponentsPersistence.makeChargerDraft(
for: system,
existingLoads: savedLoads,
existingBatteries: savedBatteries,
existingChargers: savedChargers
)
}
private func saveCharger(_ configuration: ChargerConfiguration) {
let isExisting = savedChargers.contains { $0.id == configuration.id }
SystemComponentsPersistence.saveCharger(
configuration,
for: system,
existingChargers: savedChargers,
in: modelContext
)
let eventName = isExisting ? "Charger Updated" : "Charger Created"
AnalyticsTracker.log(
eventName,
properties: [
"name": configuration.name,
"system": system.name
]
)
}
private func editCharger(_ charger: SavedCharger) {
AnalyticsTracker.log(
"Charger Editor Opened",
properties: [
"source": "edit",
"system": system.name
]
)
chargerDraft = ChargerConfiguration(savedCharger: charger, system: system)
}
private func deleteChargers(_ offsets: IndexSet) {
let chargersToDelete = offsets.map { savedChargers[$0] }
withAnimation {
for charger in chargersToDelete {
AnalyticsTracker.log(
"Charger Deleted",
properties: [
"name": charger.name,
"system": system.name
]
)
}
SystemComponentsPersistence.deleteChargers(
at: offsets,
from: savedChargers,
in: modelContext
)
}
}
private func addComponent(_ item: ComponentLibraryItem) {
let newLoad = SystemComponentsPersistence.createLoad(
from: item,
for: system,
in: modelContext,
existingLoads: savedLoads,
existingBatteries: savedBatteries,
existingChargers: savedChargers
)
AnalyticsTracker.log(
"Library Load Added",
properties: [
"id": item.id,
"name": item.localizedName,
"system": system.name
]
)
newLoadToEdit = newLoad
}
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
let awgSizes: [(Int, Double)] = [(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31),
(10, 5.26), (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6),
(1, 42.4), (-1, 53.5), (-2, 67.4), (-3, 85.0), (-4, 107.0)]
// Find the closest AWG size
let closest = awgSizes.min { abs($0.1 - crossSectionMM2) < abs($1.1 - crossSectionMM2) }
return Double(closest?.0 ?? 20)
}
private func recommendedFuse(for load: SavedLoad) -> String {
let fuse = ElectricalCalculations.recommendedFuse(forCurrent: load.current)
return fuse == fuse.rounded() ? String(format: "%.0f", fuse) : String(format: "%.1f", fuse)
}
enum ComponentTab: Hashable {
case overview
case components
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)
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)
}
}
}