import SwiftUI struct SystemOverviewView: View { @Environment(\.dismiss) private var dismiss @State private var activeStatus: LoadConfigurationStatus? @State private var suppressLoadNavigation = false let system: ElectricalSystem let loads: [SavedLoad] let batteries: [SavedBattery] let onSelectLoads: () -> Void let onSelectBatteries: () -> Void let onCreateLoad: () -> Void let onBrowseLibrary: () -> Void let onCreateBattery: () -> Void var body: some View { ScrollView { VStack(spacing: 16) { systemCard loadsCard batteriesCard } .padding(.horizontal, 16) .padding(.vertical, 20) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) } private var systemCard: some View { VStack(alignment: .leading, spacing: 16) { Text(systemOverviewTitle) .font(.headline.weight(.semibold)) HStack(spacing: 14) { ZStack { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(colorForName(system.colorName)) .frame(width: 54, height: 54) Image(systemName: system.iconName) .font(.system(size: 24, weight: .semibold)) .foregroundStyle(Color.white) } VStack(alignment: .leading, spacing: 6) { Text(system.name) .font(.title3.weight(.semibold)) .lineLimit(2) .multilineTextAlignment(.leading) if !system.location.isEmpty { Label(system.location, systemImage: "mappin.and.ellipse") .font(.subheadline) .foregroundStyle(.secondary) .labelStyle(.titleAndIcon) } } Spacer() } ViewThatFits(in: .horizontal) { HStack(spacing: 16) { summaryMetric( icon: "square.stack.3d.up", label: loadsCountLabel, value: "\(loads.count)", tint: .blue ) summaryMetric( icon: "battery.100", label: batteryCountLabel, value: "\(batteries.count)", tint: .green ) } VStack(alignment: .leading, spacing: 12) { summaryMetric( icon: "square.stack.3d.up", label: loadsCountLabel, value: "\(loads.count)", tint: .blue ) summaryMetric( icon: "battery.100", label: batteryCountLabel, value: "\(batteries.count)", tint: .green ) } } runtimeSection } .padding(.horizontal, 16) .padding(.vertical, 18) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(Color(.systemBackground)) ) } @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(.systemBackground)) ) } 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 ) 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: "\(loads.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 { 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(.systemBackground)) ) } .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) { 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: "bolt.circle", label: batteryEnergyLabel, value: formattedValue(totalEnergy, unit: "Wh"), tint: .green ) } VStack(alignment: .leading, spacing: 12) { 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: "bolt.circle", label: batteryEnergyLabel, value: formattedValue(totalEnergy, unit: "Wh"), tint: .green ) } } } } .padding(.horizontal, 16) .padding(.vertical, 18) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(Color(.systemBackground)) ) } .buttonStyle(.plain) } 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 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 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) } } @ViewBuilder private var runtimeSection: some View { if let runtimeText = formattedRuntime { HStack(alignment: .center, spacing: 12) { Image(systemName: "clock.arrow.circlepath") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(Color.orange) VStack(alignment: .leading, spacing: 4) { Text(runtimeTitle) .font(.subheadline.weight(.semibold)) Text(runtimeSubtitle) .font(.caption) .foregroundStyle(.secondary) } Spacer() Text(runtimeText) .font(.title3.weight(.semibold)) } .padding(.horizontal, 14) .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.orange.opacity(0.12)) ) } else if shouldShowRuntimeHint { Text(runtimeUnavailableText) .font(.footnote) .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 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 formattedValue(_ value: Double, unit: String) -> String { let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value) return "\(numberString) \(unit)" } private var estimatedRuntimeHours: Double? { guard totalPower > 0, totalEnergy > 0 else { return nil } let hours = totalEnergy / totalPower 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 shouldShowRuntimeHint: Bool { !batteries.isEmpty && !loads.isEmpty && formattedRuntime == nil } private func colorForName(_ colorName: String) -> Color { switch colorName { case "blue": return .blue case "green": return .green case "orange": return .orange case "red": return .red case "purple": return .purple case "yellow": return .yellow case "pink": return .pink case "teal": return .teal case "indigo": return .indigo case "mint": return .mint case "cyan": return .cyan case "brown": return .brown case "gray": return .gray default: return .blue } } 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 batteryEnergyLabel: String { NSLocalizedString( "battery.bank.metric.energy", bundle: .main, value: "Energy", comment: "Label for total 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 systemOverviewTitle: String { NSLocalizedString( "overview.system.header.title", bundle: .main, value: "System Overview", comment: "Title for system overview card" ) } 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 runtimeUnavailableText: String { NSLocalizedString( "overview.runtime.unavailable", bundle: .main, value: "Add battery capacity and load power to estimate runtime.", comment: "Message shown when runtime cannot be calculated" ) } 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 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" ) } } } } #Preview("SystemOverview – Populated") { let system = ElectricalSystem( name: "12V DC System", location: "Engine Room", iconName: "bolt.circle.fill", colorName: "blue" ) 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 ) ] SystemOverviewView( system: system, loads: loads, batteries: batteries, onSelectLoads: {}, onSelectBatteries: {}, onCreateLoad: {}, onBrowseLibrary: {}, onCreateBattery: {} ) .padding() } #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: [], onSelectLoads: {}, onSelectBatteries: {}, onCreateLoad: {}, onBrowseLibrary: {}, onCreateBattery: {} ) .padding() }