battery persistence

This commit is contained in:
Stefan Lange-Hegermann
2025-10-21 09:55:43 +02:00
parent 3c366dc454
commit 28ad6dd10c
8 changed files with 1127 additions and 152 deletions

322
Cable/BatteriesView.swift Normal file
View 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
)
}

View 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()
}
}

View 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: {}
)
}
}

View File

@@ -15,13 +15,13 @@ struct CableApp: App {
var sharedModelContainer: ModelContainer = {
do {
// 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 {
print("Failed to create ModelContainer with simple approach: \(error)")
// Try in-memory as fallback
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)
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {

28
Cable/ChargersView.swift Normal file
View 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"))
}

View File

@@ -13,12 +13,15 @@ struct LoadsView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var unitSettings: UnitSystemSettings
@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 showingSystemEditor = false
@State private var hasPresentedSystemEditorOnAppear = false
@State private var hasOpenedLoadOnAppear = false
@State private var showingComponentLibrary = false
@State private var showingSystemBOM = false
@State private var selectedComponentTab: ComponentTab = .components
@State private var batteryDraft: BatteryConfiguration?
let system: ElectricalSystem
private let presentSystemEditorOnAppear: Bool
@@ -33,93 +36,38 @@ struct LoadsView: View {
private var savedLoads: [SavedLoad] {
allLoads.filter { $0.system == system }
}
private var savedBatteries: [SavedBattery] {
allBatteries.filter { $0.system == system }
}
var body: some View {
VStack(spacing: 0) {
if savedLoads.isEmpty {
emptyStateView
} else {
librarySection
List {
ForEach(savedLoads) { load in
NavigationLink(destination: CalculatorView(savedLoad: load)) {
HStack(spacing: 12) {
LoadIconView(
remoteIconURLString: load.remoteIconURLString,
fallbackSystemName: load.iconName,
fallbackColor: colorForName(load.colorName),
size: 44)
VStack(alignment: .leading, spacing: 6) {
Text(load.name)
.fontWeight(.medium)
.lineLimit(1)
.truncationMode(.tail)
// Secondary info
HStack {
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)
TabView(selection: $selectedComponentTab) {
componentsTab
.tag(ComponentTab.components)
.tabItem {
Label("Components", systemImage: "square.stack.3d.up")
}
BatteriesView(
system: system,
batteries: savedBatteries,
onEdit: { editBattery($0) },
onDelete: deleteBatteries
)
.tag(ComponentTab.batteries)
.tabItem {
Label("Batteries", systemImage: "battery.100")
}
ChargersView(system: system)
.tag(ComponentTab.chargers)
.tabItem {
Label("Chargers", systemImage: "bolt.fill")
}
}
.onDelete(perform: deleteLoads)
}
.accessibilityIdentifier("loads-list")
}
}
.navigationBarTitleDisplayMode(.inline)
@@ -149,7 +97,7 @@ struct LoadsView: View {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
if !savedLoads.isEmpty {
if !savedLoads.isEmpty && selectedComponentTab == .components {
Button(action: {
showingSystemBOM = true
}) {
@@ -158,17 +106,34 @@ struct LoadsView: View {
.accessibilityIdentifier("system-bom-button")
}
Button(action: {
createNewLoad()
handlePrimaryAction()
}) {
Image(systemName: "plus")
}
EditButton()
.disabled(selectedComponentTab == .chargers)
if selectedComponentTab == .components || selectedComponentTab == .batteries {
EditButton()
}
}
}
}
.navigationDestination(item: $newLoadToEdit) { load in
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) {
ComponentLibraryView { item in
addComponent(item)
@@ -253,7 +218,166 @@ struct LoadsView: View {
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 {
ComponentsOnboardingView(
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() {
let defaultName = String(localized: "default.load.new", comment: "Default name when creating a new load from system view")
let loadName = uniqueLoadName(startingWith: defaultName)
let newLoad = SavedLoad(
name: loadName,
voltage: 12.0,
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
let newLoad = SystemComponentsPersistence.createDefaultLoad(
for: system,
in: modelContext,
existingLoads: savedLoads,
existingBatteries: savedBatteries
)
modelContext.insert(newLoad)
// Navigate to the new load
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) {
let localizedName = item.localizedName
let baseName = localizedName.isEmpty ? "Library Load" : localizedName
let loadName = uniqueLoadName(startingWith: baseName)
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
let newLoad = SystemComponentsPersistence.createLoad(
from: item,
for: system,
in: modelContext,
existingLoads: savedLoads,
existingBatteries: savedBatteries
)
modelContext.insert(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 {
switch colorName {
@@ -384,4 +494,10 @@ struct LoadsView: View {
// Find the smallest standard fuse that's >= target
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
View 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
}
}

View 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
}
}