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