Files
Cable/Cable/Overview/SystemOverviewView.swift
Stefan Lange-Hegermann 6258a6a66f more consitancy
2025-10-22 22:43:03 +02:00

857 lines
29 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
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()
}