diff --git a/Cable/BatteriesView.swift b/Cable/BatteriesView.swift new file mode 100644 index 0000000..9b5613e --- /dev/null +++ b/Cable/BatteriesView.swift @@ -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 + ) +} diff --git a/Cable/BatteryConfiguration.swift b/Cable/BatteryConfiguration.swift new file mode 100644 index 0000000..715689d --- /dev/null +++ b/Cable/BatteryConfiguration.swift @@ -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() + } +} diff --git a/Cable/BatteryEditorView.swift b/Cable/BatteryEditorView.swift new file mode 100644 index 0000000..e7aeab1 --- /dev/null +++ b/Cable/BatteryEditorView.swift @@ -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: {} + ) + } +} diff --git a/Cable/CableApp.swift b/Cable/CableApp.swift index 38b210c..ca21175 100644 --- a/Cable/CableApp.swift +++ b/Cable/CableApp.swift @@ -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 { diff --git a/Cable/ChargersView.swift b/Cable/ChargersView.swift new file mode 100644 index 0000000..11204c1 --- /dev/null +++ b/Cable/ChargersView.swift @@ -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")) +} diff --git a/Cable/LoadsView.swift b/Cable/LoadsView.swift index d22e609..968fe0d 100644 --- a/Cable/LoadsView.swift +++ b/Cable/LoadsView.swift @@ -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 + } } diff --git a/Cable/SavedBattery.swift b/Cable/SavedBattery.swift new file mode 100644 index 0000000..7833333 --- /dev/null +++ b/Cable/SavedBattery.swift @@ -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 + } +} diff --git a/Cable/SystemComponentsPersistence.swift b/Cable/SystemComponentsPersistence.swift new file mode 100644 index 0000000..b0972d3 --- /dev/null +++ b/Cable/SystemComponentsPersistence.swift @@ -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 + } +}