From 97a9d3903ce1927959853d58267d8243411629fa Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Wed, 29 Oct 2025 12:55:47 +0100 Subject: [PATCH] much better system overview --- Cable/Base.lproj/Localizable.strings | 16 + Cable/Loads/CableCalculator.swift | 13 +- Cable/Loads/LoadsView.swift | 1 + Cable/Overview/SystemOverviewView.swift | 649 ++++++++++++++++++++---- Cable/de.lproj/Localizable.strings | 16 + Cable/es.lproj/Localizable.strings | 16 + Cable/fr.lproj/Localizable.strings | 16 + Cable/nl.lproj/Localizable.strings | 16 + 8 files changed, 650 insertions(+), 93 deletions(-) diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index d97c082..65c7b77 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -180,6 +180,22 @@ "overview.runtime.title" = "Estimated runtime"; "overview.runtime.unavailable" = "Add battery capacity and load power to estimate runtime."; "overview.system.header.title" = "System Overview"; +"overview.bom.title" = "Bill of Materials"; +"overview.bom.subtitle" = "Tap to review components"; +"overview.bom.unavailable" = "Add loads to generate components."; +"overview.bom.placeholder.short" = "Add loads"; +"overview.chargetime.title" = "Estimated charge time"; +"overview.chargetime.subtitle" = "At combined charge rate"; +"overview.chargetime.unavailable" = "Add chargers and battery capacity to estimate."; +"overview.chargetime.placeholder.short" = "Add chargers"; +"overview.goal.prefix" = "Goal"; +"overview.goal.label" = "Goal %@"; +"overview.goal.clear" = "Remove Goal"; +"overview.goal.cancel" = "Cancel"; +"overview.goal.save" = "Save"; +"overview.runtime.goal.title" = "Runtime Goal"; +"overview.runtime.placeholder.short" = "Add capacity"; +"overview.chargetime.goal.title" = "Charge Goal"; "sample.battery.rv.name" = "LiFePO4 house bank"; "sample.battery.workshop.name" = "Workbench backup battery"; "sample.charger.dcdc.name" = "DC-DC charger"; diff --git a/Cable/Loads/CableCalculator.swift b/Cable/Loads/CableCalculator.swift index 7e37745..0a267b7 100644 --- a/Cable/Loads/CableCalculator.swift +++ b/Cable/Loads/CableCalculator.swift @@ -112,13 +112,24 @@ class ElectricalSystem { var timestamp: Date = Date() var iconName: String = "building.2" var colorName: String = "blue" + var targetRuntimeHours: Double? + var targetChargeTimeHours: Double? - init(name: String, location: String = "", iconName: String = "building.2", colorName: String = "blue") { + init( + name: String, + location: String = "", + iconName: String = "building.2", + colorName: String = "blue", + targetRuntimeHours: Double? = nil, + targetChargeTimeHours: Double? = nil + ) { self.name = name self.location = location self.timestamp = Date() self.iconName = iconName self.colorName = colorName + self.targetRuntimeHours = targetRuntimeHours + self.targetChargeTimeHours = targetChargeTimeHours } } diff --git a/Cable/Loads/LoadsView.swift b/Cable/Loads/LoadsView.swift index 624eb6d..262e91b 100644 --- a/Cable/Loads/LoadsView.swift +++ b/Cable/Loads/LoadsView.swift @@ -288,6 +288,7 @@ struct LoadsView: View { onSelectChargers: { selectedComponentTab = .chargers }, onCreateLoad: { createNewLoad() }, onBrowseLibrary: { showingComponentLibrary = true }, + onShowBillOfMaterials: { showingSystemBOM = true }, onCreateBattery: { startBatteryConfiguration() }, onCreateCharger: { startChargerConfiguration() } ) diff --git a/Cable/Overview/SystemOverviewView.swift b/Cable/Overview/SystemOverviewView.swift index a1b4b24..f5e6751 100644 --- a/Cable/Overview/SystemOverviewView.swift +++ b/Cable/Overview/SystemOverviewView.swift @@ -1,9 +1,15 @@ import SwiftUI +import SwiftData struct SystemOverviewView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.modelContext) private var modelContext @State private var activeStatus: LoadConfigurationStatus? @State private var suppressLoadNavigation = false + @State private var showingRuntimeGoalEditor = false + @State private var showingChargeGoalEditor = false + @State private var runtimeGoalDraftHours: Double = 4 + @State private var chargeGoalDraftHours: Double = 4 let system: ElectricalSystem let loads: [SavedLoad] let batteries: [SavedBattery] @@ -13,85 +19,205 @@ struct SystemOverviewView: View { let onSelectChargers: () -> Void let onCreateLoad: () -> Void let onBrowseLibrary: () -> Void + let onShowBillOfMaterials: () -> Void let onCreateBattery: () -> Void let onCreateCharger: () -> Void var body: some View { - ScrollView { - VStack(spacing: 16) { - systemCard - loadsCard - batteriesCard - chargersCard + VStack(spacing: 0) { + systemCard + .padding(.horizontal, 16) + .padding(.top, 20) + .padding(.bottom, 16) + + ScrollView { + VStack(spacing: 16) { + loadsCard + batteriesCard + chargersCard + } + .padding(.horizontal, 16) + .padding(.bottom, 20) } - .padding(.horizontal, 16) - .padding(.vertical, 20) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) + .sheet(isPresented: $showingRuntimeGoalEditor) { + GoalEditorSheet( + title: runtimeGoalSheetTitle, + tint: .orange, + value: $runtimeGoalDraftHours, + minimum: Self.minimumGoalHours, + maximum: Self.maximumGoalHours, + step: Self.goalStepHours, + cancelTitle: goalCancelTitle, + saveTitle: goalSaveTitle, + clearTitle: goalClearTitle, + showsClear: system.targetRuntimeHours != nil, + formattedDurationProvider: { formattedDuration(hours: $0) }, + onSave: { saveRuntimeGoal(hours: $0) }, + onClear: system.targetRuntimeHours != nil ? { clearRuntimeGoal() } : nil + ) + } + .sheet(isPresented: $showingChargeGoalEditor) { + GoalEditorSheet( + title: chargeGoalSheetTitle, + tint: .blue, + value: $chargeGoalDraftHours, + minimum: Self.minimumGoalHours, + maximum: Self.maximumGoalHours, + step: Self.goalStepHours, + cancelTitle: goalCancelTitle, + saveTitle: goalSaveTitle, + clearTitle: goalClearTitle, + showsClear: system.targetChargeTimeHours != nil, + formattedDurationProvider: { formattedDuration(hours: $0) }, + onSave: { saveChargeGoal(hours: $0) }, + onClear: system.targetChargeTimeHours != nil ? { clearChargeGoal() } : nil + ) + } } private var systemCard: some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 18) { Text(systemOverviewTitle) .font(.headline.weight(.semibold)) - - ViewThatFits(in: .horizontal) { - HStack(spacing: 16) { - summaryMetric( - icon: "square.stack.3d.up", - label: loadsCountLabel, - value: "\(loads.count)", - tint: .blue - ).frame(maxWidth: .infinity, alignment: .leading) - summaryMetric( - icon: "battery.100", - label: batteryCountLabel, - value: "\(batteries.count)", - tint: .green - ).frame(maxWidth: .infinity, alignment: .leading) - summaryMetric( - icon: "bolt.fill", - label: chargerCountLabel, - value: "\(chargers.count)", - tint: .orange - ).frame(maxWidth: .infinity, alignment: .leading) - } + VStack(spacing: 14) { + overviewMetricRow( + title: runtimeTitle, + subtitle: runtimeSubtitle, + icon: "clock.arrow.circlepath", + tint: .orange, + value: formattedRuntime, + placeholder: runtimePlaceholderSummary, + goal: runtimeGoalValueText, + actualHours: estimatedRuntimeHours, + goalHours: system.targetRuntimeHours, + progressFraction: nil, + hasValue: formattedRuntime != nil, + action: openRuntimeGoalEditor + ) - VStack(alignment: .leading, spacing: 12) { - summaryMetric( - icon: "square.stack.3d.up", - label: loadsCountLabel, - value: "\(loads.count)", - tint: .blue - ).frame(maxWidth: .infinity, alignment: .leading) - summaryMetric( - icon: "battery.100", - label: batteryCountLabel, - value: "\(batteries.count)", - tint: .green - ).frame(maxWidth: .infinity, alignment: .leading) - summaryMetric( - icon: "bolt.fill", - label: chargerCountLabel, - value: "\(chargers.count)", - tint: .orange - ).frame(maxWidth: .infinity, alignment: .leading) - } + overviewMetricRow( + title: bomTitle, + subtitle: bomSubtitle, + icon: "list.bullet.rectangle", + tint: .purple, + value: formattedBOMCompletedCount, + placeholder: bomPlaceholderSummary, + goal: formattedBOMTotalCount, + actualHours: nil, + goalHours: nil, + progressFraction: bomCompletionFraction, + hasValue: bomItemsCount > 0, + action: onShowBillOfMaterials + ) + + overviewMetricRow( + title: chargeTimeTitle, + subtitle: chargeTimeSubtitle, + icon: "bolt.fill", + tint: .blue, + value: formattedChargeTime, + placeholder: chargeTimePlaceholderSummary, + goal: chargeGoalValueText, + actualHours: estimatedChargeHours, + goalHours: system.targetChargeTimeHours, + progressFraction: nil, + hasValue: formattedChargeTime != nil, + action: openChargeGoalEditor + ) } - - runtimeSection + .padding(.top, 4) } - .padding(.horizontal, 16) .padding(.vertical, 18) + .padding(.horizontal, 20) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(Color(.systemBackground)) + .stroke(Color(.separator).opacity(0.18), lineWidth: 1) + .background( + RoundedRectangle(cornerRadius: 20, style: .continuous) + .fill(Color(.systemBackground)) + ) ) } + @ViewBuilder + private func overviewMetricRow( + title: String, + subtitle: String?, + icon: String, + tint: Color, + value: String?, + placeholder: String?, + goal: String?, + actualHours: Double?, + goalHours: Double?, + progressFraction: Double?, + hasValue: Bool, + action: (() -> Void)? = nil + ) -> some View { + let content = VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .center, spacing: 12) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint.opacity(0.18)) + .frame(width: 24, height: 24) + .overlay { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(tint) + } + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + if let subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(value ?? "—") + .font(.headline.weight(.semibold)) + .foregroundStyle(hasValue ? Color.primary : Color.secondary) + .lineLimit(1) + if let goal { + Text("\(goalPrefix) \(goal)") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + + progressBar( + progress: adjustedProgress(for: progressFraction, actualHours: actualHours, goalHours: goalHours), + tint: tint + ) + } + .padding(.vertical, 2) + + let paddedContent = content + .padding(.horizontal, 4) + + if let action { + Button(action: action) { + paddedContent + } + .buttonStyle(.plain) + } else { + paddedContent + } + } + @ViewBuilder private var loadsCard: some View { if loads.isEmpty { @@ -531,37 +657,6 @@ struct SystemOverviewView: View { } } - @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) @@ -580,6 +675,41 @@ struct SystemOverviewView: View { ) } + private var bomCompletionFraction: Double? { + guard bomItemsCount > 0 else { return nil } + return Double(completedBOMItemCount) / Double(bomItemsCount) + } + + private var completedBOMItemCount: Int { + settledLoads.reduce(into: Set()) { partialResult, load in + load.bomCompletedItemIDs.forEach { partialResult.insert($0) } + }.count + } + + private var bomItemsCount: Int { + settledLoads.isEmpty ? 0 : settledLoads.count * Self.bomItemsPerLoad + } + + private var settledLoads: [SavedLoad] { + loads.filter { $0.crossSection > 0 && $0.length > 0 } + } + + private func progressBar(progress: CGFloat?, tint: Color) -> some View { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(Color(.tertiarySystemFill)) + .frame(height: 4) + .frame(maxWidth: .infinity) + .overlay(alignment: .leading) { + if let progress { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(tint) + .frame(maxWidth: .infinity) + .scaleEffect(x: max(min(progress, 1), 0), y: 1, anchor: .leading) + .animation(.easeInOut(duration: 0.25), value: progress) + } + } + } + private func formattedCurrent(_ value: Double) -> String { let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value) return "\(numberString) A" @@ -590,6 +720,10 @@ struct SystemOverviewView: View { return "\(numberString) W" } + private func numberString(for value: Double) -> String { + Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value) + } + private func formattedChargerOutput(_ value: Double?) -> String { guard let value, value > 0 else { return "—" } return formattedValue(value, unit: "V") @@ -600,6 +734,32 @@ struct SystemOverviewView: View { return "\(numberString) \(unit)" } + private func progressFraction(actualHours: Double?, goalHours: Double?) -> CGFloat { + guard let actualHours, let goalHours, goalHours > 0 else { return 0 } + let ratio = actualHours / goalHours + return CGFloat(max(min(ratio, 1), 0)) + } + + private func adjustedProgress(for explicit: Double?, actualHours: Double?, goalHours: Double?) -> CGFloat? { + guard let ratio = progressRatio(explicitProgress: explicit, actualHours: actualHours, goalHours: goalHours) else { return nil } + if explicit != nil { return ratio } + guard let actual = actualHours, let goal = goalHours, goal > 0 else { return ratio } + if goal == system.targetChargeTimeHours { + let value = goal / max(actual, 0.0001) + return CGFloat(max(min(value, 1), 0)) + } + return ratio + } + + private func progressRatio(explicitProgress: Double?, actualHours: Double?, goalHours: Double?) -> CGFloat? { + if let explicitProgress { + return CGFloat(max(min(explicitProgress, 1), 0)) + } + + guard goalHours != nil else { return nil } + return progressFraction(actualHours: actualHours, goalHours: goalHours) + } + private var estimatedRuntimeHours: Double? { guard totalPower > 0, totalUsableEnergy > 0 else { return nil } let hours = totalUsableEnergy / totalPower @@ -615,9 +775,99 @@ struct SystemOverviewView: View { let numberString = Self.numberFormatter.string(from: NSNumber(value: hours)) ?? String(format: "%.1f", hours) return "\(numberString) h" } + + private var formattedChargeTime: String? { + guard let hours = estimatedChargeHours 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 var estimatedChargeHours: Double? { + guard totalUsableCapacity > 0, totalChargerCurrent > 0 else { return nil } + let hours = totalUsableCapacity / totalChargerCurrent + return hours.isFinite && hours > 0 ? hours : nil + } + + private func openRuntimeGoalEditor() { + runtimeGoalDraftHours = defaultGoalHours(current: system.targetRuntimeHours, fallback: estimatedRuntimeHours) + showingRuntimeGoalEditor = true + } + + private func openChargeGoalEditor() { + chargeGoalDraftHours = defaultGoalHours(current: system.targetChargeTimeHours, fallback: estimatedChargeHours) + showingChargeGoalEditor = true + } + + private func saveRuntimeGoal(hours: Double) { + let adjusted = clampedGoal(hours) + system.targetRuntimeHours = adjusted + runtimeGoalDraftHours = adjusted + persistSystemChanges() + showingRuntimeGoalEditor = false + } + + private func saveChargeGoal(hours: Double) { + let adjusted = clampedGoal(hours) + system.targetChargeTimeHours = adjusted + chargeGoalDraftHours = adjusted + persistSystemChanges() + showingChargeGoalEditor = false + } + + private func clearRuntimeGoal() { + system.targetRuntimeHours = nil + persistSystemChanges() + runtimeGoalDraftHours = defaultGoalHours(current: nil, fallback: estimatedRuntimeHours) + showingRuntimeGoalEditor = false + } + + private func clearChargeGoal() { + system.targetChargeTimeHours = nil + persistSystemChanges() + chargeGoalDraftHours = defaultGoalHours(current: nil, fallback: estimatedChargeHours) + showingChargeGoalEditor = false + } + + private func persistSystemChanges() { + guard modelContext.hasChanges else { return } + + do { + try modelContext.save() + } catch { +#if DEBUG + print("Failed to save system goal: \(error.localizedDescription)") +#endif + } + } + + private func defaultGoalHours(current: Double?, fallback: Double?) -> Double { + if let current, current >= Self.minimumGoalHours { + return clampedGoal(current) + } + if let fallback, fallback >= Self.minimumGoalHours { + return clampedGoal(fallback) + } + return Self.fallbackGoalHours + } + + private func clampedGoal(_ hours: Double) -> Double { + guard hours.isFinite else { return Self.fallbackGoalHours } + let rounded = (hours / Self.goalStepHours).rounded() * Self.goalStepHours + return min(max(rounded, Self.minimumGoalHours), Self.maximumGoalHours) + } + + private func formattedDuration(hours: Double) -> String { + 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 loadsSummaryTitle: String { @@ -856,6 +1106,83 @@ struct SystemOverviewView: View { ) } + private var bomTitle: String { + NSLocalizedString( + "overview.bom.title", + bundle: .main, + value: "Bill of Materials", + comment: "Title for BOM metric card in the system overview" + ) + } + + private var bomSubtitle: String { + NSLocalizedString( + "overview.bom.subtitle", + bundle: .main, + value: "Tap to review components", + comment: "Subtitle describing the BOM metric card interaction" + ) + } + + private var bomPlaceholderSummary: String { + NSLocalizedString( + "overview.bom.placeholder.short", + bundle: .main, + value: "Add loads", + comment: "Short placeholder shown when no BOM data is available" + ) + } + + private var formattedBOMCompletedCount: String? { + guard bomItemsCount > 0 else { return nil } + return numberString(for: Double(completedBOMItemCount)) + } + + private var formattedBOMTotalCount: String? { + guard bomItemsCount > 0 else { return nil } + return numberString(for: Double(bomItemsCount)) + } + + private var chargeTimeTitle: String { + NSLocalizedString( + "overview.chargetime.title", + bundle: .main, + value: "Estimated charge time", + comment: "Title for the charge time metric card" + ) + } + + private var chargeTimeSubtitle: String { + NSLocalizedString( + "overview.chargetime.subtitle", + bundle: .main, + value: "At combined charge rate", + comment: "Subtitle describing charge time assumptions" + ) + } + + private var chargeTimePlaceholderSummary: String { + NSLocalizedString( + "overview.chargetime.placeholder.short", + bundle: .main, + value: "Add chargers", + comment: "Short placeholder shown when charge time cannot be calculated" + ) + } + + private var chargeGoalValueText: String? { + formattedGoalValue(for: system.targetChargeTimeHours) + } + + private var goalPrefix: String { + NSLocalizedString( + "overview.goal.prefix", + bundle: .main, + value: "Goal", + comment: "Prefix displayed before goal value" + ) + } + private var runtimeTitle: String { NSLocalizedString( "overview.runtime.title", @@ -874,12 +1201,66 @@ struct SystemOverviewView: View { ) } - private var runtimeUnavailableText: String { + private var runtimePlaceholderSummary: String { NSLocalizedString( - "overview.runtime.unavailable", + "overview.runtime.placeholder.short", bundle: .main, - value: "Add battery capacity and load power to estimate runtime.", - comment: "Message shown when runtime cannot be calculated" + value: "Add capacity", + comment: "Short placeholder shown when runtime cannot be calculated" + ) + } + + private var runtimeGoalValueText: String? { + formattedGoalValue(for: system.targetRuntimeHours) + } + + private func formattedGoalValue(for hours: Double?) -> String? { + guard let hours else { return nil } + return formattedDuration(hours: hours) + } + + private var runtimeGoalSheetTitle: String { + NSLocalizedString( + "overview.runtime.goal.title", + bundle: .main, + value: "Runtime Goal", + comment: "Navigation title for editing the runtime goal" + ) + } + + private var chargeGoalSheetTitle: String { + NSLocalizedString( + "overview.chargetime.goal.title", + bundle: .main, + value: "Charge Goal", + comment: "Navigation title for editing the charge time goal" + ) + } + + private var goalClearTitle: String { + NSLocalizedString( + "overview.goal.clear", + bundle: .main, + value: "Remove Goal", + comment: "Button title to clear an active goal" + ) + } + + private var goalCancelTitle: String { + NSLocalizedString( + "overview.goal.cancel", + bundle: .main, + value: "Cancel", + comment: "Button title to cancel goal editing" + ) + } + + private var goalSaveTitle: String { + NSLocalizedString( + "overview.goal.save", + bundle: .main, + value: "Save", + comment: "Button title to save goal editing" ) } @@ -899,6 +1280,13 @@ struct SystemOverviewView: View { return formatter }() + private static let minimumGoalHours: Double = 0.5 + private static let maximumGoalHours: Double = 240 + private static let goalStepHours: Double = 0.25 + private static let fallbackGoalHours: Double = 4 + + private static let bomItemsPerLoad = 5 + private enum BatteryWarning { case voltage(count: Int) case capacity(count: Int) @@ -942,6 +1330,77 @@ struct SystemOverviewView: View { } } +private struct GoalEditorSheet: View { + let title: String + let tint: Color + @Binding var value: Double + let minimum: Double + let maximum: Double + let step: Double + let cancelTitle: String + let saveTitle: String + let clearTitle: String + let showsClear: Bool + let formattedDurationProvider: (Double) -> String + let onSave: (Double) -> Void + let onClear: (() -> Void)? + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section { + Stepper(value: $value, in: minimum...maximum, step: step) { + Label { + Text(formattedDurationProvider(value)) + .font(.title3.weight(.semibold)) + } icon: { + Image(systemName: "flag.checkered") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(tint) + } + } + } + + if showsClear, let onClear { + Section { + Button(role: .destructive) { + onClear() + dismiss() + } label: { + Text(clearTitle) + } + } + } + } + .tint(tint) + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(cancelTitle) { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(saveTitle) { + onSave(clampedValue) + dismiss() + } + .disabled(!value.isFinite || value < minimum) + } + } + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + + private var clampedValue: Double { + min(max(value, minimum), maximum) + } +} + #Preview("SystemOverview – Populated") { let system = ElectricalSystem( name: "12V DC System", @@ -949,6 +1408,8 @@ struct SystemOverviewView: View { iconName: "bolt.circle.fill", colorName: "blue" ) + //system.targetRuntimeHours = 8 + //system.targetChargeTimeHours = 6 let loads: [SavedLoad] = [ SavedLoad( @@ -1023,10 +1484,12 @@ struct SystemOverviewView: View { onSelectChargers: {}, onCreateLoad: {}, onBrowseLibrary: {}, + onShowBillOfMaterials: {}, onCreateBattery: {}, onCreateCharger: {} ) .padding() + .modelContainer(for: [ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self], inMemory: true) } #Preview("SystemOverview – Empty States") { @@ -1047,8 +1510,10 @@ struct SystemOverviewView: View { onSelectChargers: {}, onCreateLoad: {}, onBrowseLibrary: {}, + onShowBillOfMaterials: {}, onCreateBattery: {}, onCreateCharger: {} ) .padding() + .modelContainer(for: [ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self], inMemory: true) } diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index 95693b9..9234361 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -240,6 +240,22 @@ "overview.runtime.title" = "Geschätzte Laufzeit"; "overview.runtime.unavailable" = "Trage Batteriekapazität und Leistungsaufnahme ein, um die Laufzeit zu schätzen."; "overview.system.header.title" = "Systemübersicht"; +"overview.bom.title" = "Stückliste"; +"overview.bom.subtitle" = "Tippe, um Komponenten zu prüfen"; +"overview.bom.unavailable" = "Füge Verbraucher hinzu, um Komponenten zu erzeugen."; +"overview.bom.placeholder.short" = "Verbraucher hinzufügen"; +"overview.chargetime.title" = "Geschätzte Ladezeit"; +"overview.chargetime.subtitle" = "Bei kombinierter Laderate"; +"overview.chargetime.unavailable" = "Füge Ladegeräte und Batteriekapazität hinzu, um eine Schätzung zu erhalten."; +"overview.chargetime.placeholder.short" = "Ladegeräte hinzufügen"; +"overview.goal.prefix" = "Ziel"; +"overview.goal.label" = "Ziel %@"; +"overview.goal.clear" = "Ziel entfernen"; +"overview.goal.cancel" = "Abbrechen"; +"overview.goal.save" = "Speichern"; +"overview.runtime.goal.title" = "Laufzeit-Ziel"; +"overview.chargetime.goal.title" = "Ladezeit-Ziel"; +"overview.runtime.placeholder.short" = "Kapazität hinzufügen"; "sample.battery.rv.name" = "LiFePO4-Bordbatterie"; "sample.battery.workshop.name" = "Werkbank-Reservebatterie"; "sample.charger.dcdc.name" = "DC-DC-Ladegerät"; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index 7f6a994..88a199f 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -176,6 +176,22 @@ "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."; +"overview.bom.title" = "Lista de materiales"; +"overview.bom.subtitle" = "Pulsa para revisar los componentes"; +"overview.bom.unavailable" = "Añade cargas para generar componentes."; +"overview.bom.placeholder.short" = "Añadir cargas"; +"overview.chargetime.title" = "Tiempo de carga estimado"; +"overview.chargetime.subtitle" = "Con la tasa de carga combinada"; +"overview.chargetime.unavailable" = "Añade cargadores y capacidad de batería para calcularlo."; +"overview.chargetime.placeholder.short" = "Añadir cargadores"; +"overview.goal.prefix" = "Objetivo"; +"overview.goal.label" = "Objetivo %@"; +"overview.goal.clear" = "Eliminar objetivo"; +"overview.goal.cancel" = "Cancelar"; +"overview.goal.save" = "Guardar"; +"overview.runtime.goal.title" = "Objetivo de autonomía"; +"overview.chargetime.goal.title" = "Objetivo de carga"; +"overview.runtime.placeholder.short" = "Añadir capacidad"; "battery.bank.warning.voltage.short" = "Voltaje"; "battery.bank.warning.capacity.short" = "Capacidad"; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index 4e77dde..f490fe2 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -176,6 +176,22 @@ "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."; +"overview.bom.title" = "Liste de matériel"; +"overview.bom.subtitle" = "Touchez pour consulter les composants"; +"overview.bom.unavailable" = "Ajoutez des charges pour générer des composants."; +"overview.bom.placeholder.short" = "Ajouter des charges"; +"overview.chargetime.title" = "Temps de charge estimé"; +"overview.chargetime.subtitle" = "Au débit de charge combiné"; +"overview.chargetime.unavailable" = "Ajoutez des chargeurs et de la capacité batterie pour estimer."; +"overview.chargetime.placeholder.short" = "Ajouter des chargeurs"; +"overview.goal.prefix" = "Objectif"; +"overview.goal.label" = "Objectif %@"; +"overview.goal.clear" = "Supprimer l'objectif"; +"overview.goal.cancel" = "Annuler"; +"overview.goal.save" = "Enregistrer"; +"overview.runtime.goal.title" = "Objectif d'autonomie"; +"overview.chargetime.goal.title" = "Objectif de recharge"; +"overview.runtime.placeholder.short" = "Ajouter capacité"; "battery.bank.warning.voltage.short" = "Tension"; "battery.bank.warning.capacity.short" = "Capacité"; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index 3a3f6cf..ddfdb32 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -176,6 +176,22 @@ "overview.runtime.title" = "Geschatte looptijd"; "overview.runtime.subtitle" = "Bij huidige belasting"; "overview.runtime.unavailable" = "Voeg batterijcapaciteit en vermogensvraag toe om de looptijd te berekenen."; +"overview.bom.title" = "Stuklijst"; +"overview.bom.subtitle" = "Tik om componenten te bekijken"; +"overview.bom.unavailable" = "Voeg verbruikers toe om componenten te genereren."; +"overview.bom.placeholder.short" = "Lasten toevoegen"; +"overview.chargetime.title" = "Geschatte laadtijd"; +"overview.chargetime.subtitle" = "Met gecombineerde laadsnelheid"; +"overview.chargetime.unavailable" = "Voeg laders en accucapaciteit toe voor een schatting."; +"overview.chargetime.placeholder.short" = "Laders toevoegen"; +"overview.goal.prefix" = "Doel"; +"overview.goal.label" = "Doel %@"; +"overview.goal.clear" = "Doel verwijderen"; +"overview.goal.cancel" = "Annuleren"; +"overview.goal.save" = "Opslaan"; +"overview.runtime.goal.title" = "Looptijddoel"; +"overview.chargetime.goal.title" = "Laadtijddoel"; +"overview.runtime.placeholder.short" = "Capaciteit toevoegen"; "battery.bank.warning.voltage.short" = "Spanning"; "battery.bank.warning.capacity.short" = "Capaciteit";