Files
Cable/Cable/Overview/SystemOverviewView.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

1631 lines
58 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(
String(
localized: "battery.bank.status.dismiss",
defaultValue: "Got it"
)
)
)
)
}
}
}
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 {
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 loadsEmptyTitle: String {
String(localized: "overview.loads.empty.title", defaultValue: "No loads configured yet")
}
private var loadsEmptySubtitle: String {
String(localized: "overview.loads.empty.subtitle", defaultValue: "Add components to get cable sizing and fuse recommendations tailored to this system.")
}
private var loadsEmptyMessage: String {
String(localized: "loads.overview.empty.message", defaultValue: "Start by adding a load to see system insights.")
}
private var loadsEmptyCreateAction: String {
String(localized: "loads.overview.empty.create", defaultValue: "Add Load")
}
private var loadsEmptyBrowseAction: String {
String(localized: "loads.overview.empty.library", defaultValue: "Browse Library")
}
private var batterySummaryTitle: String {
String(localized: "battery.bank.header.title", defaultValue: "Battery Bank")
}
private var batteryCountLabel: String {
String(localized: "battery.bank.metric.count", defaultValue: "Batteries")
}
private var batteryCapacityLabel: String {
String(localized: "battery.bank.metric.capacity", defaultValue: "Capacity")
}
private var batteryUsableCapacityLabel: String {
String(localized: "battery.bank.metric.usable_capacity", defaultValue: "Usable Capacity")
}
private var batteryUsableEnergyLabel: String {
String(localized: "battery.bank.metric.usable_energy", defaultValue: "Usable Energy")
}
private var batteryEmptyTitle: String {
String(localized: "battery.bank.empty.title", defaultValue: "No Batteries Yet")
}
private var batteryEmptySubtitle: String {
let format = String(
localized: "battery.bank.empty.subtitle",
defaultValue: "Tap the plus button to configure a battery for %@."
)
return String(format: format, system.name)
}
private var batteryEmptyCreateAction: String {
String(localized: "battery.overview.empty.create", defaultValue: "Add Battery")
}
private var chargerSummaryTitle: String {
String(localized: "overview.chargers.header.title", defaultValue: "Charger Overview")
}
private var chargerCountLabel: String {
String(localized: "chargers.summary.metric.count", defaultValue: "Chargers")
}
private var chargerOutputLabel: String {
String(localized: "chargers.summary.metric.output", defaultValue: "Output Voltage")
}
private var chargerCurrentLabel: String {
String(localized: "chargers.summary.metric.current", defaultValue: "Charge Rate")
}
private var chargerPowerLabel: String {
String(localized: "chargers.summary.metric.power", defaultValue: "Charge Power")
}
private var chargerEmptyTitle: String {
String(localized: "overview.chargers.empty.title", defaultValue: "No chargers configured yet")
}
private var chargerEmptySubtitle: String {
String(localized: "overview.chargers.empty.subtitle", defaultValue: "Add shore power, DC-DC, or solar chargers to understand your charging capacity.")
}
private var chargerEmptyCreateAction: String {
String(localized: "overview.chargers.empty.create", defaultValue: "Add Charger")
}
private var systemOverviewTitle: String {
String(localized: "overview.system.header.title", defaultValue: "System Overview")
}
private var bomTitle: String {
String(localized: "overview.bom.title", defaultValue: "Bill of Materials")
}
private var bomSubtitle: String {
String(localized: "overview.bom.subtitle", defaultValue: "Tap to review components")
}
private var bomPlaceholderSummary: String {
String(localized: "overview.bom.placeholder.short", defaultValue: "Add loads")
}
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 {
String(localized: "overview.chargetime.title", defaultValue: "Estimated charge time")
}
private var chargeTimeSubtitle: String {
String(localized: "overview.chargetime.subtitle", defaultValue: "At combined charge rate")
}
private var chargeTimePlaceholderSummary: String {
String(localized: "overview.chargetime.placeholder.short", defaultValue: "Add chargers")
}
private var chargeGoalValueText: String? {
formattedGoalValue(for: system.targetChargeTimeHours)
}
private var goalPrefix: String {
String(localized: "overview.goal.prefix", defaultValue: "Goal")
}
private var runtimeTitle: String {
String(localized: "overview.runtime.title", defaultValue: "Estimated runtime")
}
private var runtimeSubtitle: String {
String(localized: "overview.runtime.subtitle", defaultValue: "At maximum load draw")
}
private var runtimePlaceholderSummary: String {
String(localized: "overview.runtime.placeholder.short", defaultValue: "Add capacity")
}
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 {
String(localized: "overview.runtime.goal.title", defaultValue: "Runtime Goal")
}
private var chargeGoalSheetTitle: String {
String(localized: "overview.chargetime.goal.title", defaultValue: "Charge Goal")
}
private var goalClearTitle: String {
String(localized: "overview.goal.clear", defaultValue: "Remove Goal")
}
private var goalCancelTitle: String {
String(localized: "overview.goal.cancel", defaultValue: "Cancel")
}
private var goalSaveTitle: String {
String(localized: "overview.goal.save", defaultValue: "Save")
}
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 String(localized: "battery.bank.warning.voltage.short", defaultValue: "Voltage")
case .capacity:
return String(localized: "battery.bank.warning.capacity.short", defaultValue: "Capacity")
}
}
}
}
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)
}