From 1fef290abf88e866a4297fbe8b2e501122f436cf Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Tue, 21 Oct 2025 15:37:07 +0200 Subject: [PATCH] some fixes --- Cable/Base.lproj/Localizable.strings | 9 + Cable/LoadsView.swift | 263 +++++----- Cable/SystemOverviewView.swift | 692 +++++++++++++++++++++++++++ Cable/de.lproj/Localizable.strings | 9 + Cable/es.lproj/Localizable.strings | 9 + Cable/fr.lproj/Localizable.strings | 9 + Cable/nl.lproj/Localizable.strings | 9 + 7 files changed, 844 insertions(+), 156 deletions(-) create mode 100644 Cable/SystemOverviewView.swift diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index 70cd9b4..be7a07a 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -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"; diff --git a/Cable/LoadsView.swift b/Cable/LoadsView.swift index 93e5d31..3699179 100644 --- a/Cable/LoadsView.swift +++ b/Cable/LoadsView.swift @@ -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 } } - diff --git a/Cable/SystemOverviewView.swift b/Cable/SystemOverviewView.swift new file mode 100644 index 0000000..ef2849a --- /dev/null +++ b/Cable/SystemOverviewView.swift @@ -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" + ) + } + } + } +} diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index df26b85..303ad03 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -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"; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index fa6e32a..71c8577 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -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"; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index 6088d2f..52dd4f0 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -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"; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index 918ac0c..c87aece 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -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";