From 2a2c48e89faa9f867d475d93912ef965072bbd54 Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Tue, 21 Oct 2025 11:43:56 +0200 Subject: [PATCH] loads info bar above list --- Cable/Base.lproj/Localizable.strings | 13 + Cable/BatteriesView.swift | 3 - Cable/LoadsView.swift | 353 ++++++++++++++++++++++++--- Cable/de.lproj/Localizable.strings | 13 + Cable/es.lproj/Localizable.strings | 13 + Cable/fr.lproj/Localizable.strings | 13 + Cable/nl.lproj/Localizable.strings | 13 + 7 files changed, 385 insertions(+), 36 deletions(-) diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index d32677a..70cd9b4 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -72,6 +72,19 @@ "tab.batteries" = "Batteries"; "tab.chargers" = "Chargers"; +"loads.overview.header.title" = "Load Overview"; +"loads.overview.metric.count" = "Loads"; +"loads.overview.metric.current" = "Total Current"; +"loads.overview.metric.power" = "Total Power"; +"loads.overview.status.missing_details.title" = "Missing load details"; +"loads.overview.status.missing_details.message" = "Enter cable length and wire size for %d %@ to see accurate recommendations."; +"loads.overview.status.missing_details.singular" = "load"; +"loads.overview.status.missing_details.plural" = "loads"; +"loads.overview.status.missing_details.banner" = "Finish configuring your loads"; +"loads.metric.fuse" = "Fuse"; +"loads.metric.cable" = "Cable"; +"loads.metric.length" = "Length"; + "battery.bank.header.title" = "Battery Bank"; "battery.bank.metric.count" = "Batteries"; "battery.bank.metric.capacity" = "Capacity"; diff --git a/Cable/BatteriesView.swift b/Cable/BatteriesView.swift index 0eb5712..247286c 100644 --- a/Cable/BatteriesView.swift +++ b/Cable/BatteriesView.swift @@ -183,9 +183,6 @@ struct BatteriesView: View { Text(bankTitle) .font(.headline.weight(.semibold)) Spacer() - Text(system.name) - .font(.subheadline) - .foregroundStyle(.secondary) } ViewThatFits(in: .horizontal) { diff --git a/Cable/LoadsView.swift b/Cable/LoadsView.swift index 55e998e..9ba995c 100644 --- a/Cable/LoadsView.swift +++ b/Cable/LoadsView.swift @@ -22,6 +22,8 @@ struct LoadsView: View { @State private var showingSystemBOM = false @State private var selectedComponentTab: ComponentTab = .components @State private var batteryDraft: BatteryConfiguration? + @State private var activeStatus: LoadStatus? + @State private var editMode: EditMode = .inactive let system: ElectricalSystem private let presentSystemEditorOnAppear: Bool @@ -89,6 +91,7 @@ struct LoadsView: View { ) } } + .environment(\.editMode, $editMode) } } .navigationBarTitleDisplayMode(.inline) @@ -118,6 +121,14 @@ struct LoadsView: View { ToolbarItem(placement: .navigationBarTrailing) { HStack { + if selectedComponentTab == .components { + Button(action: { + showingComponentLibrary = true + }) { + Image(systemName: "books.vertical") + } + .accessibilityIdentifier("component-library-button") + } if !savedLoads.isEmpty && selectedComponentTab == .components { Button(action: { showingSystemBOM = true @@ -132,8 +143,14 @@ struct LoadsView: View { Image(systemName: "plus") } .disabled(selectedComponentTab == .chargers) - if selectedComponentTab == .components || selectedComponentTab == .batteries { + if selectedComponentTab == .components { EditButton() + .environment(\.editMode, $editMode) + .disabled(savedLoads.isEmpty) + } else if selectedComponentTab == .batteries { + EditButton() + .environment(\.editMode, $editMode) + .disabled(savedBatteries.isEmpty) } } } @@ -187,6 +204,23 @@ struct LoadsView: View { ) ) } + .alert(item: $activeStatus) { status in + let detail = detailInfo(for: status) + 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" + ) + ) + ) + ) + } .onAppear { if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear { hasPresentedSystemEditorOnAppear = true @@ -202,47 +236,87 @@ struct LoadsView: View { } } } + .onChange(of: selectedComponentTab) { newValue in + if newValue == .chargers { + editMode = .inactive + } + } } - private var librarySection: some View { + private var summarySection: some View { VStack(spacing: 0) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("Component Library") - .font(.headline) - .fontWeight(.semibold) - Text("Browse electrical components from VoltPlan") - .font(.caption) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(loadsSummaryTitle) + .font(.headline.weight(.semibold)) + Spacer() } - - Spacer() - - Button(action: { - showingComponentLibrary = true - }) { - HStack(spacing: 6) { - Text("Browse") - .font(.subheadline) - .fontWeight(.medium) - Image(systemName: "arrow.up.right") - .font(.caption) + + ViewThatFits(in: .horizontal) { + HStack(spacing: 16) { + summaryMetric( + icon: "square.stack.3d.up", + label: loadsCountLabel, + value: "\(savedLoads.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: "\(savedLoads.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 + ) } - .foregroundColor(.blue) } - .buttonStyle(.plain) + + if let status = loadStatus { + Button { + activeStatus = status + } label: { + statusBanner(for: status) + } + .buttonStyle(.plain) + } } .padding(.horizontal, 16) - .padding(.vertical, 12) + .padding(.vertical, 10) .background(Color(.systemGroupedBackground)) - + Divider() + .background(Color(.separator)) } } private var componentsTab: some View { VStack(spacing: 0) { - librarySection + summarySection List { ForEach(savedLoads) { load in @@ -304,17 +378,17 @@ struct LoadsView: View { ViewThatFits(in: .horizontal) { HStack(spacing: 12) { metricBadge( - label: "Fuse", + label: fuseMetricLabel, value: "\(recommendedFuse(for: load)) A", tint: .pink ) metricBadge( - label: "Cable", + label: cableMetricLabel, value: wireGaugeString(for: load), tint: .teal ) metricBadge( - label: "Length", + label: lengthMetricLabel, value: lengthString(for: load), tint: .orange ) @@ -323,17 +397,17 @@ struct LoadsView: View { VStack(alignment: .leading, spacing: 8) { metricBadge( - label: "Fuse", + label: fuseMetricLabel, value: "\(recommendedFuse(for: load)) A", tint: .pink ) metricBadge( - label: "Cable", + label: cableMetricLabel, value: wireGaugeString(for: load), tint: .teal ) metricBadge( - label: "Length", + label: lengthMetricLabel, value: lengthString(for: load), tint: .orange ) @@ -381,6 +455,219 @@ struct LoadsView: View { } } + private var fuseMetricLabel: String { + NSLocalizedString( + "loads.metric.fuse", + bundle: .main, + value: "Fuse", + comment: "Label for fuse metric in load detail row" + ) + } + + private var cableMetricLabel: String { + NSLocalizedString( + "loads.metric.cable", + bundle: .main, + value: "Cable", + comment: "Label for cable metric in load detail row" + ) + } + + private var lengthMetricLabel: String { + NSLocalizedString( + "loads.metric.length", + bundle: .main, + value: "Length", + comment: "Label for cable length metric in load detail row" + ) + } + + 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 totalCurrent: Double { + savedLoads.reduce(0) { result, load in + result + max(load.current, 0) + } + } + + private var totalPower: Double { + savedLoads.reduce(0) { result, load in + result + max(load.power, 0) + } + } + + private var loadStatus: LoadStatus? { + guard !savedLoads.isEmpty else { return nil } + let incompleteLoads = savedLoads.filter { load in + load.length <= 0 || load.crossSection <= 0 + } + if !incompleteLoads.isEmpty { + return .missingDetails(count: incompleteLoads.count) + } + return nil + } + + private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + Text(value) + .font(.body.weight(.semibold)) + } + Text(label.uppercased()) + .font(.caption2) + .fontWeight(.medium) + .foregroundStyle(.secondary) + } + } + + private func statusBanner(for status: LoadStatus) -> 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 = LoadsView.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value) + return "\(numberString) A" + } + + private func formattedPower(_ value: Double) -> String { + let numberString = LoadsView.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value) + 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 + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 1 + 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()) diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index 36a3cc6..df47b35 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -140,6 +140,19 @@ "tab.batteries" = "Batterien"; "tab.chargers" = "Ladegeräte"; +"loads.overview.header.title" = "Verbraucherübersicht"; +"loads.overview.metric.count" = "Verbraucher"; +"loads.overview.metric.current" = "Strom"; +"loads.overview.metric.power" = "Leistung"; +"loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails"; +"loads.overview.status.missing_details.message" = "Gib Kabellänge und Leitungsquerschnitt für %d %@ ein, um genaue Empfehlungen zu erhalten."; +"loads.overview.status.missing_details.singular" = "Verbraucher"; +"loads.overview.status.missing_details.plural" = "Verbraucher"; +"loads.overview.status.missing_details.banner" = "Konfiguration deiner Verbraucher abschließen"; +"loads.metric.fuse" = "Sicherung"; +"loads.metric.cable" = "Querschnitt"; +"loads.metric.length" = "Länge"; + "battery.bank.header.title" = "Batteriebank"; "battery.bank.metric.count" = "Batterien"; "battery.bank.metric.capacity" = "Kapazität"; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index 2232b55..fa6e32a 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -139,6 +139,19 @@ "tab.batteries" = "Baterías"; "tab.chargers" = "Cargadores"; +"loads.overview.header.title" = "Resumen de cargas"; +"loads.overview.metric.count" = "Cargas"; +"loads.overview.metric.current" = "Corriente total"; +"loads.overview.metric.power" = "Potencia total"; +"loads.overview.status.missing_details.title" = "Faltan detalles de la carga"; +"loads.overview.status.missing_details.message" = "Introduce la longitud del cable y el calibre del conductor para %d %@ para obtener recomendaciones precisas."; +"loads.overview.status.missing_details.singular" = "carga"; +"loads.overview.status.missing_details.plural" = "cargas"; +"loads.overview.status.missing_details.banner" = "Completa la configuración de tus cargas"; +"loads.metric.fuse" = "Fusible"; +"loads.metric.cable" = "Cable"; +"loads.metric.length" = "Longitud"; + "battery.bank.header.title" = "Banco de baterías"; "battery.bank.metric.count" = "Baterías"; "battery.bank.metric.capacity" = "Capacidad"; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index 29a0590..6088d2f 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -139,6 +139,19 @@ "tab.batteries" = "Batteries"; "tab.chargers" = "Chargeurs"; +"loads.overview.header.title" = "Aperçu des charges"; +"loads.overview.metric.count" = "Charges"; +"loads.overview.metric.current" = "Courant total"; +"loads.overview.metric.power" = "Puissance totale"; +"loads.overview.status.missing_details.title" = "Détails de charge manquants"; +"loads.overview.status.missing_details.message" = "Saisissez la longueur de câble et la section du conducteur pour %d %@ afin d'obtenir des recommandations précises."; +"loads.overview.status.missing_details.singular" = "charge"; +"loads.overview.status.missing_details.plural" = "charges"; +"loads.overview.status.missing_details.banner" = "Terminez la configuration de vos charges"; +"loads.metric.fuse" = "Fusible"; +"loads.metric.cable" = "Câble"; +"loads.metric.length" = "Longueur"; + "battery.bank.header.title" = "Banque de batteries"; "battery.bank.metric.count" = "Batteries"; "battery.bank.metric.capacity" = "Capacité"; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index baec5b8..918ac0c 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -139,6 +139,19 @@ "tab.batteries" = "Batterijen"; "tab.chargers" = "Laders"; +"loads.overview.header.title" = "Lastenoverzicht"; +"loads.overview.metric.count" = "Lasten"; +"loads.overview.metric.current" = "Totale stroom"; +"loads.overview.metric.power" = "Totaal vermogen"; +"loads.overview.status.missing_details.title" = "Ontbrekende lastdetails"; +"loads.overview.status.missing_details.message" = "Voer kabellengte en kabeldoorsnede in voor %d %@ om nauwkeurige aanbevelingen te krijgen."; +"loads.overview.status.missing_details.singular" = "last"; +"loads.overview.status.missing_details.plural" = "lasten"; +"loads.overview.status.missing_details.banner" = "Rond de configuratie van je lasten af"; +"loads.metric.fuse" = "Zekering"; +"loads.metric.cable" = "Kabel"; +"loads.metric.length" = "Lengte"; + "battery.bank.header.title" = "Accubank"; "battery.bank.metric.count" = "Batterijen"; "battery.bank.metric.capacity" = "Capaciteit";