- Add dutyCyclePercent and defaultUtilizationFactorPercent to ComponentLibraryItem with normalization logic and backend field fetching - Change default dailyUsageHours from 1h to 24h - Replace goal editor stepper with day/hour/minute wheel pickers - Update app icon colors and remove duplicate icon assets - Move SavedBattery.swift into Batteries/ directory, remove Pods group - Add iPad-only flag and start frame support to screenshot framing scripts - Rework localized App Store screenshot titles across all languages - Add runtime goals and BOM completed items to sample data - Bump version to 1.5.1 (build 41) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1844 lines
63 KiB
Swift
1844 lines
63 KiB
Swift
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]
|
||
let chargers: [SavedCharger]
|
||
let onSelectLoads: () -> Void
|
||
let onSelectBatteries: () -> Void
|
||
let onSelectChargers: () -> Void
|
||
let onCreateLoad: () -> Void
|
||
let onBrowseLibrary: () -> Void
|
||
let onShowBillOfMaterials: () -> Void
|
||
let onCreateBattery: () -> Void
|
||
let onCreateCharger: () -> Void
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
|
||
if #available(iOS 26.0, *) {
|
||
ScrollView {
|
||
VStack(spacing: 16) {
|
||
loadsCard
|
||
batteriesCard
|
||
chargersCard
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.bottom, 20)
|
||
}
|
||
// let the content slide under the top inset
|
||
// choose the Liquid Glass-friendly edge style
|
||
.scrollEdgeEffectStyle(.soft, for: .top)
|
||
|
||
// pin the Liquid Glass system card at the top safe area
|
||
.safeAreaInset(edge: .top, spacing: 0) {
|
||
systemCard
|
||
// Liquid Glass on custom view
|
||
.glassEffect(.regular, in: .rect(cornerRadius: 20))
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 12)
|
||
.padding(.bottom, 12)
|
||
}
|
||
} else {
|
||
ScrollView {
|
||
VStack(spacing: 16) {
|
||
loadsCard
|
||
batteriesCard
|
||
chargersCard
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.bottom, 20)
|
||
}
|
||
// Don’t ignore the safe area — we want content to sit below the header
|
||
.safeAreaInset(edge: .top, spacing: 0) {
|
||
systemCard
|
||
.padding(.horizontal, 16)
|
||
.padding(.top, 20)
|
||
.padding(.bottom, 16)
|
||
.background(Color(.systemGroupedBackground)
|
||
)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.strokeBorder(.white.opacity(0.15))
|
||
)
|
||
}
|
||
}
|
||
|
||
}
|
||
.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: 18) {
|
||
|
||
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
|
||
)
|
||
|
||
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
|
||
)
|
||
|
||
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,
|
||
accessibilityIdentifier: "system-bom-button"
|
||
)
|
||
|
||
}
|
||
.padding(.top, 4)
|
||
}
|
||
.padding(.vertical, 18)
|
||
.padding(.horizontal, 20)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.stroke(Color(.separator).opacity(0.18), lineWidth: 1)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.fill(Color(red: 81/255, green: 144/255, blue: 152/255).opacity(0.12))
|
||
)
|
||
)
|
||
}
|
||
|
||
@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,
|
||
accessibilityIdentifier: String? = 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)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.contentShape(Rectangle())
|
||
|
||
if let action {
|
||
if let accessibilityIdentifier {
|
||
Button(action: action) {
|
||
paddedContent
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityIdentifier(accessibilityIdentifier)
|
||
.accessibilityLabel(title)
|
||
.accessibilityAddTraits(.isButton)
|
||
.contentShape(Rectangle())
|
||
} else {
|
||
Button(action: action) {
|
||
paddedContent
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel(title)
|
||
.accessibilityAddTraits(.isButton)
|
||
.contentShape(Rectangle())
|
||
}
|
||
} else {
|
||
paddedContent
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var loadsCard: some View {
|
||
if loads.isEmpty {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
HStack(alignment: .firstTextBaseline) {
|
||
Text(loadsSummaryTitle)
|
||
.font(.headline.weight(.semibold))
|
||
Spacer()
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text(loadsEmptyTitle)
|
||
.font(.subheadline.weight(.semibold))
|
||
Text(loadsEmptyMessage)
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Button(action: onCreateLoad) {
|
||
Label(loadsEmptyCreateAction, systemImage: "plus")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.large)
|
||
|
||
Button(action: onBrowseLibrary) {
|
||
Label(loadsEmptyBrowseAction, systemImage: "books.vertical")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.tint(.accentColor)
|
||
.controlSize(.large)
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 18)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.fill(Color(.tertiarySystemBackground))
|
||
)
|
||
} else {
|
||
Button {
|
||
if suppressLoadNavigation {
|
||
suppressLoadNavigation = false
|
||
return
|
||
}
|
||
onSelectLoads()
|
||
} label: {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
HStack(alignment: .firstTextBaseline) {
|
||
Text(loadsSummaryTitle)
|
||
.font(.headline.weight(.semibold))
|
||
Spacer()
|
||
}
|
||
|
||
ViewThatFits(in: .horizontal) {
|
||
HStack(spacing: 16) {
|
||
summaryMetric(
|
||
icon: "square.stack.3d.up",
|
||
label: loadsCountLabel,
|
||
value: "\(loads.count)",
|
||
tint: .blue
|
||
).frame(maxWidth: .infinity, alignment: .leading)
|
||
summaryMetric(
|
||
icon: "bolt.fill",
|
||
label: loadsCurrentLabel,
|
||
value: formattedCurrent(totalCurrent),
|
||
tint: .orange
|
||
).frame(maxWidth: .infinity, alignment: .leading)
|
||
summaryMetric(
|
||
icon: "gauge.medium",
|
||
label: loadsPowerLabel,
|
||
value: formattedPower(totalPower),
|
||
tint: .green
|
||
).frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
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: "bolt.fill",
|
||
label: loadsCurrentLabel,
|
||
value: formattedCurrent(totalCurrent),
|
||
tint: .orange
|
||
).frame(maxWidth: .infinity, alignment: .leading)
|
||
summaryMetric(
|
||
icon: "gauge.medium",
|
||
label: loadsPowerLabel,
|
||
value: formattedPower(totalPower),
|
||
tint: .green
|
||
).frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
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(.tertiarySystemBackground))
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.alert(item: $activeStatus) { status in
|
||
let detail = status.detailInfo()
|
||
return Alert(
|
||
title: Text(detail.title),
|
||
message: Text(detail.message),
|
||
dismissButton: .default(
|
||
Text(
|
||
NSLocalizedString(
|
||
"battery.bank.status.dismiss",
|
||
bundle: .main,
|
||
value: "Got it",
|
||
comment: "Dismiss button title for load status alert"
|
||
)
|
||
)
|
||
)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var batteriesCard: some View {
|
||
Button(action: onSelectBatteries) {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
HStack(alignment: .center, spacing: 10) {
|
||
Text(batterySummaryTitle)
|
||
.font(.headline.weight(.semibold))
|
||
if let warning = batteryWarning {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: warning.symbol)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(warning.tint)
|
||
Text(warning.shortLabel)
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(warning.tint)
|
||
}
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 6)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||
.fill(warning.tint.opacity(0.12))
|
||
)
|
||
}
|
||
Spacer()
|
||
}
|
||
|
||
if batteries.isEmpty {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(batteryEmptyTitle)
|
||
.font(.subheadline.weight(.semibold))
|
||
Text(batteryEmptySubtitle)
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
Button(action: onCreateBattery) {
|
||
Label(batteryEmptyCreateAction, systemImage: "plus")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.large)
|
||
}
|
||
} else {
|
||
ViewThatFits(in: .horizontal) {
|
||
HStack(spacing: 16) {
|
||
batteryMetricsContent
|
||
}
|
||
|
||
LazyVGrid(
|
||
columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: 2),
|
||
alignment: .leading,
|
||
spacing: 16
|
||
) {
|
||
batteryMetricsContent
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
batteryMetricsContent
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 18)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.fill(Color(.tertiarySystemBackground))
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var batteryMetricsContent: some View {
|
||
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: "battery.100.bolt",
|
||
label: batteryUsableCapacityLabel,
|
||
value: formattedValue(totalUsableCapacity, unit: "Ah"),
|
||
tint: .teal
|
||
)
|
||
summaryMetric(
|
||
icon: "bolt.circle",
|
||
label: batteryUsableEnergyLabel,
|
||
value: formattedValue(totalUsableEnergy, unit: "Wh"),
|
||
tint: .green
|
||
)
|
||
}
|
||
|
||
private var chargersCard: some View {
|
||
Button(action: onSelectChargers) {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
Text(chargerSummaryTitle)
|
||
.font(.headline.weight(.semibold))
|
||
|
||
if chargers.isEmpty {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(chargerEmptyTitle)
|
||
.font(.subheadline.weight(.semibold))
|
||
Text(chargerEmptySubtitle)
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
Button(action: onCreateCharger) {
|
||
Label(chargerEmptyCreateAction, systemImage: "plus")
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.large)
|
||
}
|
||
} else {
|
||
ViewThatFits(in: .horizontal) {
|
||
HStack(spacing: 16) {
|
||
chargerMetricsContent
|
||
}
|
||
|
||
LazyVGrid(
|
||
columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: 2),
|
||
alignment: .leading,
|
||
spacing: 16
|
||
) {
|
||
chargerMetricsContent
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
chargerMetricsContent
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 18)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.fill(Color(.tertiarySystemBackground))
|
||
)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var chargerMetricsContent: some View {
|
||
summaryMetric(
|
||
icon: "bolt.fill",
|
||
label: chargerCountLabel,
|
||
value: "\(chargers.count)",
|
||
tint: .blue
|
||
).frame(maxWidth: .infinity, alignment: .leading)
|
||
summaryMetric(
|
||
icon: "gauge.medium",
|
||
label: chargerCurrentLabel,
|
||
value: formattedCurrent(totalChargerCurrent),
|
||
tint: .orange
|
||
).frame(maxWidth: .infinity, alignment: .leading)
|
||
summaryMetric(
|
||
icon: "bolt.circle",
|
||
label: chargerPowerLabel,
|
||
value: formattedPower(totalChargerPower),
|
||
tint: .green
|
||
).frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
|
||
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 totalAverageLoadPower: Double {
|
||
loads.reduce(0) { result, load in
|
||
let power = max(load.power, 0)
|
||
guard power > 0 else { return result }
|
||
let dutyCycleFraction = max(min(load.dutyCyclePercent, 100), 0) / 100
|
||
let usageFraction = max(min(load.dailyUsageHours, 24), 0) / 24
|
||
return result + power * dutyCycleFraction * usageFraction
|
||
}
|
||
}
|
||
|
||
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 totalUsableCapacity: Double {
|
||
batteries.reduce(0) { result, battery in
|
||
result + battery.usableCapacityAmpHours
|
||
}
|
||
}
|
||
|
||
private var totalUsableEnergy: Double {
|
||
batteries.reduce(0) { result, battery in
|
||
result + battery.usableEnergyWattHours
|
||
}
|
||
}
|
||
|
||
private var totalChargerCurrent: Double {
|
||
chargers.reduce(0) { result, charger in
|
||
result + max(0, charger.maxCurrentAmps)
|
||
}
|
||
}
|
||
|
||
private var totalChargerPower: Double {
|
||
chargers.reduce(0) { result, charger in
|
||
result + max(0, charger.effectivePowerWatts)
|
||
}
|
||
}
|
||
|
||
private var representativeChargerOutput: Double? {
|
||
let outputs = chargers.map { $0.outputVoltage }.filter { $0 > 0 }
|
||
guard !outputs.isEmpty else { return nil }
|
||
let total = outputs.reduce(0, +)
|
||
return total / Double(outputs.count)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
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 var bomCompletionFraction: Double? {
|
||
guard bomItemsCount > 0 else { return nil }
|
||
return Double(completedBOMItemCount) / Double(bomItemsCount)
|
||
}
|
||
|
||
private var completedBOMItemCount: Int {
|
||
let loadCount = settledLoads.reduce(0) { result, load in
|
||
let uniqueItems = Set(load.bomCompletedItemIDs)
|
||
let cappedCount = min(uniqueItems.count, Self.bomItemsPerLoad)
|
||
return result + cappedCount
|
||
}
|
||
|
||
let batteryCount = settledBatteries.reduce(0) { result, battery in
|
||
let uniqueItems = Set(battery.bomCompletedItemIDs)
|
||
let cappedCount = min(uniqueItems.count, Self.bomItemsPerBattery)
|
||
return result + cappedCount
|
||
}
|
||
|
||
let chargerCount = settledChargers.reduce(0) { result, charger in
|
||
let uniqueItems = Set(charger.bomCompletedItemIDs)
|
||
let cappedCount = min(uniqueItems.count, Self.bomItemsPerCharger)
|
||
return result + cappedCount
|
||
}
|
||
|
||
return loadCount + batteryCount + chargerCount
|
||
}
|
||
|
||
private var bomItemsCount: Int {
|
||
let loadItems = settledLoads.count * Self.bomItemsPerLoad
|
||
let batteryItems = settledBatteries.count * Self.bomItemsPerBattery
|
||
let chargerItems = settledChargers.count * Self.bomItemsPerCharger
|
||
let total = loadItems + batteryItems + chargerItems
|
||
return total
|
||
}
|
||
|
||
private var settledLoads: [SavedLoad] {
|
||
loads.filter { $0.crossSection > 0 && $0.length > 0 }
|
||
}
|
||
|
||
private var settledBatteries: [SavedBattery] {
|
||
batteries.filter { $0.system == system }
|
||
}
|
||
|
||
private var settledChargers: [SavedCharger] {
|
||
chargers.filter { $0.system == system }
|
||
}
|
||
|
||
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"
|
||
}
|
||
|
||
private func formattedPower(_ value: Double) -> String {
|
||
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
||
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")
|
||
}
|
||
|
||
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 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 totalAverageLoadPower > 0, totalUsableEnergy > 0 else { return nil }
|
||
let hours = totalUsableEnergy / totalAverageLoadPower
|
||
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 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 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 {
|
||
NSLocalizedString(
|
||
"loads.overview.header.title",
|
||
bundle: .main,
|
||
value: "Load Overview",
|
||
comment: "Title for the loads overview summary section"
|
||
)
|
||
}
|
||
|
||
private var loadsCountLabel: String {
|
||
NSLocalizedString(
|
||
"loads.overview.metric.count",
|
||
bundle: .main,
|
||
value: "Loads",
|
||
comment: "Label for number of loads metric"
|
||
)
|
||
}
|
||
|
||
private var loadsCurrentLabel: String {
|
||
NSLocalizedString(
|
||
"loads.overview.metric.current",
|
||
bundle: .main,
|
||
value: "Total Current",
|
||
comment: "Label for total load current metric"
|
||
)
|
||
}
|
||
|
||
private var loadsPowerLabel: String {
|
||
NSLocalizedString(
|
||
"loads.overview.metric.power",
|
||
bundle: .main,
|
||
value: "Total Power",
|
||
comment: "Label for total load power metric"
|
||
)
|
||
}
|
||
|
||
private var loadsEmptyTitle: String {
|
||
NSLocalizedString(
|
||
"overview.loads.empty.title",
|
||
bundle: .main,
|
||
value: "No loads configured yet",
|
||
comment: "Title shown in overview when no loads exist"
|
||
)
|
||
}
|
||
|
||
private var loadsEmptySubtitle: String {
|
||
NSLocalizedString(
|
||
"overview.loads.empty.subtitle",
|
||
bundle: .main,
|
||
value: "Add components to get cable sizing and fuse recommendations tailored to this system.",
|
||
comment: "Subtitle shown in overview when no loads exist"
|
||
)
|
||
}
|
||
|
||
private var loadsEmptyMessage: String {
|
||
NSLocalizedString(
|
||
"loads.overview.empty.message",
|
||
bundle: .main,
|
||
value: "Start by adding a load to see system insights.",
|
||
comment: "Message shown when no loads exist"
|
||
)
|
||
}
|
||
|
||
private var loadsEmptyCreateAction: String {
|
||
NSLocalizedString(
|
||
"loads.overview.empty.create",
|
||
bundle: .main,
|
||
value: "Create Load",
|
||
comment: "Button title to create a new load"
|
||
)
|
||
}
|
||
|
||
private var loadsEmptyBrowseAction: String {
|
||
NSLocalizedString(
|
||
"loads.overview.empty.library",
|
||
bundle: .main,
|
||
value: "Browse Library",
|
||
comment: "Button title to open load library"
|
||
)
|
||
}
|
||
|
||
private var batterySummaryTitle: String {
|
||
NSLocalizedString(
|
||
"battery.bank.header.title",
|
||
bundle: .main,
|
||
value: "Battery Bank",
|
||
comment: "Title for the battery bank summary section"
|
||
)
|
||
}
|
||
|
||
private var batteryCountLabel: String {
|
||
NSLocalizedString(
|
||
"battery.bank.metric.count",
|
||
bundle: .main,
|
||
value: "Batteries",
|
||
comment: "Label for number of batteries metric"
|
||
)
|
||
}
|
||
|
||
private var batteryCapacityLabel: String {
|
||
NSLocalizedString(
|
||
"battery.bank.metric.capacity",
|
||
bundle: .main,
|
||
value: "Capacity",
|
||
comment: "Label for total capacity metric"
|
||
)
|
||
}
|
||
|
||
private var batteryUsableCapacityLabel: String {
|
||
NSLocalizedString(
|
||
"battery.bank.metric.usable_capacity",
|
||
bundle: .main,
|
||
value: "Usable Capacity",
|
||
comment: "Label for usable capacity metric"
|
||
)
|
||
}
|
||
|
||
private var batteryUsableEnergyLabel: String {
|
||
NSLocalizedString(
|
||
"battery.bank.metric.usable_energy",
|
||
bundle: .main,
|
||
value: "Usable Energy",
|
||
comment: "Label for usable energy metric"
|
||
)
|
||
}
|
||
|
||
private var batteryEmptyTitle: String {
|
||
NSLocalizedString(
|
||
"battery.bank.empty.title",
|
||
bundle: .main,
|
||
value: "No Batteries Yet",
|
||
comment: "Title shown when no batteries are configured"
|
||
)
|
||
}
|
||
|
||
private var batteryEmptySubtitle: String {
|
||
let format = NSLocalizedString(
|
||
"battery.bank.empty.subtitle",
|
||
tableName: nil,
|
||
bundle: .main,
|
||
value: "Tap the plus button to configure a battery for %@.",
|
||
comment: "Subtitle shown when no batteries are configured"
|
||
)
|
||
return String(format: format, system.name)
|
||
}
|
||
|
||
private var batteryEmptyCreateAction: String {
|
||
NSLocalizedString(
|
||
"battery.overview.empty.create",
|
||
bundle: .main,
|
||
value: "Create Battery",
|
||
comment: "Button title to create a new battery"
|
||
)
|
||
}
|
||
|
||
private var chargerSummaryTitle: String {
|
||
NSLocalizedString(
|
||
"overview.chargers.header.title",
|
||
bundle: .main,
|
||
value: "Charger Overview",
|
||
comment: "Title for the chargers summary section"
|
||
)
|
||
}
|
||
|
||
private var chargerCountLabel: String {
|
||
NSLocalizedString(
|
||
"chargers.summary.metric.count",
|
||
bundle: .main,
|
||
value: "Chargers",
|
||
comment: "Label for number of chargers metric"
|
||
)
|
||
}
|
||
|
||
private var chargerOutputLabel: String {
|
||
NSLocalizedString(
|
||
"chargers.summary.metric.output",
|
||
bundle: .main,
|
||
value: "Output Voltage",
|
||
comment: "Label for representative output voltage metric"
|
||
)
|
||
}
|
||
|
||
private var chargerCurrentLabel: String {
|
||
NSLocalizedString(
|
||
"chargers.summary.metric.current",
|
||
bundle: .main,
|
||
value: "Charge Rate",
|
||
comment: "Label for total charger current metric"
|
||
)
|
||
}
|
||
|
||
private var chargerPowerLabel: String {
|
||
NSLocalizedString(
|
||
"chargers.summary.metric.power",
|
||
bundle: .main,
|
||
value: "Charge Power",
|
||
comment: "Label for total charger power metric"
|
||
)
|
||
}
|
||
|
||
private var chargerEmptyTitle: String {
|
||
NSLocalizedString(
|
||
"overview.chargers.empty.title",
|
||
bundle: .main,
|
||
value: "No chargers configured yet",
|
||
comment: "Title shown when no chargers are configured"
|
||
)
|
||
}
|
||
|
||
private var chargerEmptySubtitle: String {
|
||
NSLocalizedString(
|
||
"overview.chargers.empty.subtitle",
|
||
bundle: .main,
|
||
value: "Add shore power, DC-DC, or solar chargers to understand your charging capacity.",
|
||
comment: "Subtitle shown when no chargers are configured"
|
||
)
|
||
}
|
||
|
||
private var chargerEmptyCreateAction: String {
|
||
NSLocalizedString(
|
||
"overview.chargers.empty.create",
|
||
bundle: .main,
|
||
value: "Add Charger",
|
||
comment: "Button title to create a charger from the overview"
|
||
)
|
||
}
|
||
|
||
private var systemOverviewTitle: String {
|
||
NSLocalizedString(
|
||
"overview.system.header.title",
|
||
bundle: .main,
|
||
value: "System Overview",
|
||
comment: "Title for system overview card"
|
||
)
|
||
}
|
||
|
||
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",
|
||
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 runtimePlaceholderSummary: String {
|
||
NSLocalizedString(
|
||
"overview.runtime.placeholder.short",
|
||
bundle: .main,
|
||
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"
|
||
)
|
||
}
|
||
|
||
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 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 static let bomItemsPerBattery = 1
|
||
private static let bomItemsPerCharger = 1
|
||
|
||
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"
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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)?
|
||
|
||
@State private var days: Int
|
||
@State private var hours: Int
|
||
@State private var minutes: Int
|
||
private let minuteStepValues: [Int]
|
||
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
init(
|
||
title: String,
|
||
tint: Color,
|
||
value: Binding<Double>,
|
||
minimum: Double,
|
||
maximum: Double,
|
||
step: Double,
|
||
cancelTitle: String,
|
||
saveTitle: String,
|
||
clearTitle: String,
|
||
showsClear: Bool,
|
||
formattedDurationProvider: @escaping (Double) -> String,
|
||
onSave: @escaping (Double) -> Void,
|
||
onClear: (() -> Void)?
|
||
) {
|
||
self.title = title
|
||
self.tint = tint
|
||
self._value = value
|
||
self.minimum = minimum
|
||
self.maximum = maximum
|
||
self.step = step
|
||
self.cancelTitle = cancelTitle
|
||
self.saveTitle = saveTitle
|
||
self.clearTitle = clearTitle
|
||
self.showsClear = showsClear
|
||
self.formattedDurationProvider = formattedDurationProvider
|
||
self.onSave = onSave
|
||
self.onClear = onClear
|
||
self.minuteStepValues = GoalEditorSheet.minuteValues(forStep: step)
|
||
|
||
let initialComponents = GoalEditorSheet.components(
|
||
for: value.wrappedValue,
|
||
minimum: minimum,
|
||
maximum: maximum,
|
||
minuteValues: self.minuteStepValues
|
||
)
|
||
_days = State(initialValue: initialComponents.days)
|
||
_hours = State(initialValue: initialComponents.hours)
|
||
_minutes = State(initialValue: initialComponents.minutes)
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
Label {
|
||
Text(formattedDurationProvider(value))
|
||
.font(.title3.weight(.semibold))
|
||
} icon: {
|
||
Image(systemName: "flag.checkered")
|
||
.symbolRenderingMode(.hierarchical)
|
||
.foregroundStyle(tint)
|
||
}
|
||
|
||
HStack {
|
||
Picker("Days", selection: $days) {
|
||
ForEach(0...maxDays, id: \.self) { day in
|
||
Text("\(day) day\(day == 1 ? "" : "s")")
|
||
.tag(day)
|
||
}
|
||
}
|
||
.pickerStyle(.wheel)
|
||
.frame(maxWidth: .infinity)
|
||
|
||
Picker("Hours", selection: $hours) {
|
||
ForEach(hourRange, id: \.self) { hour in
|
||
Text("\(hour) hr\(hour == 1 ? "" : "s")")
|
||
.tag(hour)
|
||
}
|
||
}
|
||
.pickerStyle(.wheel)
|
||
.frame(maxWidth: .infinity)
|
||
|
||
Picker("Minutes", selection: $minutes) {
|
||
ForEach(minuteOptionsForSelection, id: \.self) { minute in
|
||
Text("\(minute) min\(minute == 1 ? "" : "s")")
|
||
.tag(minute)
|
||
}
|
||
}
|
||
.pickerStyle(.wheel)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.frame(height: 140)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
.onChange(of: days) { _ in
|
||
let cappedHours = min(hours, maxHours(for: days))
|
||
if cappedHours != hours {
|
||
hours = cappedHours
|
||
}
|
||
let allowedMinutes = minuteOptions(for: days, hours: hours)
|
||
if !allowedMinutes.contains(minutes) {
|
||
minutes = allowedMinutes.last ?? 0
|
||
}
|
||
updateValueFromSelection()
|
||
}
|
||
.onChange(of: hours) { _ in
|
||
let allowedMinutes = minuteOptions(for: days, hours: hours)
|
||
if !allowedMinutes.contains(minutes) {
|
||
minutes = allowedMinutes.last ?? 0
|
||
}
|
||
updateValueFromSelection()
|
||
}
|
||
.onChange(of: minutes) { _ in
|
||
updateValueFromSelection()
|
||
}
|
||
.onChange(of: value) { newValue in
|
||
syncPickers(with: newValue)
|
||
}
|
||
.presentationDetents([.medium])
|
||
.presentationDragIndicator(.visible)
|
||
}
|
||
|
||
private var maxDays: Int {
|
||
max(0, Int(floor(maximum / 24)))
|
||
}
|
||
|
||
private var hourRange: ClosedRange<Int> {
|
||
0...maxHours(for: days)
|
||
}
|
||
|
||
private var minuteOptionsForSelection: [Int] {
|
||
minuteOptions(for: days, hours: hours)
|
||
}
|
||
|
||
private var clampedValue: Double {
|
||
clamp(value)
|
||
}
|
||
|
||
private func maxHours(for days: Int) -> Int {
|
||
GoalEditorSheet.maxHours(forDays: days, maximum: maximum)
|
||
}
|
||
|
||
private func minuteOptions(for days: Int, hours: Int) -> [Int] {
|
||
GoalEditorSheet.allowedMinutes(
|
||
forDays: days,
|
||
hours: hours,
|
||
maximum: maximum,
|
||
minuteValues: minuteStepValues
|
||
)
|
||
}
|
||
|
||
private func updateValueFromSelection() {
|
||
let totalHours = Double(days * 24 + hours) + Double(minutes) / 60
|
||
let clamped = clamp(totalHours)
|
||
if abs(value - clamped) > .ulpOfOne {
|
||
value = clamped
|
||
}
|
||
}
|
||
|
||
private func syncPickers(with newValue: Double) {
|
||
let clamped = clamp(newValue)
|
||
let components = GoalEditorSheet.components(
|
||
for: clamped,
|
||
minimum: minimum,
|
||
maximum: maximum,
|
||
minuteValues: minuteStepValues
|
||
)
|
||
if components.days != days {
|
||
days = components.days
|
||
}
|
||
if components.hours != hours {
|
||
hours = components.hours
|
||
}
|
||
if components.minutes != minutes {
|
||
minutes = components.minutes
|
||
}
|
||
}
|
||
|
||
private func clamp(_ candidate: Double) -> Double {
|
||
min(max(candidate, minimum), maximum)
|
||
}
|
||
|
||
private static func components(
|
||
for value: Double,
|
||
minimum: Double,
|
||
maximum: Double,
|
||
minuteValues: [Int]
|
||
) -> (days: Int, hours: Int, minutes: Int) {
|
||
let clamped = min(max(value, minimum), maximum)
|
||
let totalMinutes = Int((clamped * 60).rounded())
|
||
let minutesPerDay = 24 * 60
|
||
let maxDays = max(0, Int(floor(maximum / 24)))
|
||
let rawDays = totalMinutes / minutesPerDay
|
||
let days = min(rawDays, maxDays)
|
||
let remainingAfterDays = totalMinutes - (days * minutesPerDay)
|
||
let rawHours = remainingAfterDays / 60
|
||
let maxHours = maxHours(forDays: days, maximum: maximum)
|
||
let hours = min(rawHours, maxHours)
|
||
let minuteRemainder = remainingAfterDays - hours * 60
|
||
let allowedMinutes = allowedMinutes(
|
||
forDays: days,
|
||
hours: hours,
|
||
maximum: maximum,
|
||
minuteValues: minuteValues
|
||
)
|
||
let minutes = closestMinuteValue(
|
||
target: minuteRemainder,
|
||
allowedMinutes: allowedMinutes
|
||
)
|
||
return (days, hours, minutes)
|
||
}
|
||
|
||
private static func maxHours(forDays days: Int, maximum: Double) -> Int {
|
||
let remaining = maximum - Double(days * 24)
|
||
guard remaining > 0 else { return 0 }
|
||
return min(23, max(0, Int(floor(remaining))))
|
||
}
|
||
|
||
private static func allowedMinutes(
|
||
forDays days: Int,
|
||
hours: Int,
|
||
maximum: Double,
|
||
minuteValues: [Int]
|
||
) -> [Int] {
|
||
guard maximum > 0 else { return [0] }
|
||
let usedHours = Double(days * 24 + hours)
|
||
let remaining = maximum - usedHours
|
||
guard remaining > 0 else { return [0] }
|
||
|
||
let allowed = minuteValues.filter { minute in
|
||
let additional = Double(minute) / 60
|
||
return usedHours + additional <= maximum + 1e-6
|
||
}
|
||
return allowed.isEmpty ? [0] : allowed
|
||
}
|
||
|
||
private static func closestMinuteValue(target: Int, allowedMinutes: [Int]) -> Int {
|
||
guard !allowedMinutes.isEmpty else { return 0 }
|
||
let clampedTarget = max(0, min(59, target))
|
||
return allowedMinutes.min(by: { abs($0 - clampedTarget) < abs($1 - clampedTarget) }) ?? 0
|
||
}
|
||
|
||
private static func minuteValues(forStep step: Double) -> [Int] {
|
||
guard step > 0 else { return [0, 15, 30, 45] }
|
||
let increment = max(1, Int(round(step * 60)))
|
||
guard increment < 60 else { return [0] }
|
||
|
||
var values: [Int] = []
|
||
var current = 0
|
||
while current < 60 {
|
||
values.append(current)
|
||
current += increment
|
||
}
|
||
return values.isEmpty ? [0] : values
|
||
}
|
||
}
|
||
|
||
#Preview("SystemOverview – Populated") {
|
||
let system = ElectricalSystem(
|
||
name: "12V DC System",
|
||
location: "Engine Room",
|
||
iconName: "bolt.circle.fill",
|
||
colorName: "blue",
|
||
targetRuntimeHours: 24,
|
||
targetChargeTimeHours: 3
|
||
)
|
||
//system.targetRuntimeHours = 8
|
||
//system.targetChargeTimeHours = 6
|
||
|
||
let loads: [SavedLoad] = [
|
||
SavedLoad(
|
||
name: "Navigation Lights",
|
||
voltage: 12.8,
|
||
current: 2.4,
|
||
power: 28.8,
|
||
length: 5.0,
|
||
crossSection: 2.5
|
||
),
|
||
SavedLoad(
|
||
name: "Bilge Pump",
|
||
voltage: 12.8,
|
||
current: 8.0,
|
||
power: 96.0,
|
||
length: 3.0,
|
||
crossSection: 4.0
|
||
),
|
||
SavedLoad(
|
||
name: "Chartplotter",
|
||
voltage: 12.8,
|
||
current: 1.5,
|
||
power: 18.0,
|
||
length: 2.0,
|
||
crossSection: 1.5
|
||
)
|
||
]
|
||
|
||
let batteries: [SavedBattery] = [
|
||
SavedBattery(
|
||
name: "House AGM",
|
||
nominalVoltage: 12.0,
|
||
capacityAmpHours: 100.0
|
||
),
|
||
SavedBattery(
|
||
name: "Starter AGM",
|
||
nominalVoltage: 12.0,
|
||
capacityAmpHours: 100.0
|
||
)
|
||
]
|
||
|
||
let chargers: [SavedCharger] = [
|
||
SavedCharger(
|
||
name: "Shore Power",
|
||
inputVoltage: 230,
|
||
outputVoltage: 14.4,
|
||
maxCurrentAmps: 40,
|
||
maxPowerWatts: 580,
|
||
iconName: "powerplug",
|
||
colorName: "orange",
|
||
system: system
|
||
),
|
||
SavedCharger(
|
||
name: "DC-DC Charger",
|
||
inputVoltage: 12.8,
|
||
outputVoltage: 14.2,
|
||
maxCurrentAmps: 30,
|
||
maxPowerWatts: 0,
|
||
iconName: "bolt.circle",
|
||
colorName: "blue",
|
||
system: system
|
||
)
|
||
]
|
||
|
||
SystemOverviewView(
|
||
system: system,
|
||
loads: loads,
|
||
batteries: batteries,
|
||
chargers: chargers,
|
||
onSelectLoads: {},
|
||
onSelectBatteries: {},
|
||
onSelectChargers: {},
|
||
onCreateLoad: {},
|
||
onBrowseLibrary: {},
|
||
onShowBillOfMaterials: {},
|
||
onCreateBattery: {},
|
||
onCreateCharger: {}
|
||
)
|
||
.padding()
|
||
.modelContainer(for: [ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self], inMemory: true)
|
||
}
|
||
|
||
#Preview("SystemOverview – Empty States") {
|
||
let system = ElectricalSystem(
|
||
name: "24V DC System",
|
||
location: "Main Panel",
|
||
iconName: "bolt.circle.fill",
|
||
colorName: "green"
|
||
)
|
||
|
||
return SystemOverviewView(
|
||
system: system,
|
||
loads: [],
|
||
batteries: [],
|
||
chargers: [],
|
||
onSelectLoads: {},
|
||
onSelectBatteries: {},
|
||
onSelectChargers: {},
|
||
onCreateLoad: {},
|
||
onBrowseLibrary: {},
|
||
onShowBillOfMaterials: {},
|
||
onCreateBattery: {},
|
||
onCreateCharger: {}
|
||
)
|
||
.padding()
|
||
.modelContainer(for: [ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self], inMemory: true)
|
||
}
|