some fixes
This commit is contained in:
@@ -68,6 +68,7 @@
|
||||
"system.icon.keywords.cold" = "cold, freeze, cool";
|
||||
"system.icon.keywords.climate" = "climate, hvac, temperature";
|
||||
|
||||
"tab.overview" = "Overview";
|
||||
"tab.components" = "Components";
|
||||
"tab.batteries" = "Batteries";
|
||||
"tab.chargers" = "Chargers";
|
||||
@@ -84,6 +85,14 @@
|
||||
"loads.metric.fuse" = "Fuse";
|
||||
"loads.metric.cable" = "Cable";
|
||||
"loads.metric.length" = "Length";
|
||||
"overview.system.header.title" = "System Overview";
|
||||
"overview.loads.empty.title" = "No loads configured yet";
|
||||
"overview.loads.empty.subtitle" = "Add components to get cable sizing and fuse recommendations tailored to this system.";
|
||||
"overview.runtime.title" = "Estimated runtime";
|
||||
"overview.runtime.subtitle" = "At current load draw";
|
||||
"overview.runtime.unavailable" = "Add battery capacity and load power to estimate runtime.";
|
||||
"battery.bank.warning.voltage.short" = "Voltage";
|
||||
"battery.bank.warning.capacity.short" = "Capacity";
|
||||
|
||||
"battery.bank.header.title" = "Battery Bank";
|
||||
"battery.bank.metric.count" = "Batteries";
|
||||
|
||||
@@ -20,9 +20,9 @@ struct LoadsView: View {
|
||||
@State private var hasOpenedLoadOnAppear = false
|
||||
@State private var showingComponentLibrary = false
|
||||
@State private var showingSystemBOM = false
|
||||
@State private var selectedComponentTab: ComponentTab = .components
|
||||
@State private var selectedComponentTab: ComponentTab = .overview
|
||||
@State private var batteryDraft: BatteryConfiguration?
|
||||
@State private var activeStatus: LoadStatus?
|
||||
@State private var activeStatus: LoadConfigurationStatus?
|
||||
@State private var editMode: EditMode = .inactive
|
||||
|
||||
let system: ElectricalSystem
|
||||
@@ -45,54 +45,62 @@ struct LoadsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if savedLoads.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
TabView(selection: $selectedComponentTab) {
|
||||
componentsTab
|
||||
.tag(ComponentTab.components)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.components",
|
||||
bundle: .main,
|
||||
comment: "Tab title for components list"
|
||||
),
|
||||
systemImage: "square.stack.3d.up"
|
||||
)
|
||||
}
|
||||
BatteriesView(
|
||||
system: system,
|
||||
batteries: savedBatteries,
|
||||
editMode: $editMode,
|
||||
onEdit: { editBattery($0) },
|
||||
onDelete: deleteBatteries
|
||||
)
|
||||
.environment(\.editMode, $editMode)
|
||||
.tag(ComponentTab.batteries)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.batteries",
|
||||
bundle: .main,
|
||||
comment: "Tab title for battery configurations"
|
||||
),
|
||||
systemImage: "battery.100"
|
||||
)
|
||||
}
|
||||
ChargersView(system: system)
|
||||
.tag(ComponentTab.chargers)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.chargers",
|
||||
bundle: .main,
|
||||
comment: "Tab title for chargers view"
|
||||
),
|
||||
systemImage: "bolt.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
TabView(selection: $selectedComponentTab) {
|
||||
overviewTab
|
||||
.tag(ComponentTab.overview)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.overview",
|
||||
bundle: .main,
|
||||
comment: "Tab title for system overview"
|
||||
),
|
||||
systemImage: "rectangle.3.group"
|
||||
)
|
||||
}
|
||||
componentsTab
|
||||
.tag(ComponentTab.components)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.components",
|
||||
bundle: .main,
|
||||
comment: "Tab title for components list"
|
||||
),
|
||||
systemImage: "square.stack.3d.up"
|
||||
)
|
||||
}
|
||||
BatteriesView(
|
||||
system: system,
|
||||
batteries: savedBatteries,
|
||||
editMode: $editMode,
|
||||
onEdit: { editBattery($0) },
|
||||
onDelete: deleteBatteries
|
||||
)
|
||||
.environment(\.editMode, $editMode)
|
||||
.tag(ComponentTab.batteries)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.batteries",
|
||||
bundle: .main,
|
||||
comment: "Tab title for battery configurations"
|
||||
),
|
||||
systemImage: "battery.100"
|
||||
)
|
||||
}
|
||||
ChargersView(system: system)
|
||||
.tag(ComponentTab.chargers)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.chargers",
|
||||
bundle: .main,
|
||||
comment: "Tab title for chargers view"
|
||||
),
|
||||
systemImage: "bolt.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -130,7 +138,7 @@ struct LoadsView: View {
|
||||
}
|
||||
.accessibilityIdentifier("component-library-button")
|
||||
}
|
||||
if !savedLoads.isEmpty && selectedComponentTab == .components {
|
||||
if !savedLoads.isEmpty && (selectedComponentTab == .components || selectedComponentTab == .overview) {
|
||||
Button(action: {
|
||||
showingSystemBOM = true
|
||||
}) {
|
||||
@@ -204,7 +212,7 @@ struct LoadsView: View {
|
||||
)
|
||||
}
|
||||
.alert(item: $activeStatus) { status in
|
||||
let detail = detailInfo(for: status)
|
||||
let detail = status.detailInfo()
|
||||
return Alert(
|
||||
title: Text(detail.title),
|
||||
message: Text(detail.message),
|
||||
@@ -235,14 +243,25 @@ struct LoadsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedComponentTab) { oldValue, newValue in
|
||||
if newValue == .chargers {
|
||||
.onChange(of: selectedComponentTab) { _, newValue in
|
||||
if newValue == .chargers || newValue == .overview {
|
||||
editMode = .inactive
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
|
||||
private var overviewTab: some View {
|
||||
SystemOverviewView(
|
||||
system: system,
|
||||
loads: savedLoads,
|
||||
batteries: savedBatteries,
|
||||
onSelectLoads: { selectedComponentTab = .components },
|
||||
onSelectBatteries: { selectedComponentTab = .batteries }
|
||||
)
|
||||
.accessibilityIdentifier("system-overview")
|
||||
}
|
||||
|
||||
private var summarySection: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
@@ -318,25 +337,37 @@ struct LoadsView: View {
|
||||
VStack(spacing: 0) {
|
||||
summarySection
|
||||
|
||||
List {
|
||||
ForEach(savedLoads) { load in
|
||||
Button {
|
||||
selectLoad(load)
|
||||
} label: {
|
||||
loadRow(for: load)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(editMode == .active)
|
||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
if savedLoads.isEmpty {
|
||||
ScrollView {
|
||||
ComponentsOnboardingView(
|
||||
onCreate: { createNewLoad() },
|
||||
onBrowse: { showingComponentLibrary = true }
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 32)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.onDelete(perform: deleteLoads)
|
||||
} else {
|
||||
List {
|
||||
ForEach(savedLoads) { load in
|
||||
Button {
|
||||
selectLoad(load)
|
||||
} label: {
|
||||
loadRow(for: load)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(editMode == .active)
|
||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.onDelete(perform: deleteLoads)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.accessibilityIdentifier("loads-list")
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.accessibilityIdentifier("loads-list")
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
@@ -548,7 +579,7 @@ struct LoadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var loadStatus: LoadStatus? {
|
||||
private var loadStatus: LoadConfigurationStatus? {
|
||||
guard !savedLoads.isEmpty else { return nil }
|
||||
let incompleteLoads = savedLoads.filter { load in
|
||||
load.length <= 0 || load.crossSection <= 0
|
||||
@@ -575,7 +606,7 @@ struct LoadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func statusBanner(for status: LoadStatus) -> some View {
|
||||
private func statusBanner(for status: LoadConfigurationStatus) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: status.symbol)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
@@ -603,39 +634,6 @@ struct LoadsView: View {
|
||||
return "\(numberString) W"
|
||||
}
|
||||
|
||||
private func detailInfo(for status: LoadStatus) -> LoadStatusDetail {
|
||||
switch status {
|
||||
case .missingDetails(let count):
|
||||
let title = NSLocalizedString(
|
||||
"loads.overview.status.missing_details.title",
|
||||
bundle: .main,
|
||||
value: "Missing load details",
|
||||
comment: "Alert title when loads are missing required details"
|
||||
)
|
||||
let format = NSLocalizedString(
|
||||
"loads.overview.status.missing_details.message",
|
||||
bundle: .main,
|
||||
value: "Enter cable length and wire size for %d %@ to see accurate recommendations.",
|
||||
comment: "Alert message when loads are missing required details"
|
||||
)
|
||||
let loadWord = count == 1
|
||||
? NSLocalizedString(
|
||||
"loads.overview.status.missing_details.singular",
|
||||
bundle: .main,
|
||||
value: "load",
|
||||
comment: "Singular noun for load"
|
||||
)
|
||||
: NSLocalizedString(
|
||||
"loads.overview.status.missing_details.plural",
|
||||
bundle: .main,
|
||||
value: "loads",
|
||||
comment: "Plural noun for loads"
|
||||
)
|
||||
let message = String(format: format, count, loadWord)
|
||||
return LoadStatusDetail(title: title, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private static let numberFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.locale = .current
|
||||
@@ -644,48 +642,6 @@ struct LoadsView: View {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private enum LoadStatus: Identifiable {
|
||||
case missingDetails(count: Int)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .missingDetails(let count):
|
||||
return "missing-details-\(count)"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .missingDetails:
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var tint: Color {
|
||||
switch self {
|
||||
case .missingDetails:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
|
||||
var bannerText: String {
|
||||
switch self {
|
||||
case .missingDetails:
|
||||
return NSLocalizedString(
|
||||
"loads.overview.status.missing_details.banner",
|
||||
bundle: .main,
|
||||
value: "Finish configuring your loads",
|
||||
comment: "Short banner text describing loads that need additional details"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LoadStatusDetail {
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
private func metricBadge(label: String, value: String, tint: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label.uppercased())
|
||||
@@ -704,13 +660,6 @@ struct LoadsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
ComponentsOnboardingView(
|
||||
onCreate: { createNewLoad() },
|
||||
onBrowse: { showingComponentLibrary = true }
|
||||
)
|
||||
}
|
||||
|
||||
private func deleteLoads(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
@@ -721,6 +670,8 @@ struct LoadsView: View {
|
||||
|
||||
private func handlePrimaryAction() {
|
||||
switch selectedComponentTab {
|
||||
case .overview:
|
||||
createNewLoad()
|
||||
case .components:
|
||||
createNewLoad()
|
||||
case .batteries:
|
||||
@@ -822,9 +773,9 @@ struct LoadsView: View {
|
||||
}
|
||||
|
||||
private enum ComponentTab: Hashable {
|
||||
case overview
|
||||
case components
|
||||
case batteries
|
||||
case chargers
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
692
Cable/SystemOverviewView.swift
Normal file
692
Cable/SystemOverviewView.swift
Normal file
@@ -0,0 +1,692 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SystemOverviewView: View {
|
||||
@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
|
||||
|
||||
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))
|
||||
)
|
||||
}
|
||||
|
||||
private var loadsCard: some View {
|
||||
Button {
|
||||
if suppressLoadNavigation {
|
||||
suppressLoadNavigation = false
|
||||
return
|
||||
}
|
||||
onSelectLoads()
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(loadsSummaryTitle)
|
||||
.font(.headline.weight(.semibold))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if loads.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(loadsEmptyTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(loadsEmptySubtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
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: 8) {
|
||||
Text(batteryEmptyTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(batteryEmptySubtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} 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 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 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,7 @@
|
||||
"VoltPlan Library" = "VoltPlan-Bibliothek";
|
||||
"New Load" = "Neuer Verbraucher";
|
||||
|
||||
"tab.overview" = "Übersicht";
|
||||
"tab.components" = "Verbraucher";
|
||||
"tab.batteries" = "Batterien";
|
||||
"tab.chargers" = "Ladegeräte";
|
||||
@@ -152,6 +153,14 @@
|
||||
"loads.metric.fuse" = "Sicherung";
|
||||
"loads.metric.cable" = "Schnitt";
|
||||
"loads.metric.length" = "Länge";
|
||||
"overview.system.header.title" = "Systemübersicht";
|
||||
"overview.loads.empty.title" = "Noch keine Verbraucher eingerichtet";
|
||||
"overview.loads.empty.subtitle" = "Füge Verbraucher hinzu, um auf dieses System zugeschnittene Kabel- und Sicherungsempfehlungen zu erhalten.";
|
||||
"overview.runtime.title" = "Geschätzte Laufzeit";
|
||||
"overview.runtime.subtitle" = "Bei aktueller Last";
|
||||
"overview.runtime.unavailable" = "Trage Batteriekapazität und Leistungsaufnahme ein, um die Laufzeit zu schätzen.";
|
||||
"battery.bank.warning.voltage.short" = "Spannung";
|
||||
"battery.bank.warning.capacity.short" = "Kapazität";
|
||||
|
||||
"battery.bank.header.title" = "Batteriebank";
|
||||
"battery.bank.metric.count" = "Batterien";
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
"VoltPlan Library" = "Biblioteca de VoltPlan";
|
||||
"New Load" = "Carga nueva";
|
||||
|
||||
"tab.overview" = "Resumen";
|
||||
"tab.components" = "Componentes";
|
||||
"tab.batteries" = "Baterías";
|
||||
"tab.chargers" = "Cargadores";
|
||||
@@ -151,6 +152,14 @@
|
||||
"loads.metric.fuse" = "Fusible";
|
||||
"loads.metric.cable" = "Cable";
|
||||
"loads.metric.length" = "Longitud";
|
||||
"overview.system.header.title" = "Resumen del sistema";
|
||||
"overview.loads.empty.title" = "Aún no hay cargas configuradas";
|
||||
"overview.loads.empty.subtitle" = "Añade cargas para obtener recomendaciones de cables y fusibles adaptadas a este sistema.";
|
||||
"overview.runtime.title" = "Autonomía estimada";
|
||||
"overview.runtime.subtitle" = "Con la carga actual";
|
||||
"overview.runtime.unavailable" = "Añade capacidad de batería y potencia de carga para estimar la autonomía.";
|
||||
"battery.bank.warning.voltage.short" = "Voltaje";
|
||||
"battery.bank.warning.capacity.short" = "Capacidad";
|
||||
|
||||
"battery.bank.header.title" = "Banco de baterías";
|
||||
"battery.bank.metric.count" = "Baterías";
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
"VoltPlan Library" = "Bibliothèque VoltPlan";
|
||||
"New Load" = "Nouvelle charge";
|
||||
|
||||
"tab.overview" = "Aperçu";
|
||||
"tab.components" = "Composants";
|
||||
"tab.batteries" = "Batteries";
|
||||
"tab.chargers" = "Chargeurs";
|
||||
@@ -151,6 +152,14 @@
|
||||
"loads.metric.fuse" = "Fusible";
|
||||
"loads.metric.cable" = "Câble";
|
||||
"loads.metric.length" = "Longueur";
|
||||
"overview.system.header.title" = "Aperçu du système";
|
||||
"overview.loads.empty.title" = "Aucune charge configurée pour l'instant";
|
||||
"overview.loads.empty.subtitle" = "Ajoutez des charges pour obtenir des recommandations de câbles et de fusibles adaptées à ce système.";
|
||||
"overview.runtime.title" = "Autonomie estimée";
|
||||
"overview.runtime.subtitle" = "Avec la charge actuelle";
|
||||
"overview.runtime.unavailable" = "Ajoutez la capacité de la batterie et la puissance consommée pour estimer l’autonomie.";
|
||||
"battery.bank.warning.voltage.short" = "Tension";
|
||||
"battery.bank.warning.capacity.short" = "Capacité";
|
||||
|
||||
"battery.bank.header.title" = "Banque de batteries";
|
||||
"battery.bank.metric.count" = "Batteries";
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
"VoltPlan Library" = "VoltPlan-bibliotheek";
|
||||
"New Load" = "Nieuwe last";
|
||||
|
||||
"tab.overview" = "Overzicht";
|
||||
"tab.components" = "Componenten";
|
||||
"tab.batteries" = "Batterijen";
|
||||
"tab.chargers" = "Laders";
|
||||
@@ -151,6 +152,14 @@
|
||||
"loads.metric.fuse" = "Zekering";
|
||||
"loads.metric.cable" = "Kabel";
|
||||
"loads.metric.length" = "Lengte";
|
||||
"overview.system.header.title" = "Systeemoverzicht";
|
||||
"overview.loads.empty.title" = "Nog geen lasten geconfigureerd";
|
||||
"overview.loads.empty.subtitle" = "Voeg lasten toe om kabel- en zekeringsadviezen te krijgen die zijn afgestemd op dit systeem.";
|
||||
"overview.runtime.title" = "Geschatte looptijd";
|
||||
"overview.runtime.subtitle" = "Bij huidige belasting";
|
||||
"overview.runtime.unavailable" = "Voeg batterijcapaciteit en vermogensvraag toe om de looptijd te berekenen.";
|
||||
"battery.bank.warning.voltage.short" = "Spanning";
|
||||
"battery.bank.warning.capacity.short" = "Capaciteit";
|
||||
|
||||
"battery.bank.header.title" = "Accubank";
|
||||
"battery.bank.metric.count" = "Batterijen";
|
||||
|
||||
Reference in New Issue
Block a user