battery persistence
This commit is contained in:
322
Cable/BatteriesView.swift
Normal file
322
Cable/BatteriesView.swift
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct BatteriesView: View {
|
||||||
|
let system: ElectricalSystem
|
||||||
|
let batteries: [SavedBattery]
|
||||||
|
let onEdit: (SavedBattery) -> Void
|
||||||
|
let onDelete: (IndexSet) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
system: ElectricalSystem,
|
||||||
|
batteries: [SavedBattery],
|
||||||
|
onEdit: @escaping (SavedBattery) -> Void = { _ in },
|
||||||
|
onDelete: @escaping (IndexSet) -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.system = system
|
||||||
|
self.batteries = batteries
|
||||||
|
self.onEdit = onEdit
|
||||||
|
self.onDelete = onDelete
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if batteries.isEmpty {
|
||||||
|
emptyState
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
summarySection
|
||||||
|
|
||||||
|
List {
|
||||||
|
ForEach(batteries) { battery in
|
||||||
|
Button {
|
||||||
|
onEdit(battery)
|
||||||
|
} label: {
|
||||||
|
batteryRow(for: battery)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
.onDelete(perform: onDelete)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var summarySection: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text("Battery Bank")
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
Spacer()
|
||||||
|
Text(system.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
summaryMetric(
|
||||||
|
icon: "battery.100",
|
||||||
|
label: "Batteries",
|
||||||
|
value: "\(batteries.count)",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "gauge.medium",
|
||||||
|
label: "Capacity",
|
||||||
|
value: formattedValue(totalCapacity, unit: "Ah"),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "bolt.circle",
|
||||||
|
label: "Energy",
|
||||||
|
value: formattedValue(totalEnergy, unit: "Wh"),
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
summaryMetric(
|
||||||
|
icon: "battery.100",
|
||||||
|
label: "Batteries",
|
||||||
|
value: "\(batteries.count)",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "gauge.medium",
|
||||||
|
label: "Capacity",
|
||||||
|
value: formattedValue(totalCapacity, unit: "Ah"),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "bolt.circle",
|
||||||
|
label: "Energy",
|
||||||
|
value: formattedValue(totalEnergy, unit: "Wh"),
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.background(Color(.separator))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func batteryRow(for battery: SavedBattery) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
batteryIcon
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(battery.name)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
Spacer()
|
||||||
|
Text(formattedValue(battery.nominalVoltage, unit: "V"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(battery.chemistry.displayName)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
Capsule(style: .continuous)
|
||||||
|
.fill(Color(.tertiarySystemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
metricBadge(
|
||||||
|
label: "Voltage",
|
||||||
|
value: formattedValue(battery.nominalVoltage, unit: "V"),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
metricBadge(
|
||||||
|
label: "Capacity",
|
||||||
|
value: formattedValue(battery.capacityAmpHours, unit: "Ah"),
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
metricBadge(
|
||||||
|
label: "Energy",
|
||||||
|
value: formattedValue(battery.energyWattHours, unit: "Wh"),
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.fill(Color(.systemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batteryIcon: some View {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(colorForName(system.colorName))
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
Image(systemName: "battery.100.bolt")
|
||||||
|
.font(.system(size: 22, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.fill(tint.opacity(0.12))
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(Color(.secondarySystemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metricBadge(label: String, value: String, tint: Color) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.fill(tint.opacity(0.12))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 emptyState: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "battery.100")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("No Batteries Yet")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text("Tap the plus button to configure a battery for \(system.name).")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let numberFormatter: NumberFormatter = {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.locale = .current
|
||||||
|
formatter.minimumFractionDigits = 0
|
||||||
|
formatter.maximumFractionDigits = 1
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
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 enum BatteriesViewPreviewData {
|
||||||
|
static let system = ElectricalSystem(name: "Preview System", iconName: "bolt", colorName: "green")
|
||||||
|
static let batteries: [SavedBattery] = [
|
||||||
|
SavedBattery(
|
||||||
|
name: "House Bank",
|
||||||
|
nominalVoltage: 12.8,
|
||||||
|
capacityAmpHours: 200,
|
||||||
|
chemistry: .lithiumIronPhosphate,
|
||||||
|
system: system
|
||||||
|
),
|
||||||
|
SavedBattery(
|
||||||
|
name: "Starter Battery",
|
||||||
|
nominalVoltage: 12.0,
|
||||||
|
capacityAmpHours: 90,
|
||||||
|
chemistry: .agm,
|
||||||
|
system: system
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
BatteriesView(
|
||||||
|
system: BatteriesViewPreviewData.system,
|
||||||
|
batteries: BatteriesViewPreviewData.batteries
|
||||||
|
)
|
||||||
|
}
|
||||||
63
Cable/BatteryConfiguration.swift
Normal file
63
Cable/BatteryConfiguration.swift
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct BatteryConfiguration: Identifiable {
|
||||||
|
enum Chemistry: String, CaseIterable, Identifiable {
|
||||||
|
case agm = "AGM"
|
||||||
|
case gel = "Gel"
|
||||||
|
case floodedLeadAcid = "Flooded Lead Acid"
|
||||||
|
case lithiumIronPhosphate = "LiFePO4"
|
||||||
|
case lithiumIon = "Lithium Ion"
|
||||||
|
|
||||||
|
var id: Self { self }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: UUID
|
||||||
|
var name: String
|
||||||
|
var nominalVoltage: Double
|
||||||
|
var capacityAmpHours: Double
|
||||||
|
var chemistry: Chemistry
|
||||||
|
var system: ElectricalSystem
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String,
|
||||||
|
nominalVoltage: Double = 12.8,
|
||||||
|
capacityAmpHours: Double = 100,
|
||||||
|
chemistry: Chemistry = .lithiumIronPhosphate,
|
||||||
|
system: ElectricalSystem
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.nominalVoltage = nominalVoltage
|
||||||
|
self.capacityAmpHours = capacityAmpHours
|
||||||
|
self.chemistry = chemistry
|
||||||
|
self.system = system
|
||||||
|
}
|
||||||
|
|
||||||
|
init(savedBattery: SavedBattery, system: ElectricalSystem) {
|
||||||
|
self.id = savedBattery.id
|
||||||
|
self.name = savedBattery.name
|
||||||
|
self.nominalVoltage = savedBattery.nominalVoltage
|
||||||
|
self.capacityAmpHours = savedBattery.capacityAmpHours
|
||||||
|
self.chemistry = savedBattery.chemistry
|
||||||
|
self.system = system
|
||||||
|
}
|
||||||
|
|
||||||
|
var energyWattHours: Double {
|
||||||
|
nominalVoltage * capacityAmpHours
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(to savedBattery: SavedBattery) {
|
||||||
|
savedBattery.name = name
|
||||||
|
savedBattery.nominalVoltage = nominalVoltage
|
||||||
|
savedBattery.capacityAmpHours = capacityAmpHours
|
||||||
|
savedBattery.chemistry = chemistry
|
||||||
|
savedBattery.system = system
|
||||||
|
savedBattery.timestamp = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
243
Cable/BatteryEditorView.swift
Normal file
243
Cable/BatteryEditorView.swift
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct BatteryEditorView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var configuration: BatteryConfiguration
|
||||||
|
|
||||||
|
let onSave: (BatteryConfiguration) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
init(configuration: BatteryConfiguration, onSave: @escaping (BatteryConfiguration) -> Void, onCancel: @escaping () -> Void) {
|
||||||
|
_configuration = State(initialValue: configuration)
|
||||||
|
self.onSave = onSave
|
||||||
|
self.onCancel = onCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
headerCard
|
||||||
|
slidersSection
|
||||||
|
}
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||||
|
.navigationTitle(
|
||||||
|
NSLocalizedString(
|
||||||
|
"battery.editor.title",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Battery Setup",
|
||||||
|
comment: "Title for the battery editor"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(
|
||||||
|
NSLocalizedString(
|
||||||
|
"battery.editor.cancel",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Cancel",
|
||||||
|
comment: "Cancel button title"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(
|
||||||
|
NSLocalizedString(
|
||||||
|
"battery.editor.save",
|
||||||
|
bundle: .main,
|
||||||
|
value: "Save",
|
||||||
|
comment: "Save button title"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
.disabled(configuration.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Name")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("House Bank", text: $configuration.name)
|
||||||
|
.textInputAutocapitalization(.words)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(Color(.secondarySystemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Chemistry")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Menu {
|
||||||
|
ForEach(BatteryConfiguration.Chemistry.allCases) { chemistry in
|
||||||
|
Button {
|
||||||
|
configuration.chemistry = chemistry
|
||||||
|
} label: {
|
||||||
|
if chemistry == configuration.chemistry {
|
||||||
|
Label(chemistry.displayName, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(chemistry.displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(configuration.chemistry.displayName)
|
||||||
|
.font(.body.weight(.semibold))
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.footnote.weight(.bold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(Color(.secondarySystemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Summary")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
summaryBadge(
|
||||||
|
title: "Voltage",
|
||||||
|
value: formattedValue(configuration.nominalVoltage, unit: "V"),
|
||||||
|
symbol: "bolt"
|
||||||
|
)
|
||||||
|
summaryBadge(
|
||||||
|
title: "Capacity",
|
||||||
|
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
|
||||||
|
symbol: "gauge.medium"
|
||||||
|
)
|
||||||
|
summaryBadge(
|
||||||
|
title: "Energy",
|
||||||
|
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
|
||||||
|
symbol: "battery.100.bolt"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
summaryBadge(
|
||||||
|
title: "Voltage",
|
||||||
|
value: formattedValue(configuration.nominalVoltage, unit: "V"),
|
||||||
|
symbol: "bolt"
|
||||||
|
)
|
||||||
|
summaryBadge(
|
||||||
|
title: "Capacity",
|
||||||
|
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
|
||||||
|
symbol: "gauge.medium"
|
||||||
|
)
|
||||||
|
summaryBadge(
|
||||||
|
title: "Energy",
|
||||||
|
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
|
||||||
|
symbol: "battery.100.bolt"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.fill(Color(.tertiarySystemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var slidersSection: some View {
|
||||||
|
VStack(spacing: 30) {
|
||||||
|
SliderSection(
|
||||||
|
title: "Nominal Voltage",
|
||||||
|
value: $configuration.nominalVoltage,
|
||||||
|
range: 6...60,
|
||||||
|
unit: "V",
|
||||||
|
snapValues: [6, 12, 12.8, 24, 25.6, 36, 48, 51.2]
|
||||||
|
)
|
||||||
|
SliderSection(
|
||||||
|
title: "Capacity",
|
||||||
|
value: $configuration.capacityAmpHours,
|
||||||
|
range: 5...1000,
|
||||||
|
unit: "Ah",
|
||||||
|
snapValues: [10, 20, 50, 75, 100, 125, 150, 200, 300, 400, 600, 800, 1000]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.fill(Color(.secondarySystemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func summaryBadge(title: String, value: String, symbol: String) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: symbol)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
Text(title)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(Color(.secondarySystemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let numberFormatter: NumberFormatter = {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.locale = .current
|
||||||
|
formatter.minimumFractionDigits = 0
|
||||||
|
formatter.maximumFractionDigits = 1
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
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 save() {
|
||||||
|
onSave(configuration)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancel() {
|
||||||
|
onCancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let previewSystem = ElectricalSystem(name: "Camper")
|
||||||
|
return NavigationStack {
|
||||||
|
BatteryEditorView(
|
||||||
|
configuration: BatteryConfiguration(name: "House Bank", system: previewSystem),
|
||||||
|
onSave: { _ in },
|
||||||
|
onCancel: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,13 +15,13 @@ struct CableApp: App {
|
|||||||
var sharedModelContainer: ModelContainer = {
|
var sharedModelContainer: ModelContainer = {
|
||||||
do {
|
do {
|
||||||
// Try the simple approach first
|
// Try the simple approach first
|
||||||
return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, Item.self)
|
return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, Item.self)
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to create ModelContainer with simple approach: \(error)")
|
print("Failed to create ModelContainer with simple approach: \(error)")
|
||||||
|
|
||||||
// Try in-memory as fallback
|
// Try in-memory as fallback
|
||||||
do {
|
do {
|
||||||
let schema = Schema([ElectricalSystem.self, SavedLoad.self, Item.self])
|
let schema = Schema([ElectricalSystem.self, SavedLoad.self, SavedBattery.self, Item.self])
|
||||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
28
Cable/ChargersView.swift
Normal file
28
Cable/ChargersView.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ChargersView: View {
|
||||||
|
let system: ElectricalSystem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "bolt.fill")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text("Chargers for \(system.name)")
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text("Charger components will be available soon.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ChargersView(system: ElectricalSystem(name: "Preview System"))
|
||||||
|
}
|
||||||
@@ -13,12 +13,15 @@ struct LoadsView: View {
|
|||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||||
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
|
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
|
||||||
|
@Query(sort: \SavedBattery.timestamp, order: .reverse) private var allBatteries: [SavedBattery]
|
||||||
@State private var newLoadToEdit: SavedLoad?
|
@State private var newLoadToEdit: SavedLoad?
|
||||||
@State private var showingSystemEditor = false
|
@State private var showingSystemEditor = false
|
||||||
@State private var hasPresentedSystemEditorOnAppear = false
|
@State private var hasPresentedSystemEditorOnAppear = false
|
||||||
@State private var hasOpenedLoadOnAppear = false
|
@State private var hasOpenedLoadOnAppear = false
|
||||||
@State private var showingComponentLibrary = false
|
@State private var showingComponentLibrary = false
|
||||||
@State private var showingSystemBOM = false
|
@State private var showingSystemBOM = false
|
||||||
|
@State private var selectedComponentTab: ComponentTab = .components
|
||||||
|
@State private var batteryDraft: BatteryConfiguration?
|
||||||
|
|
||||||
let system: ElectricalSystem
|
let system: ElectricalSystem
|
||||||
private let presentSystemEditorOnAppear: Bool
|
private let presentSystemEditorOnAppear: Bool
|
||||||
@@ -33,93 +36,38 @@ struct LoadsView: View {
|
|||||||
private var savedLoads: [SavedLoad] {
|
private var savedLoads: [SavedLoad] {
|
||||||
allLoads.filter { $0.system == system }
|
allLoads.filter { $0.system == system }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var savedBatteries: [SavedBattery] {
|
||||||
|
allBatteries.filter { $0.system == system }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if savedLoads.isEmpty {
|
if savedLoads.isEmpty {
|
||||||
emptyStateView
|
emptyStateView
|
||||||
} else {
|
} else {
|
||||||
librarySection
|
TabView(selection: $selectedComponentTab) {
|
||||||
|
componentsTab
|
||||||
List {
|
.tag(ComponentTab.components)
|
||||||
ForEach(savedLoads) { load in
|
.tabItem {
|
||||||
NavigationLink(destination: CalculatorView(savedLoad: load)) {
|
Label("Components", systemImage: "square.stack.3d.up")
|
||||||
HStack(spacing: 12) {
|
}
|
||||||
LoadIconView(
|
BatteriesView(
|
||||||
remoteIconURLString: load.remoteIconURLString,
|
system: system,
|
||||||
fallbackSystemName: load.iconName,
|
batteries: savedBatteries,
|
||||||
fallbackColor: colorForName(load.colorName),
|
onEdit: { editBattery($0) },
|
||||||
size: 44)
|
onDelete: deleteBatteries
|
||||||
|
)
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
.tag(ComponentTab.batteries)
|
||||||
Text(load.name)
|
.tabItem {
|
||||||
.fontWeight(.medium)
|
Label("Batteries", systemImage: "battery.100")
|
||||||
.lineLimit(1)
|
}
|
||||||
.truncationMode(.tail)
|
ChargersView(system: system)
|
||||||
|
.tag(ComponentTab.chargers)
|
||||||
// Secondary info
|
.tabItem {
|
||||||
HStack {
|
Label("Chargers", systemImage: "bolt.fill")
|
||||||
Group {
|
|
||||||
Text(String(format: "%.1fV", load.voltage))
|
|
||||||
Text("•")
|
|
||||||
if load.isWattMode {
|
|
||||||
Text(String(format: "%.0fW", load.power))
|
|
||||||
} else {
|
|
||||||
Text(String(format: "%.1fA", load.current))
|
|
||||||
}
|
|
||||||
Text("•")
|
|
||||||
Text(String(format: "%.1f%@",
|
|
||||||
unitSettings.unitSystem == .metric ? load.length : load.length * 3.28084,
|
|
||||||
unitSettings.unitSystem.lengthUnit))
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prominent fuse and wire gauge display
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("FUSE")
|
|
||||||
.font(.caption2)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text("\(recommendedFuse(for: load))A")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.orange.opacity(0.1))
|
|
||||||
.cornerRadius(6)
|
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text("WIRE")
|
|
||||||
.font(.caption2)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Text(String(format: unitSettings.unitSystem == .imperial ? "%.0f AWG" : "%.1fmm²",
|
|
||||||
unitSettings.unitSystem == .imperial ? awgFromCrossSection(load.crossSection) : load.crossSection))
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.bold)
|
|
||||||
.foregroundColor(.blue)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.blue.opacity(0.1))
|
|
||||||
.cornerRadius(6)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.onDelete(perform: deleteLoads)
|
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier("loads-list")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -149,7 +97,7 @@ struct LoadsView: View {
|
|||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack {
|
HStack {
|
||||||
if !savedLoads.isEmpty {
|
if !savedLoads.isEmpty && selectedComponentTab == .components {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingSystemBOM = true
|
showingSystemBOM = true
|
||||||
}) {
|
}) {
|
||||||
@@ -158,17 +106,34 @@ struct LoadsView: View {
|
|||||||
.accessibilityIdentifier("system-bom-button")
|
.accessibilityIdentifier("system-bom-button")
|
||||||
}
|
}
|
||||||
Button(action: {
|
Button(action: {
|
||||||
createNewLoad()
|
handlePrimaryAction()
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
}
|
}
|
||||||
EditButton()
|
.disabled(selectedComponentTab == .chargers)
|
||||||
|
if selectedComponentTab == .components || selectedComponentTab == .batteries {
|
||||||
|
EditButton()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(item: $newLoadToEdit) { load in
|
.navigationDestination(item: $newLoadToEdit) { load in
|
||||||
CalculatorView(savedLoad: load)
|
CalculatorView(savedLoad: load)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $batteryDraft) { draft in
|
||||||
|
NavigationStack {
|
||||||
|
BatteryEditorView(
|
||||||
|
configuration: draft,
|
||||||
|
onSave: { configuration in
|
||||||
|
saveBattery(configuration)
|
||||||
|
batteryDraft = nil
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
batteryDraft = nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingComponentLibrary) {
|
.sheet(isPresented: $showingComponentLibrary) {
|
||||||
ComponentLibraryView { item in
|
ComponentLibraryView { item in
|
||||||
addComponent(item)
|
addComponent(item)
|
||||||
@@ -253,7 +218,166 @@ struct LoadsView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var componentsTab: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
librarySection
|
||||||
|
|
||||||
|
List {
|
||||||
|
ForEach(savedLoads) { load in
|
||||||
|
Button {
|
||||||
|
selectLoad(load)
|
||||||
|
} label: {
|
||||||
|
loadRow(for: load)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.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")
|
||||||
|
}
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectLoad(_ load: SavedLoad) {
|
||||||
|
newLoadToEdit = load
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadRow(for load: SavedLoad) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
LoadIconView(
|
||||||
|
remoteIconURLString: load.remoteIconURLString,
|
||||||
|
fallbackSystemName: load.iconName,
|
||||||
|
fallbackColor: colorForName(load.colorName),
|
||||||
|
size: 48
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(load.name)
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
|
||||||
|
Text(loadSummary(for: load))
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
Capsule(style: .continuous)
|
||||||
|
.fill(Color(.tertiarySystemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
metricBadge(
|
||||||
|
label: "Fuse",
|
||||||
|
value: "\(recommendedFuse(for: load)) A",
|
||||||
|
tint: .pink
|
||||||
|
)
|
||||||
|
metricBadge(
|
||||||
|
label: "Cable",
|
||||||
|
value: wireGaugeString(for: load),
|
||||||
|
tint: .teal
|
||||||
|
)
|
||||||
|
metricBadge(
|
||||||
|
label: "Length",
|
||||||
|
value: lengthString(for: load),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
metricBadge(
|
||||||
|
label: "Fuse",
|
||||||
|
value: "\(recommendedFuse(for: load)) A",
|
||||||
|
tint: .pink
|
||||||
|
)
|
||||||
|
metricBadge(
|
||||||
|
label: "Cable",
|
||||||
|
value: wireGaugeString(for: load),
|
||||||
|
tint: .teal
|
||||||
|
)
|
||||||
|
metricBadge(
|
||||||
|
label: "Length",
|
||||||
|
value: lengthString(for: load),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.fill(Color(.systemBackground))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadSummary(for load: SavedLoad) -> String {
|
||||||
|
let voltageText = String(format: "%.1fV", load.voltage)
|
||||||
|
let lengthText: String
|
||||||
|
if unitSettings.unitSystem == .metric {
|
||||||
|
lengthText = String(format: "%.1f%@", load.length, unitSettings.unitSystem.lengthUnit)
|
||||||
|
} else {
|
||||||
|
let imperialLength = load.length * 3.28084
|
||||||
|
lengthText = String(format: "%.1f%@", imperialLength, unitSettings.unitSystem.lengthUnit)
|
||||||
|
}
|
||||||
|
let powerOrCurrent = load.isWattMode
|
||||||
|
? String(format: "%.0fW", load.power)
|
||||||
|
: String(format: "%.1fA", load.current)
|
||||||
|
return [voltageText, powerOrCurrent, lengthText].joined(separator: " • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wireGaugeString(for load: SavedLoad) -> String {
|
||||||
|
if unitSettings.unitSystem == .imperial {
|
||||||
|
let awgValue = awgFromCrossSection(load.crossSection)
|
||||||
|
return String(format: "%.0f AWG", awgValue)
|
||||||
|
} else {
|
||||||
|
return String(format: "%.1f mm²", load.crossSection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lengthString(for load: SavedLoad) -> String {
|
||||||
|
if unitSettings.unitSystem == .imperial {
|
||||||
|
let imperialLength = load.length * 3.28084
|
||||||
|
return String(format: "%.1f ft", imperialLength)
|
||||||
|
} else {
|
||||||
|
return String(format: "%.1f m", load.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metricBadge(label: String, value: String, tint: Color) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label.uppercased())
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(value)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||||
|
.fill(tint.opacity(0.12))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private var emptyStateView: some View {
|
private var emptyStateView: some View {
|
||||||
ComponentsOnboardingView(
|
ComponentsOnboardingView(
|
||||||
onCreate: { createNewLoad() },
|
onCreate: { createNewLoad() },
|
||||||
@@ -268,83 +392,69 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handlePrimaryAction() {
|
||||||
|
switch selectedComponentTab {
|
||||||
|
case .components:
|
||||||
|
createNewLoad()
|
||||||
|
case .batteries:
|
||||||
|
startBatteryConfiguration()
|
||||||
|
case .chargers:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func createNewLoad() {
|
private func createNewLoad() {
|
||||||
let defaultName = String(localized: "default.load.new", comment: "Default name when creating a new load from system view")
|
let newLoad = SystemComponentsPersistence.createDefaultLoad(
|
||||||
let loadName = uniqueLoadName(startingWith: defaultName)
|
for: system,
|
||||||
let newLoad = SavedLoad(
|
in: modelContext,
|
||||||
name: loadName,
|
existingLoads: savedLoads,
|
||||||
voltage: 12.0,
|
existingBatteries: savedBatteries
|
||||||
current: 5.0,
|
|
||||||
power: 60.0, // 12V * 5A = 60W
|
|
||||||
length: 10.0,
|
|
||||||
crossSection: 1.0,
|
|
||||||
iconName: "lightbulb",
|
|
||||||
colorName: "blue",
|
|
||||||
isWattMode: false,
|
|
||||||
system: system,
|
|
||||||
remoteIconURLString: nil
|
|
||||||
)
|
)
|
||||||
modelContext.insert(newLoad)
|
|
||||||
|
|
||||||
// Navigate to the new load
|
|
||||||
newLoadToEdit = newLoad
|
newLoadToEdit = newLoad
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startBatteryConfiguration() {
|
||||||
|
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
|
||||||
|
for: system,
|
||||||
|
existingLoads: savedLoads,
|
||||||
|
existingBatteries: savedBatteries
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveBattery(_ configuration: BatteryConfiguration) {
|
||||||
|
SystemComponentsPersistence.saveBattery(
|
||||||
|
configuration,
|
||||||
|
for: system,
|
||||||
|
existingBatteries: savedBatteries,
|
||||||
|
in: modelContext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func editBattery(_ battery: SavedBattery) {
|
||||||
|
batteryDraft = BatteryConfiguration(savedBattery: battery, system: system)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteBatteries(_ offsets: IndexSet) {
|
||||||
|
withAnimation {
|
||||||
|
SystemComponentsPersistence.deleteBatteries(
|
||||||
|
at: offsets,
|
||||||
|
from: savedBatteries,
|
||||||
|
in: modelContext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func addComponent(_ item: ComponentLibraryItem) {
|
private func addComponent(_ item: ComponentLibraryItem) {
|
||||||
let localizedName = item.localizedName
|
let newLoad = SystemComponentsPersistence.createLoad(
|
||||||
let baseName = localizedName.isEmpty ? "Library Load" : localizedName
|
from: item,
|
||||||
let loadName = uniqueLoadName(startingWith: baseName)
|
for: system,
|
||||||
let voltage = item.displayVoltage ?? 12.0
|
in: modelContext,
|
||||||
let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0)
|
existingLoads: savedLoads,
|
||||||
let current: Double
|
existingBatteries: savedBatteries
|
||||||
if let explicitCurrent = item.current {
|
|
||||||
current = explicitCurrent
|
|
||||||
} else if voltage > 0 {
|
|
||||||
current = power / voltage
|
|
||||||
} else {
|
|
||||||
current = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
let affiliateLink = item.primaryAffiliateLink
|
|
||||||
|
|
||||||
let newLoad = SavedLoad(
|
|
||||||
name: loadName,
|
|
||||||
voltage: voltage,
|
|
||||||
current: current,
|
|
||||||
power: power,
|
|
||||||
length: 10.0,
|
|
||||||
crossSection: 1.0,
|
|
||||||
iconName: "lightbulb",
|
|
||||||
colorName: "blue",
|
|
||||||
isWattMode: item.watt != nil,
|
|
||||||
system: system,
|
|
||||||
remoteIconURLString: item.iconURL?.absoluteString,
|
|
||||||
affiliateURLString: affiliateLink?.url.absoluteString,
|
|
||||||
affiliateCountryCode: affiliateLink?.country
|
|
||||||
)
|
)
|
||||||
|
|
||||||
modelContext.insert(newLoad)
|
|
||||||
newLoadToEdit = newLoad
|
newLoadToEdit = newLoad
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uniqueLoadName(startingWith baseName: String) -> String {
|
|
||||||
let existingNames = Set(savedLoads.map { $0.name })
|
|
||||||
|
|
||||||
if !existingNames.contains(baseName) {
|
|
||||||
return baseName
|
|
||||||
}
|
|
||||||
|
|
||||||
var counter = 2
|
|
||||||
var candidate = "\(baseName) \(counter)"
|
|
||||||
|
|
||||||
while existingNames.contains(candidate) {
|
|
||||||
counter += 1
|
|
||||||
candidate = "\(baseName) \(counter)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidate
|
|
||||||
}
|
|
||||||
|
|
||||||
private func colorForName(_ colorName: String) -> Color {
|
private func colorForName(_ colorName: String) -> Color {
|
||||||
switch colorName {
|
switch colorName {
|
||||||
@@ -384,4 +494,10 @@ struct LoadsView: View {
|
|||||||
// Find the smallest standard fuse that's >= target
|
// Find the smallest standard fuse that's >= target
|
||||||
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
|
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum ComponentTab: Hashable {
|
||||||
|
case components
|
||||||
|
case batteries
|
||||||
|
case chargers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
Cable/SavedBattery.swift
Normal file
44
Cable/SavedBattery.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
class SavedBattery {
|
||||||
|
@Attribute(.unique) var id: UUID
|
||||||
|
var name: String
|
||||||
|
var nominalVoltage: Double
|
||||||
|
var capacityAmpHours: Double
|
||||||
|
private var chemistryRawValue: String
|
||||||
|
var system: ElectricalSystem?
|
||||||
|
var timestamp: Date
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
name: String,
|
||||||
|
nominalVoltage: Double = 12.8,
|
||||||
|
capacityAmpHours: Double = 100,
|
||||||
|
chemistry: BatteryConfiguration.Chemistry = .lithiumIronPhosphate,
|
||||||
|
system: ElectricalSystem? = nil,
|
||||||
|
timestamp: Date = Date()
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.nominalVoltage = nominalVoltage
|
||||||
|
self.capacityAmpHours = capacityAmpHours
|
||||||
|
self.chemistryRawValue = chemistry.rawValue
|
||||||
|
self.system = system
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
var chemistry: BatteryConfiguration.Chemistry {
|
||||||
|
get {
|
||||||
|
BatteryConfiguration.Chemistry(rawValue: chemistryRawValue) ?? .lithiumIronPhosphate
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
chemistryRawValue = newValue.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var energyWattHours: Double {
|
||||||
|
nominalVoltage * capacityAmpHours
|
||||||
|
}
|
||||||
|
}
|
||||||
159
Cable/SystemComponentsPersistence.swift
Normal file
159
Cable/SystemComponentsPersistence.swift
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct SystemComponentsPersistence {
|
||||||
|
static func createDefaultLoad(
|
||||||
|
for system: ElectricalSystem,
|
||||||
|
in context: ModelContext,
|
||||||
|
existingLoads: [SavedLoad],
|
||||||
|
existingBatteries: [SavedBattery]
|
||||||
|
) -> SavedLoad {
|
||||||
|
let defaultName = String(
|
||||||
|
localized: "default.load.new",
|
||||||
|
comment: "Default name when creating a new load from system view"
|
||||||
|
)
|
||||||
|
let loadName = uniqueName(
|
||||||
|
startingWith: defaultName,
|
||||||
|
loads: existingLoads,
|
||||||
|
batteries: existingBatteries
|
||||||
|
)
|
||||||
|
let newLoad = SavedLoad(
|
||||||
|
name: loadName,
|
||||||
|
voltage: 12.0,
|
||||||
|
current: 5.0,
|
||||||
|
power: 60.0,
|
||||||
|
length: 10.0,
|
||||||
|
crossSection: 1.0,
|
||||||
|
iconName: "lightbulb",
|
||||||
|
colorName: "blue",
|
||||||
|
isWattMode: false,
|
||||||
|
system: system,
|
||||||
|
remoteIconURLString: nil
|
||||||
|
)
|
||||||
|
context.insert(newLoad)
|
||||||
|
return newLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createLoad(
|
||||||
|
from item: ComponentLibraryItem,
|
||||||
|
for system: ElectricalSystem,
|
||||||
|
in context: ModelContext,
|
||||||
|
existingLoads: [SavedLoad],
|
||||||
|
existingBatteries: [SavedBattery]
|
||||||
|
) -> SavedLoad {
|
||||||
|
let localizedName = item.localizedName
|
||||||
|
let baseName = localizedName.isEmpty ? "Library Load" : localizedName
|
||||||
|
let loadName = uniqueName(
|
||||||
|
startingWith: baseName,
|
||||||
|
loads: existingLoads,
|
||||||
|
batteries: existingBatteries
|
||||||
|
)
|
||||||
|
let voltage = item.displayVoltage ?? 12.0
|
||||||
|
let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0)
|
||||||
|
let current: Double
|
||||||
|
if let explicitCurrent = item.current {
|
||||||
|
current = explicitCurrent
|
||||||
|
} else if voltage > 0 {
|
||||||
|
current = power / voltage
|
||||||
|
} else {
|
||||||
|
current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let affiliateLink = item.primaryAffiliateLink
|
||||||
|
|
||||||
|
let newLoad = SavedLoad(
|
||||||
|
name: loadName,
|
||||||
|
voltage: voltage,
|
||||||
|
current: current,
|
||||||
|
power: power,
|
||||||
|
length: 10.0,
|
||||||
|
crossSection: 1.0,
|
||||||
|
iconName: "lightbulb",
|
||||||
|
colorName: "blue",
|
||||||
|
isWattMode: item.watt != nil,
|
||||||
|
system: system,
|
||||||
|
remoteIconURLString: item.iconURL?.absoluteString,
|
||||||
|
affiliateURLString: affiliateLink?.url.absoluteString,
|
||||||
|
affiliateCountryCode: affiliateLink?.country
|
||||||
|
)
|
||||||
|
|
||||||
|
context.insert(newLoad)
|
||||||
|
return newLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeBatteryDraft(
|
||||||
|
for system: ElectricalSystem,
|
||||||
|
existingLoads: [SavedLoad],
|
||||||
|
existingBatteries: [SavedBattery]
|
||||||
|
) -> BatteryConfiguration {
|
||||||
|
let defaultName = NSLocalizedString(
|
||||||
|
"battery.editor.default_name",
|
||||||
|
bundle: .main,
|
||||||
|
value: "New Battery",
|
||||||
|
comment: "Default name when configuring a new battery"
|
||||||
|
)
|
||||||
|
let batteryName = uniqueName(
|
||||||
|
startingWith: defaultName,
|
||||||
|
loads: existingLoads,
|
||||||
|
batteries: existingBatteries
|
||||||
|
)
|
||||||
|
return BatteryConfiguration(
|
||||||
|
name: batteryName,
|
||||||
|
system: system
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveBattery(
|
||||||
|
_ configuration: BatteryConfiguration,
|
||||||
|
for system: ElectricalSystem,
|
||||||
|
existingBatteries: [SavedBattery],
|
||||||
|
in context: ModelContext
|
||||||
|
) {
|
||||||
|
if let existing = existingBatteries.first(where: { $0.id == configuration.id }) {
|
||||||
|
configuration.apply(to: existing)
|
||||||
|
} else {
|
||||||
|
let newBattery = SavedBattery(
|
||||||
|
id: configuration.id,
|
||||||
|
name: configuration.name,
|
||||||
|
nominalVoltage: configuration.nominalVoltage,
|
||||||
|
capacityAmpHours: configuration.capacityAmpHours,
|
||||||
|
chemistry: configuration.chemistry,
|
||||||
|
system: system
|
||||||
|
)
|
||||||
|
context.insert(newBattery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deleteBatteries(
|
||||||
|
at offsets: IndexSet,
|
||||||
|
from batteries: [SavedBattery],
|
||||||
|
in context: ModelContext
|
||||||
|
) {
|
||||||
|
for index in offsets {
|
||||||
|
context.delete(batteries[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func uniqueName(
|
||||||
|
startingWith baseName: String,
|
||||||
|
loads: [SavedLoad],
|
||||||
|
batteries: [SavedBattery]
|
||||||
|
) -> String {
|
||||||
|
let existingNames = Set(loads.map { $0.name } + batteries.map { $0.name })
|
||||||
|
|
||||||
|
if !existingNames.contains(baseName) {
|
||||||
|
return baseName
|
||||||
|
}
|
||||||
|
|
||||||
|
var counter = 2
|
||||||
|
var candidate = "\(baseName) \(counter)"
|
||||||
|
|
||||||
|
while existingNames.contains(candidate) {
|
||||||
|
counter += 1
|
||||||
|
candidate = "\(baseName) \(counter)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user