Files
Cable/Cable/Overview/SystemOverviewView.swift
Stefan Lange-Hegermann 34e8c0f74b Add duty cycle/utilization fields, wheel picker for goals, and updated screenshots
- Add dutyCyclePercent and defaultUtilizationFactorPercent to ComponentLibraryItem
  with normalization logic and backend field fetching
- Change default dailyUsageHours from 1h to 24h
- Replace goal editor stepper with day/hour/minute wheel pickers
- Update app icon colors and remove duplicate icon assets
- Move SavedBattery.swift into Batteries/ directory, remove Pods group
- Add iPad-only flag and start frame support to screenshot framing scripts
- Rework localized App Store screenshot titles across all languages
- Add runtime goals and BOM completed items to sample data
- Bump version to 1.5.1 (build 41)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:49:21 +01:00

1844 lines
63 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import SwiftData
struct SystemOverviewView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@State private var activeStatus: LoadConfigurationStatus?
@State private var suppressLoadNavigation = false
@State private var showingRuntimeGoalEditor = false
@State private var showingChargeGoalEditor = false
@State private var runtimeGoalDraftHours: Double = 4
@State private var chargeGoalDraftHours: Double = 4
let system: ElectricalSystem
let loads: [SavedLoad]
let batteries: [SavedBattery]
let chargers: [SavedCharger]
let onSelectLoads: () -> Void
let onSelectBatteries: () -> Void
let onSelectChargers: () -> Void
let onCreateLoad: () -> Void
let onBrowseLibrary: () -> Void
let onShowBillOfMaterials: () -> Void
let onCreateBattery: () -> Void
let onCreateCharger: () -> Void
var body: some View {
VStack(spacing: 0) {
if #available(iOS 26.0, *) {
ScrollView {
VStack(spacing: 16) {
loadsCard
batteriesCard
chargersCard
}
.padding(.horizontal, 16)
.padding(.bottom, 20)
}
// let the content slide under the top inset
// choose the Liquid Glass-friendly edge style
.scrollEdgeEffectStyle(.soft, for: .top)
// pin the Liquid Glass system card at the top safe area
.safeAreaInset(edge: .top, spacing: 0) {
systemCard
// Liquid Glass on custom view
.glassEffect(.regular, in: .rect(cornerRadius: 20))
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 12)
}
} else {
ScrollView {
VStack(spacing: 16) {
loadsCard
batteriesCard
chargersCard
}
.padding(.horizontal, 16)
.padding(.bottom, 20)
}
// Dont ignore the safe area we want content to sit below the header
.safeAreaInset(edge: .top, spacing: 0) {
systemCard
.padding(.horizontal, 16)
.padding(.top, 20)
.padding(.bottom, 16)
.background(Color(.systemGroupedBackground)
)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(.white.opacity(0.15))
)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.sheet(isPresented: $showingRuntimeGoalEditor) {
GoalEditorSheet(
title: runtimeGoalSheetTitle,
tint: .orange,
value: $runtimeGoalDraftHours,
minimum: Self.minimumGoalHours,
maximum: Self.maximumGoalHours,
step: Self.goalStepHours,
cancelTitle: goalCancelTitle,
saveTitle: goalSaveTitle,
clearTitle: goalClearTitle,
showsClear: system.targetRuntimeHours != nil,
formattedDurationProvider: { formattedDuration(hours: $0) },
onSave: { saveRuntimeGoal(hours: $0) },
onClear: system.targetRuntimeHours != nil ? { clearRuntimeGoal() } : nil
)
}
.sheet(isPresented: $showingChargeGoalEditor) {
GoalEditorSheet(
title: chargeGoalSheetTitle,
tint: .blue,
value: $chargeGoalDraftHours,
minimum: Self.minimumGoalHours,
maximum: Self.maximumGoalHours,
step: Self.goalStepHours,
cancelTitle: goalCancelTitle,
saveTitle: goalSaveTitle,
clearTitle: goalClearTitle,
showsClear: system.targetChargeTimeHours != nil,
formattedDurationProvider: { formattedDuration(hours: $0) },
onSave: { saveChargeGoal(hours: $0) },
onClear: system.targetChargeTimeHours != nil ? { clearChargeGoal() } : nil
)
}
}
private var systemCard: some View {
VStack(alignment: .leading, spacing: 18) {
VStack(spacing: 14) {
overviewMetricRow(
title: runtimeTitle,
subtitle: runtimeSubtitle,
icon: "clock.arrow.circlepath",
tint: .orange,
value: formattedRuntime,
placeholder: runtimePlaceholderSummary,
goal: runtimeGoalValueText,
actualHours: estimatedRuntimeHours,
goalHours: system.targetRuntimeHours,
progressFraction: nil,
hasValue: formattedRuntime != nil,
action: openRuntimeGoalEditor
)
overviewMetricRow(
title: chargeTimeTitle,
subtitle: chargeTimeSubtitle,
icon: "bolt.fill",
tint: .blue,
value: formattedChargeTime,
placeholder: chargeTimePlaceholderSummary,
goal: chargeGoalValueText,
actualHours: estimatedChargeHours,
goalHours: system.targetChargeTimeHours,
progressFraction: nil,
hasValue: formattedChargeTime != nil,
action: openChargeGoalEditor
)
overviewMetricRow(
title: bomTitle,
subtitle: bomSubtitle,
icon: "list.bullet.rectangle",
tint: .purple,
value: formattedBOMCompletedCount,
placeholder: bomPlaceholderSummary,
goal: formattedBOMTotalCount,
actualHours: nil,
goalHours: nil,
progressFraction: bomCompletionFraction,
hasValue: bomItemsCount > 0,
action: onShowBillOfMaterials,
accessibilityIdentifier: "system-bom-button"
)
}
.padding(.top, 4)
}
.padding(.vertical, 18)
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color(.separator).opacity(0.18), lineWidth: 1)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(red: 81/255, green: 144/255, blue: 152/255).opacity(0.12))
)
)
}
@ViewBuilder
private func overviewMetricRow(
title: String,
subtitle: String?,
icon: String,
tint: Color,
value: String?,
placeholder: String?,
goal: String?,
actualHours: Double?,
goalHours: Double?,
progressFraction: Double?,
hasValue: Bool,
action: (() -> Void)? = nil,
accessibilityIdentifier: String? = nil
) -> some View {
let content = VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 12) {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(tint.opacity(0.18))
.frame(width: 24, height: 24)
.overlay {
Image(systemName: icon)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(tint)
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
if let subtitle {
Text(subtitle)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(value ?? "")
.font(.headline.weight(.semibold))
.foregroundStyle(hasValue ? Color.primary : Color.secondary)
.lineLimit(1)
if let goal {
Text("\(goalPrefix) \(goal)")
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
progressBar(
progress: adjustedProgress(for: progressFraction, actualHours: actualHours, goalHours: goalHours),
tint: tint
)
}
.padding(.vertical, 2)
let paddedContent = content
.padding(.horizontal, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
if let action {
if let accessibilityIdentifier {
Button(action: action) {
paddedContent
}
.buttonStyle(.plain)
.accessibilityIdentifier(accessibilityIdentifier)
.accessibilityLabel(title)
.accessibilityAddTraits(.isButton)
.contentShape(Rectangle())
} else {
Button(action: action) {
paddedContent
}
.buttonStyle(.plain)
.accessibilityLabel(title)
.accessibilityAddTraits(.isButton)
.contentShape(Rectangle())
}
} else {
paddedContent
}
}
@ViewBuilder
private var loadsCard: some View {
if loads.isEmpty {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .firstTextBaseline) {
Text(loadsSummaryTitle)
.font(.headline.weight(.semibold))
Spacer()
}
VStack(alignment: .leading, spacing: 8) {
Text(loadsEmptyTitle)
.font(.subheadline.weight(.semibold))
Text(loadsEmptyMessage)
.font(.footnote)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 10) {
Button(action: onCreateLoad) {
Label(loadsEmptyCreateAction, systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
Button(action: onBrowseLibrary) {
Label(loadsEmptyBrowseAction, systemImage: "books.vertical")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.accentColor)
.controlSize(.large)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(.tertiarySystemBackground))
)
} else {
Button {
if suppressLoadNavigation {
suppressLoadNavigation = false
return
}
onSelectLoads()
} label: {
VStack(alignment: .leading, spacing: 16) {
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: "\(loads.count)",
tint: .blue
).frame(maxWidth: .infinity, alignment: .leading)
summaryMetric(
icon: "bolt.fill",
label: loadsCurrentLabel,
value: formattedCurrent(totalCurrent),
tint: .orange
).frame(maxWidth: .infinity, alignment: .leading)
summaryMetric(
icon: "gauge.medium",
label: loadsPowerLabel,
value: formattedPower(totalPower),
tint: .green
).frame(maxWidth: .infinity, alignment: .leading)
}
VStack(alignment: .leading, spacing: 12) {
summaryMetric(
icon: "square.stack.3d.up",
label: loadsCountLabel,
value: "\(loads.count)",
tint: .blue
).frame(maxWidth: .infinity, alignment: .leading)
summaryMetric(
icon: "bolt.fill",
label: loadsCurrentLabel,
value: formattedCurrent(totalCurrent),
tint: .orange
).frame(maxWidth: .infinity, alignment: .leading)
summaryMetric(
icon: "gauge.medium",
label: loadsPowerLabel,
value: formattedPower(totalPower),
tint: .green
).frame(maxWidth: .infinity, alignment: .leading)
}
}
if let status = loadStatus {
statusBanner(for: status)
.simultaneousGesture(
TapGesture().onEnded {
suppressLoadNavigation = true
activeStatus = status
}
)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(.tertiarySystemBackground))
)
}
.buttonStyle(.plain)
.alert(item: $activeStatus) { status in
let detail = status.detailInfo()
return Alert(
title: Text(detail.title),
message: Text(detail.message),
dismissButton: .default(
Text(
NSLocalizedString(
"battery.bank.status.dismiss",
bundle: .main,
value: "Got it",
comment: "Dismiss button title for load status alert"
)
)
)
)
}
}
}
private var batteriesCard: some View {
Button(action: onSelectBatteries) {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .center, spacing: 10) {
Text(batterySummaryTitle)
.font(.headline.weight(.semibold))
if let warning = batteryWarning {
HStack(spacing: 6) {
Image(systemName: warning.symbol)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(warning.tint)
Text(warning.shortLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(warning.tint)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(warning.tint.opacity(0.12))
)
}
Spacer()
}
if batteries.isEmpty {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(batteryEmptyTitle)
.font(.subheadline.weight(.semibold))
Text(batteryEmptySubtitle)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Button(action: onCreateBattery) {
Label(batteryEmptyCreateAction, systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
} else {
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) {
batteryMetricsContent
}
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: 2),
alignment: .leading,
spacing: 16
) {
batteryMetricsContent
}
VStack(alignment: .leading, spacing: 12) {
batteryMetricsContent
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(.tertiarySystemBackground))
)
}
.buttonStyle(.plain)
}
@ViewBuilder
private var batteryMetricsContent: some View {
summaryMetric(
icon: "battery.100",
label: batteryCountLabel,
value: "\(batteries.count)",
tint: .blue
)
summaryMetric(
icon: "gauge.medium",
label: batteryCapacityLabel,
value: formattedValue(totalCapacity, unit: "Ah"),
tint: .orange
)
summaryMetric(
icon: "battery.100.bolt",
label: batteryUsableCapacityLabel,
value: formattedValue(totalUsableCapacity, unit: "Ah"),
tint: .teal
)
summaryMetric(
icon: "bolt.circle",
label: batteryUsableEnergyLabel,
value: formattedValue(totalUsableEnergy, unit: "Wh"),
tint: .green
)
}
private var chargersCard: some View {
Button(action: onSelectChargers) {
VStack(alignment: .leading, spacing: 16) {
Text(chargerSummaryTitle)
.font(.headline.weight(.semibold))
if chargers.isEmpty {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(chargerEmptyTitle)
.font(.subheadline.weight(.semibold))
Text(chargerEmptySubtitle)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Button(action: onCreateCharger) {
Label(chargerEmptyCreateAction, systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
} else {
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) {
chargerMetricsContent
}
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: 2),
alignment: .leading,
spacing: 16
) {
chargerMetricsContent
}
VStack(alignment: .leading, spacing: 12) {
chargerMetricsContent
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(.tertiarySystemBackground))
)
}
.buttonStyle(.plain)
}
@ViewBuilder
private var chargerMetricsContent: some View {
summaryMetric(
icon: "bolt.fill",
label: chargerCountLabel,
value: "\(chargers.count)",
tint: .blue
).frame(maxWidth: .infinity, alignment: .leading)
summaryMetric(
icon: "gauge.medium",
label: chargerCurrentLabel,
value: formattedCurrent(totalChargerCurrent),
tint: .orange
).frame(maxWidth: .infinity, alignment: .leading)
summaryMetric(
icon: "bolt.circle",
label: chargerPowerLabel,
value: formattedPower(totalChargerPower),
tint: .green
).frame(maxWidth: .infinity, alignment: .leading)
}
private var loadStatus: LoadConfigurationStatus? {
guard !loads.isEmpty else { return nil }
let incomplete = loads.filter { load in
load.length <= 0 || load.crossSection <= 0
}
if !incomplete.isEmpty {
return .missingDetails(count: incomplete.count)
}
return nil
}
private var totalCurrent: Double {
loads.reduce(0) { result, load in
result + max(load.current, 0)
}
}
private var totalPower: Double {
loads.reduce(0) { result, load in
result + max(load.power, 0)
}
}
private var totalAverageLoadPower: Double {
loads.reduce(0) { result, load in
let power = max(load.power, 0)
guard power > 0 else { return result }
let dutyCycleFraction = max(min(load.dutyCyclePercent, 100), 0) / 100
let usageFraction = max(min(load.dailyUsageHours, 24), 0) / 24
return result + power * dutyCycleFraction * usageFraction
}
}
private var totalCapacity: Double {
batteries.reduce(0) { result, battery in
result + battery.capacityAmpHours
}
}
private var totalEnergy: Double {
batteries.reduce(0) { result, battery in
result + battery.energyWattHours
}
}
private var totalUsableCapacity: Double {
batteries.reduce(0) { result, battery in
result + battery.usableCapacityAmpHours
}
}
private var totalUsableEnergy: Double {
batteries.reduce(0) { result, battery in
result + battery.usableEnergyWattHours
}
}
private var totalChargerCurrent: Double {
chargers.reduce(0) { result, charger in
result + max(0, charger.maxCurrentAmps)
}
}
private var totalChargerPower: Double {
chargers.reduce(0) { result, charger in
result + max(0, charger.effectivePowerWatts)
}
}
private var representativeChargerOutput: Double? {
let outputs = chargers.map { $0.outputVoltage }.filter { $0 > 0 }
guard !outputs.isEmpty else { return nil }
let total = outputs.reduce(0, +)
return total / Double(outputs.count)
}
private var batteryWarning: BatteryWarning? {
guard batteries.count > 1 else { return nil }
if let targetVoltage = dominantValue(from: batteries.map { $0.nominalVoltage }, scale: 0.1) {
let mismatched = batteries.filter { abs($0.nominalVoltage - targetVoltage) > 0.05 }
if !mismatched.isEmpty {
return .voltage(count: mismatched.count)
}
}
if let targetCapacity = dominantValue(from: batteries.map { $0.capacityAmpHours }, scale: 1.0) {
let mismatched = batteries.filter { abs($0.capacityAmpHours - targetCapacity) > 0.5 }
if !mismatched.isEmpty {
return .capacity(count: mismatched.count)
}
}
return nil
}
private func dominantValue(from values: [Double], scale: Double) -> Double? {
guard !values.isEmpty else { return nil }
var counts: [Double: Int] = [:]
var bestKey: Double?
var bestCount = 0
for value in values {
let key = (value / scale).rounded() * scale
let newCount = (counts[key] ?? 0) + 1
counts[key] = newCount
if newCount > bestCount {
bestCount = newCount
bestKey = key
}
}
return bestKey
}
private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint)
Text(value)
.font(.body.weight(.semibold))
}
Text(label.uppercased())
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.secondary)
}
}
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 var bomCompletionFraction: Double? {
guard bomItemsCount > 0 else { return nil }
return Double(completedBOMItemCount) / Double(bomItemsCount)
}
private var completedBOMItemCount: Int {
let loadCount = settledLoads.reduce(0) { result, load in
let uniqueItems = Set(load.bomCompletedItemIDs)
let cappedCount = min(uniqueItems.count, Self.bomItemsPerLoad)
return result + cappedCount
}
let batteryCount = settledBatteries.reduce(0) { result, battery in
let uniqueItems = Set(battery.bomCompletedItemIDs)
let cappedCount = min(uniqueItems.count, Self.bomItemsPerBattery)
return result + cappedCount
}
let chargerCount = settledChargers.reduce(0) { result, charger in
let uniqueItems = Set(charger.bomCompletedItemIDs)
let cappedCount = min(uniqueItems.count, Self.bomItemsPerCharger)
return result + cappedCount
}
return loadCount + batteryCount + chargerCount
}
private var bomItemsCount: Int {
let loadItems = settledLoads.count * Self.bomItemsPerLoad
let batteryItems = settledBatteries.count * Self.bomItemsPerBattery
let chargerItems = settledChargers.count * Self.bomItemsPerCharger
let total = loadItems + batteryItems + chargerItems
return total
}
private var settledLoads: [SavedLoad] {
loads.filter { $0.crossSection > 0 && $0.length > 0 }
}
private var settledBatteries: [SavedBattery] {
batteries.filter { $0.system == system }
}
private var settledChargers: [SavedCharger] {
chargers.filter { $0.system == system }
}
private func progressBar(progress: CGFloat?, tint: Color) -> some View {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(Color(.tertiarySystemFill))
.frame(height: 4)
.frame(maxWidth: .infinity)
.overlay(alignment: .leading) {
if let progress {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(tint)
.frame(maxWidth: .infinity)
.scaleEffect(x: max(min(progress, 1), 0), y: 1, anchor: .leading)
.animation(.easeInOut(duration: 0.25), value: progress)
}
}
}
private func formattedCurrent(_ value: Double) -> String {
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
return "\(numberString) A"
}
private func formattedPower(_ value: Double) -> String {
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
return "\(numberString) W"
}
private func numberString(for value: Double) -> String {
Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value)
}
private func formattedChargerOutput(_ value: Double?) -> String {
guard let value, value > 0 else { return "" }
return formattedValue(value, unit: "V")
}
private func formattedValue(_ value: Double, unit: String) -> String {
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
return "\(numberString) \(unit)"
}
private func progressFraction(actualHours: Double?, goalHours: Double?) -> CGFloat {
guard let actualHours, let goalHours, goalHours > 0 else { return 0 }
let ratio = actualHours / goalHours
return CGFloat(max(min(ratio, 1), 0))
}
private func adjustedProgress(for explicit: Double?, actualHours: Double?, goalHours: Double?) -> CGFloat? {
guard let ratio = progressRatio(explicitProgress: explicit, actualHours: actualHours, goalHours: goalHours) else { return nil }
if explicit != nil { return ratio }
guard let actual = actualHours, let goal = goalHours, goal > 0 else { return ratio }
if goal == system.targetChargeTimeHours {
let value = goal / max(actual, 0.0001)
return CGFloat(max(min(value, 1), 0))
}
return ratio
}
private func progressRatio(explicitProgress: Double?, actualHours: Double?, goalHours: Double?) -> CGFloat? {
if let explicitProgress {
return CGFloat(max(min(explicitProgress, 1), 0))
}
guard goalHours != nil else { return nil }
return progressFraction(actualHours: actualHours, goalHours: goalHours)
}
private var estimatedRuntimeHours: Double? {
guard totalAverageLoadPower > 0, totalUsableEnergy > 0 else { return nil }
let hours = totalUsableEnergy / totalAverageLoadPower
return hours.isFinite && hours > 0 ? hours : nil
}
private var formattedRuntime: String? {
guard let hours = estimatedRuntimeHours else { return nil }
let seconds = hours * 3600
if let formatted = Self.runtimeFormatter.string(from: seconds) {
return formatted
}
let numberString = Self.numberFormatter.string(from: NSNumber(value: hours)) ?? String(format: "%.1f", hours)
return "\(numberString) h"
}
private var formattedChargeTime: String? {
guard let hours = estimatedChargeHours else { return nil }
let seconds = hours * 3600
if let formatted = Self.runtimeFormatter.string(from: seconds) {
return formatted
}
let numberString = Self.numberFormatter.string(from: NSNumber(value: hours)) ?? String(format: "%.1f", hours)
return "\(numberString) h"
}
private var estimatedChargeHours: Double? {
guard totalUsableCapacity > 0, totalChargerCurrent > 0 else { return nil }
let hours = totalUsableCapacity / totalChargerCurrent
return hours.isFinite && hours > 0 ? hours : nil
}
private func openRuntimeGoalEditor() {
runtimeGoalDraftHours = defaultGoalHours(current: system.targetRuntimeHours, fallback: estimatedRuntimeHours)
showingRuntimeGoalEditor = true
}
private func openChargeGoalEditor() {
chargeGoalDraftHours = defaultGoalHours(current: system.targetChargeTimeHours, fallback: estimatedChargeHours)
showingChargeGoalEditor = true
}
private func saveRuntimeGoal(hours: Double) {
let adjusted = clampedGoal(hours)
system.targetRuntimeHours = adjusted
runtimeGoalDraftHours = adjusted
persistSystemChanges()
showingRuntimeGoalEditor = false
}
private func saveChargeGoal(hours: Double) {
let adjusted = clampedGoal(hours)
system.targetChargeTimeHours = adjusted
chargeGoalDraftHours = adjusted
persistSystemChanges()
showingChargeGoalEditor = false
}
private func clearRuntimeGoal() {
system.targetRuntimeHours = nil
persistSystemChanges()
runtimeGoalDraftHours = defaultGoalHours(current: nil, fallback: estimatedRuntimeHours)
showingRuntimeGoalEditor = false
}
private func clearChargeGoal() {
system.targetChargeTimeHours = nil
persistSystemChanges()
chargeGoalDraftHours = defaultGoalHours(current: nil, fallback: estimatedChargeHours)
showingChargeGoalEditor = false
}
private func persistSystemChanges() {
guard modelContext.hasChanges else { return }
do {
try modelContext.save()
} catch {
#if DEBUG
print("Failed to save system goal: \(error.localizedDescription)")
#endif
}
}
private func defaultGoalHours(current: Double?, fallback: Double?) -> Double {
if let current, current >= Self.minimumGoalHours {
return clampedGoal(current)
}
if let fallback, fallback >= Self.minimumGoalHours {
return clampedGoal(fallback)
}
return Self.fallbackGoalHours
}
private func clampedGoal(_ hours: Double) -> Double {
guard hours.isFinite else { return Self.fallbackGoalHours }
let rounded = (hours / Self.goalStepHours).rounded() * Self.goalStepHours
return min(max(rounded, Self.minimumGoalHours), Self.maximumGoalHours)
}
private func formattedDuration(hours: Double) -> String {
let seconds = hours * 3600
if let formatted = Self.runtimeFormatter.string(from: seconds) {
return formatted
}
let numberString = Self.numberFormatter.string(from: NSNumber(value: hours)) ?? String(format: "%.1f", hours)
return "\(numberString) h"
}
private var loadsSummaryTitle: String {
NSLocalizedString(
"loads.overview.header.title",
bundle: .main,
value: "Load Overview",
comment: "Title for the loads overview summary section"
)
}
private var loadsCountLabel: String {
NSLocalizedString(
"loads.overview.metric.count",
bundle: .main,
value: "Loads",
comment: "Label for number of loads metric"
)
}
private var loadsCurrentLabel: String {
NSLocalizedString(
"loads.overview.metric.current",
bundle: .main,
value: "Total Current",
comment: "Label for total load current metric"
)
}
private var loadsPowerLabel: String {
NSLocalizedString(
"loads.overview.metric.power",
bundle: .main,
value: "Total Power",
comment: "Label for total load power metric"
)
}
private var loadsEmptyTitle: String {
NSLocalizedString(
"overview.loads.empty.title",
bundle: .main,
value: "No loads configured yet",
comment: "Title shown in overview when no loads exist"
)
}
private var loadsEmptySubtitle: String {
NSLocalizedString(
"overview.loads.empty.subtitle",
bundle: .main,
value: "Add components to get cable sizing and fuse recommendations tailored to this system.",
comment: "Subtitle shown in overview when no loads exist"
)
}
private var loadsEmptyMessage: String {
NSLocalizedString(
"loads.overview.empty.message",
bundle: .main,
value: "Start by adding a load to see system insights.",
comment: "Message shown when no loads exist"
)
}
private var loadsEmptyCreateAction: String {
NSLocalizedString(
"loads.overview.empty.create",
bundle: .main,
value: "Create Load",
comment: "Button title to create a new load"
)
}
private var loadsEmptyBrowseAction: String {
NSLocalizedString(
"loads.overview.empty.library",
bundle: .main,
value: "Browse Library",
comment: "Button title to open load library"
)
}
private var batterySummaryTitle: String {
NSLocalizedString(
"battery.bank.header.title",
bundle: .main,
value: "Battery Bank",
comment: "Title for the battery bank summary section"
)
}
private var batteryCountLabel: String {
NSLocalizedString(
"battery.bank.metric.count",
bundle: .main,
value: "Batteries",
comment: "Label for number of batteries metric"
)
}
private var batteryCapacityLabel: String {
NSLocalizedString(
"battery.bank.metric.capacity",
bundle: .main,
value: "Capacity",
comment: "Label for total capacity metric"
)
}
private var batteryUsableCapacityLabel: String {
NSLocalizedString(
"battery.bank.metric.usable_capacity",
bundle: .main,
value: "Usable Capacity",
comment: "Label for usable capacity metric"
)
}
private var batteryUsableEnergyLabel: String {
NSLocalizedString(
"battery.bank.metric.usable_energy",
bundle: .main,
value: "Usable Energy",
comment: "Label for usable energy metric"
)
}
private var batteryEmptyTitle: String {
NSLocalizedString(
"battery.bank.empty.title",
bundle: .main,
value: "No Batteries Yet",
comment: "Title shown when no batteries are configured"
)
}
private var batteryEmptySubtitle: String {
let format = NSLocalizedString(
"battery.bank.empty.subtitle",
tableName: nil,
bundle: .main,
value: "Tap the plus button to configure a battery for %@.",
comment: "Subtitle shown when no batteries are configured"
)
return String(format: format, system.name)
}
private var batteryEmptyCreateAction: String {
NSLocalizedString(
"battery.overview.empty.create",
bundle: .main,
value: "Create Battery",
comment: "Button title to create a new battery"
)
}
private var chargerSummaryTitle: String {
NSLocalizedString(
"overview.chargers.header.title",
bundle: .main,
value: "Charger Overview",
comment: "Title for the chargers summary section"
)
}
private var chargerCountLabel: String {
NSLocalizedString(
"chargers.summary.metric.count",
bundle: .main,
value: "Chargers",
comment: "Label for number of chargers metric"
)
}
private var chargerOutputLabel: String {
NSLocalizedString(
"chargers.summary.metric.output",
bundle: .main,
value: "Output Voltage",
comment: "Label for representative output voltage metric"
)
}
private var chargerCurrentLabel: String {
NSLocalizedString(
"chargers.summary.metric.current",
bundle: .main,
value: "Charge Rate",
comment: "Label for total charger current metric"
)
}
private var chargerPowerLabel: String {
NSLocalizedString(
"chargers.summary.metric.power",
bundle: .main,
value: "Charge Power",
comment: "Label for total charger power metric"
)
}
private var chargerEmptyTitle: String {
NSLocalizedString(
"overview.chargers.empty.title",
bundle: .main,
value: "No chargers configured yet",
comment: "Title shown when no chargers are configured"
)
}
private var chargerEmptySubtitle: String {
NSLocalizedString(
"overview.chargers.empty.subtitle",
bundle: .main,
value: "Add shore power, DC-DC, or solar chargers to understand your charging capacity.",
comment: "Subtitle shown when no chargers are configured"
)
}
private var chargerEmptyCreateAction: String {
NSLocalizedString(
"overview.chargers.empty.create",
bundle: .main,
value: "Add Charger",
comment: "Button title to create a charger from the overview"
)
}
private var systemOverviewTitle: String {
NSLocalizedString(
"overview.system.header.title",
bundle: .main,
value: "System Overview",
comment: "Title for system overview card"
)
}
private var bomTitle: String {
NSLocalizedString(
"overview.bom.title",
bundle: .main,
value: "Bill of Materials",
comment: "Title for BOM metric card in the system overview"
)
}
private var bomSubtitle: String {
NSLocalizedString(
"overview.bom.subtitle",
bundle: .main,
value: "Tap to review components",
comment: "Subtitle describing the BOM metric card interaction"
)
}
private var bomPlaceholderSummary: String {
NSLocalizedString(
"overview.bom.placeholder.short",
bundle: .main,
value: "Add loads",
comment: "Short placeholder shown when no BOM data is available"
)
}
private var formattedBOMCompletedCount: String? {
guard bomItemsCount > 0 else { return nil }
return numberString(for: Double(completedBOMItemCount))
}
private var formattedBOMTotalCount: String? {
guard bomItemsCount > 0 else { return nil }
return numberString(for: Double(bomItemsCount))
}
private var chargeTimeTitle: String {
NSLocalizedString(
"overview.chargetime.title",
bundle: .main,
value: "Estimated charge time",
comment: "Title for the charge time metric card"
)
}
private var chargeTimeSubtitle: String {
NSLocalizedString(
"overview.chargetime.subtitle",
bundle: .main,
value: "At combined charge rate",
comment: "Subtitle describing charge time assumptions"
)
}
private var chargeTimePlaceholderSummary: String {
NSLocalizedString(
"overview.chargetime.placeholder.short",
bundle: .main,
value: "Add chargers",
comment: "Short placeholder shown when charge time cannot be calculated"
)
}
private var chargeGoalValueText: String? {
formattedGoalValue(for: system.targetChargeTimeHours)
}
private var goalPrefix: String {
NSLocalizedString(
"overview.goal.prefix",
bundle: .main,
value: "Goal",
comment: "Prefix displayed before goal value"
)
}
private var runtimeTitle: String {
NSLocalizedString(
"overview.runtime.title",
bundle: .main,
value: "Estimated runtime",
comment: "Title for estimated runtime section"
)
}
private var runtimeSubtitle: String {
NSLocalizedString(
"overview.runtime.subtitle",
bundle: .main,
value: "At current load draw",
comment: "Subtitle describing runtime assumption"
)
}
private var runtimePlaceholderSummary: String {
NSLocalizedString(
"overview.runtime.placeholder.short",
bundle: .main,
value: "Add capacity",
comment: "Short placeholder shown when runtime cannot be calculated"
)
}
private var runtimeGoalValueText: String? {
formattedGoalValue(for: system.targetRuntimeHours)
}
private func formattedGoalValue(for hours: Double?) -> String? {
guard let hours else { return nil }
return formattedDuration(hours: hours)
}
private var runtimeGoalSheetTitle: String {
NSLocalizedString(
"overview.runtime.goal.title",
bundle: .main,
value: "Runtime Goal",
comment: "Navigation title for editing the runtime goal"
)
}
private var chargeGoalSheetTitle: String {
NSLocalizedString(
"overview.chargetime.goal.title",
bundle: .main,
value: "Charge Goal",
comment: "Navigation title for editing the charge time goal"
)
}
private var goalClearTitle: String {
NSLocalizedString(
"overview.goal.clear",
bundle: .main,
value: "Remove Goal",
comment: "Button title to clear an active goal"
)
}
private var goalCancelTitle: String {
NSLocalizedString(
"overview.goal.cancel",
bundle: .main,
value: "Cancel",
comment: "Button title to cancel goal editing"
)
}
private var goalSaveTitle: String {
NSLocalizedString(
"overview.goal.save",
bundle: .main,
value: "Save",
comment: "Button title to save goal editing"
)
}
private static let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = .current
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
return formatter
}()
private static let runtimeFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.day, .hour, .minute]
formatter.unitsStyle = .abbreviated
formatter.maximumUnitCount = 2
return formatter
}()
private static let minimumGoalHours: Double = 0.5
private static let maximumGoalHours: Double = 240
private static let goalStepHours: Double = 0.25
private static let fallbackGoalHours: Double = 4
private static let bomItemsPerLoad = 5
private static let bomItemsPerBattery = 1
private static let bomItemsPerCharger = 1
private enum BatteryWarning {
case voltage(count: Int)
case capacity(count: Int)
var symbol: String {
switch self {
case .voltage:
return "exclamationmark.triangle.fill"
case .capacity:
return "exclamationmark.circle.fill"
}
}
var tint: Color {
switch self {
case .voltage:
return .red
case .capacity:
return .orange
}
}
var shortLabel: String {
switch self {
case .voltage:
return NSLocalizedString(
"battery.bank.warning.voltage.short",
bundle: .main,
value: "Voltage",
comment: "Short label for voltage warning"
)
case .capacity:
return NSLocalizedString(
"battery.bank.warning.capacity.short",
bundle: .main,
value: "Capacity",
comment: "Short label for capacity warning"
)
}
}
}
}
private struct GoalEditorSheet: View {
let title: String
let tint: Color
@Binding var value: Double
let minimum: Double
let maximum: Double
let step: Double
let cancelTitle: String
let saveTitle: String
let clearTitle: String
let showsClear: Bool
let formattedDurationProvider: (Double) -> String
let onSave: (Double) -> Void
let onClear: (() -> Void)?
@State private var days: Int
@State private var hours: Int
@State private var minutes: Int
private let minuteStepValues: [Int]
@Environment(\.dismiss) private var dismiss
init(
title: String,
tint: Color,
value: Binding<Double>,
minimum: Double,
maximum: Double,
step: Double,
cancelTitle: String,
saveTitle: String,
clearTitle: String,
showsClear: Bool,
formattedDurationProvider: @escaping (Double) -> String,
onSave: @escaping (Double) -> Void,
onClear: (() -> Void)?
) {
self.title = title
self.tint = tint
self._value = value
self.minimum = minimum
self.maximum = maximum
self.step = step
self.cancelTitle = cancelTitle
self.saveTitle = saveTitle
self.clearTitle = clearTitle
self.showsClear = showsClear
self.formattedDurationProvider = formattedDurationProvider
self.onSave = onSave
self.onClear = onClear
self.minuteStepValues = GoalEditorSheet.minuteValues(forStep: step)
let initialComponents = GoalEditorSheet.components(
for: value.wrappedValue,
minimum: minimum,
maximum: maximum,
minuteValues: self.minuteStepValues
)
_days = State(initialValue: initialComponents.days)
_hours = State(initialValue: initialComponents.hours)
_minutes = State(initialValue: initialComponents.minutes)
}
var body: some View {
NavigationStack {
Form {
Section {
VStack(alignment: .leading, spacing: 16) {
Label {
Text(formattedDurationProvider(value))
.font(.title3.weight(.semibold))
} icon: {
Image(systemName: "flag.checkered")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(tint)
}
HStack {
Picker("Days", selection: $days) {
ForEach(0...maxDays, id: \.self) { day in
Text("\(day) day\(day == 1 ? "" : "s")")
.tag(day)
}
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
Picker("Hours", selection: $hours) {
ForEach(hourRange, id: \.self) { hour in
Text("\(hour) hr\(hour == 1 ? "" : "s")")
.tag(hour)
}
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
Picker("Minutes", selection: $minutes) {
ForEach(minuteOptionsForSelection, id: \.self) { minute in
Text("\(minute) min\(minute == 1 ? "" : "s")")
.tag(minute)
}
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
}
.frame(height: 140)
}
}
if showsClear, let onClear {
Section {
Button(role: .destructive) {
onClear()
dismiss()
} label: {
Text(clearTitle)
}
}
}
}
.tint(tint)
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(cancelTitle) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(saveTitle) {
onSave(clampedValue)
dismiss()
}
.disabled(!value.isFinite || value < minimum)
}
}
}
.onChange(of: days) { _ in
let cappedHours = min(hours, maxHours(for: days))
if cappedHours != hours {
hours = cappedHours
}
let allowedMinutes = minuteOptions(for: days, hours: hours)
if !allowedMinutes.contains(minutes) {
minutes = allowedMinutes.last ?? 0
}
updateValueFromSelection()
}
.onChange(of: hours) { _ in
let allowedMinutes = minuteOptions(for: days, hours: hours)
if !allowedMinutes.contains(minutes) {
minutes = allowedMinutes.last ?? 0
}
updateValueFromSelection()
}
.onChange(of: minutes) { _ in
updateValueFromSelection()
}
.onChange(of: value) { newValue in
syncPickers(with: newValue)
}
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
private var maxDays: Int {
max(0, Int(floor(maximum / 24)))
}
private var hourRange: ClosedRange<Int> {
0...maxHours(for: days)
}
private var minuteOptionsForSelection: [Int] {
minuteOptions(for: days, hours: hours)
}
private var clampedValue: Double {
clamp(value)
}
private func maxHours(for days: Int) -> Int {
GoalEditorSheet.maxHours(forDays: days, maximum: maximum)
}
private func minuteOptions(for days: Int, hours: Int) -> [Int] {
GoalEditorSheet.allowedMinutes(
forDays: days,
hours: hours,
maximum: maximum,
minuteValues: minuteStepValues
)
}
private func updateValueFromSelection() {
let totalHours = Double(days * 24 + hours) + Double(minutes) / 60
let clamped = clamp(totalHours)
if abs(value - clamped) > .ulpOfOne {
value = clamped
}
}
private func syncPickers(with newValue: Double) {
let clamped = clamp(newValue)
let components = GoalEditorSheet.components(
for: clamped,
minimum: minimum,
maximum: maximum,
minuteValues: minuteStepValues
)
if components.days != days {
days = components.days
}
if components.hours != hours {
hours = components.hours
}
if components.minutes != minutes {
minutes = components.minutes
}
}
private func clamp(_ candidate: Double) -> Double {
min(max(candidate, minimum), maximum)
}
private static func components(
for value: Double,
minimum: Double,
maximum: Double,
minuteValues: [Int]
) -> (days: Int, hours: Int, minutes: Int) {
let clamped = min(max(value, minimum), maximum)
let totalMinutes = Int((clamped * 60).rounded())
let minutesPerDay = 24 * 60
let maxDays = max(0, Int(floor(maximum / 24)))
let rawDays = totalMinutes / minutesPerDay
let days = min(rawDays, maxDays)
let remainingAfterDays = totalMinutes - (days * minutesPerDay)
let rawHours = remainingAfterDays / 60
let maxHours = maxHours(forDays: days, maximum: maximum)
let hours = min(rawHours, maxHours)
let minuteRemainder = remainingAfterDays - hours * 60
let allowedMinutes = allowedMinutes(
forDays: days,
hours: hours,
maximum: maximum,
minuteValues: minuteValues
)
let minutes = closestMinuteValue(
target: minuteRemainder,
allowedMinutes: allowedMinutes
)
return (days, hours, minutes)
}
private static func maxHours(forDays days: Int, maximum: Double) -> Int {
let remaining = maximum - Double(days * 24)
guard remaining > 0 else { return 0 }
return min(23, max(0, Int(floor(remaining))))
}
private static func allowedMinutes(
forDays days: Int,
hours: Int,
maximum: Double,
minuteValues: [Int]
) -> [Int] {
guard maximum > 0 else { return [0] }
let usedHours = Double(days * 24 + hours)
let remaining = maximum - usedHours
guard remaining > 0 else { return [0] }
let allowed = minuteValues.filter { minute in
let additional = Double(minute) / 60
return usedHours + additional <= maximum + 1e-6
}
return allowed.isEmpty ? [0] : allowed
}
private static func closestMinuteValue(target: Int, allowedMinutes: [Int]) -> Int {
guard !allowedMinutes.isEmpty else { return 0 }
let clampedTarget = max(0, min(59, target))
return allowedMinutes.min(by: { abs($0 - clampedTarget) < abs($1 - clampedTarget) }) ?? 0
}
private static func minuteValues(forStep step: Double) -> [Int] {
guard step > 0 else { return [0, 15, 30, 45] }
let increment = max(1, Int(round(step * 60)))
guard increment < 60 else { return [0] }
var values: [Int] = []
var current = 0
while current < 60 {
values.append(current)
current += increment
}
return values.isEmpty ? [0] : values
}
}
#Preview("SystemOverview Populated") {
let system = ElectricalSystem(
name: "12V DC System",
location: "Engine Room",
iconName: "bolt.circle.fill",
colorName: "blue",
targetRuntimeHours: 24,
targetChargeTimeHours: 3
)
//system.targetRuntimeHours = 8
//system.targetChargeTimeHours = 6
let loads: [SavedLoad] = [
SavedLoad(
name: "Navigation Lights",
voltage: 12.8,
current: 2.4,
power: 28.8,
length: 5.0,
crossSection: 2.5
),
SavedLoad(
name: "Bilge Pump",
voltage: 12.8,
current: 8.0,
power: 96.0,
length: 3.0,
crossSection: 4.0
),
SavedLoad(
name: "Chartplotter",
voltage: 12.8,
current: 1.5,
power: 18.0,
length: 2.0,
crossSection: 1.5
)
]
let batteries: [SavedBattery] = [
SavedBattery(
name: "House AGM",
nominalVoltage: 12.0,
capacityAmpHours: 100.0
),
SavedBattery(
name: "Starter AGM",
nominalVoltage: 12.0,
capacityAmpHours: 100.0
)
]
let chargers: [SavedCharger] = [
SavedCharger(
name: "Shore Power",
inputVoltage: 230,
outputVoltage: 14.4,
maxCurrentAmps: 40,
maxPowerWatts: 580,
iconName: "powerplug",
colorName: "orange",
system: system
),
SavedCharger(
name: "DC-DC Charger",
inputVoltage: 12.8,
outputVoltage: 14.2,
maxCurrentAmps: 30,
maxPowerWatts: 0,
iconName: "bolt.circle",
colorName: "blue",
system: system
)
]
SystemOverviewView(
system: system,
loads: loads,
batteries: batteries,
chargers: chargers,
onSelectLoads: {},
onSelectBatteries: {},
onSelectChargers: {},
onCreateLoad: {},
onBrowseLibrary: {},
onShowBillOfMaterials: {},
onCreateBattery: {},
onCreateCharger: {}
)
.padding()
.modelContainer(for: [ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self], inMemory: true)
}
#Preview("SystemOverview Empty States") {
let system = ElectricalSystem(
name: "24V DC System",
location: "Main Panel",
iconName: "bolt.circle.fill",
colorName: "green"
)
return SystemOverviewView(
system: system,
loads: [],
batteries: [],
chargers: [],
onSelectLoads: {},
onSelectBatteries: {},
onSelectChargers: {},
onCreateLoad: {},
onBrowseLibrary: {},
onShowBillOfMaterials: {},
onCreateBattery: {},
onCreateCharger: {}
)
.padding()
.modelContainer(for: [ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self], inMemory: true)
}