- 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>
1221 lines
44 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|
|
|