1014 lines
36 KiB
Swift
1014 lines
36 KiB
Swift
//
|
|
// CalculatorView.swift
|
|
// Cable
|
|
//
|
|
// Created by Stefan Lange-Hegermann on 11.09.25.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct CalculatorView: View {
|
|
@EnvironmentObject var unitSettings: UnitSystemSettings
|
|
@StateObject private var calculator = CableCalculator()
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Query private var savedLoads: [SavedLoad]
|
|
@State private var showingLibrary = false
|
|
@State private var isWattMode = false
|
|
@State private var editingValue: EditingValue? = nil
|
|
@State private var showingLoadEditor = false
|
|
@State private var presentedAffiliateLink: AffiliateLinkInfo?
|
|
@State private var completedItemIDs: Set<String>
|
|
|
|
let savedLoad: SavedLoad?
|
|
|
|
init(savedLoad: SavedLoad? = nil) {
|
|
self.savedLoad = savedLoad
|
|
_completedItemIDs = State(initialValue: Set(savedLoad?.bomCompletedItemIDs ?? []))
|
|
}
|
|
|
|
enum EditingValue {
|
|
case voltage, current, power, length
|
|
}
|
|
|
|
struct AffiliateLinkInfo: Identifiable, Equatable {
|
|
let id: String
|
|
let affiliateURL: URL?
|
|
let buttonTitle: String
|
|
let regionName: String?
|
|
let countryCode: String?
|
|
}
|
|
|
|
struct BOMItem: Identifiable, Equatable {
|
|
enum Destination: Equatable {
|
|
case affiliate(URL)
|
|
case amazonSearch(String)
|
|
}
|
|
|
|
let id: String
|
|
let title: String
|
|
let detail: String
|
|
let iconSystemName: String
|
|
let destination: Destination
|
|
let isPrimaryComponent: Bool
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
badgesSection
|
|
circuitDiagram
|
|
resultsSection
|
|
mainContent
|
|
}
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.navigationTitle("")
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
navigationTitle
|
|
}
|
|
|
|
if savedLoad == nil {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Save") {
|
|
saveCurrentLoad()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingLibrary) {
|
|
LoadLibraryView(calculator: calculator)
|
|
}
|
|
.sheet(isPresented: $showingLoadEditor) {
|
|
LoadEditorView(
|
|
loadName: Binding(
|
|
get: { calculator.loadName },
|
|
set: {
|
|
calculator.loadName = $0
|
|
autoUpdateSavedLoad()
|
|
}
|
|
),
|
|
iconName: Binding(
|
|
get: { savedLoad?.iconName ?? "lightbulb" },
|
|
set: { newValue in
|
|
guard let savedLoad else { return }
|
|
savedLoad.iconName = newValue
|
|
savedLoad.remoteIconURLString = nil
|
|
autoUpdateSavedLoad()
|
|
}
|
|
),
|
|
colorName: Binding(
|
|
get: { savedLoad?.colorName ?? "blue" },
|
|
set: {
|
|
savedLoad?.colorName = $0
|
|
autoUpdateSavedLoad()
|
|
}
|
|
),
|
|
remoteIconURLString: Binding(
|
|
get: { savedLoad?.remoteIconURLString },
|
|
set: { newValue in
|
|
guard let savedLoad else { return }
|
|
savedLoad.remoteIconURLString = newValue
|
|
autoUpdateSavedLoad()
|
|
}
|
|
)
|
|
)
|
|
}
|
|
.sheet(item: $presentedAffiliateLink) { info in
|
|
BillOfMaterialsView(
|
|
info: info,
|
|
items: buildBillOfMaterialsItems(deviceLink: info.affiliateURL),
|
|
completedItemIDs: $completedItemIDs
|
|
)
|
|
}
|
|
.alert("Edit Length", isPresented: Binding(
|
|
get: { editingValue == .length },
|
|
set: { if !$0 { editingValue = nil } }
|
|
)) {
|
|
TextField("Length", value: $calculator.length, format: .number)
|
|
.keyboardType(.decimalPad)
|
|
Button("Cancel", role: .cancel) { editingValue = nil }
|
|
Button("Save") {
|
|
editingValue = nil
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} message: {
|
|
Text("Enter length in \(unitSettings.unitSystem.lengthUnit)")
|
|
}
|
|
.alert("Edit Voltage", isPresented: Binding(
|
|
get: { editingValue == .voltage },
|
|
set: { if !$0 { editingValue = nil } }
|
|
)) {
|
|
TextField("Voltage", value: $calculator.voltage, format: .number)
|
|
.keyboardType(.decimalPad)
|
|
Button("Cancel", role: .cancel) { editingValue = nil }
|
|
Button("Save") {
|
|
editingValue = nil
|
|
if isWattMode {
|
|
calculator.updateFromPower()
|
|
} else {
|
|
calculator.updateFromCurrent()
|
|
}
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} message: {
|
|
Text("Enter voltage in volts (V)")
|
|
}
|
|
.alert("Edit Current", isPresented: Binding(
|
|
get: { editingValue == .current },
|
|
set: { if !$0 { editingValue = nil } }
|
|
)) {
|
|
TextField("Current", value: $calculator.current, format: .number)
|
|
.keyboardType(.decimalPad)
|
|
Button("Cancel", role: .cancel) { editingValue = nil }
|
|
Button("Save") {
|
|
editingValue = nil
|
|
calculator.updateFromCurrent()
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} message: {
|
|
Text("Enter current in amperes (A)")
|
|
}
|
|
.alert("Edit Power", isPresented: Binding(
|
|
get: { editingValue == .power },
|
|
set: { if !$0 { editingValue = nil } }
|
|
)) {
|
|
TextField("Power", value: $calculator.power, format: .number)
|
|
.keyboardType(.decimalPad)
|
|
Button("Cancel", role: .cancel) { editingValue = nil }
|
|
Button("Save") {
|
|
editingValue = nil
|
|
calculator.updateFromPower()
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} message: {
|
|
Text("Enter power in watts (W)")
|
|
}
|
|
.onAppear {
|
|
if let savedLoad = savedLoad {
|
|
loadConfiguration(from: savedLoad)
|
|
}
|
|
}
|
|
.onChange(of: completedItemIDs) { _ in
|
|
persistCompletedItems()
|
|
}
|
|
}
|
|
|
|
private var loadIcon: String {
|
|
savedLoad?.iconName ?? "lightbulb"
|
|
}
|
|
|
|
private var loadColor: Color {
|
|
let colorName = savedLoad?.colorName ?? "blue"
|
|
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 loadRemoteIconURLString: String? {
|
|
savedLoad?.remoteIconURLString
|
|
}
|
|
|
|
private var affiliateLinkInfo: AffiliateLinkInfo? {
|
|
guard let savedLoad else { return nil }
|
|
|
|
let affiliateURL: URL?
|
|
if let urlString = savedLoad.affiliateURLString,
|
|
let parsedURL = URL(string: urlString) {
|
|
affiliateURL = parsedURL
|
|
} else {
|
|
affiliateURL = nil
|
|
}
|
|
|
|
let rawCountryCode = savedLoad.affiliateCountryCode ?? Locale.current.regionCode
|
|
let countryCode = rawCountryCode?.uppercased()
|
|
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 }
|
|
|
|
let buttonTitle = "Review parts"
|
|
let identifier = "bom-\(savedLoad.name)-\(savedLoad.timestamp.timeIntervalSince1970)"
|
|
|
|
return AffiliateLinkInfo(
|
|
id: identifier,
|
|
affiliateURL: affiliateURL,
|
|
buttonTitle: buttonTitle,
|
|
regionName: regionName,
|
|
countryCode: countryCode
|
|
)
|
|
}
|
|
|
|
private var navigationTitle: some View {
|
|
Button(action: {
|
|
showingLoadEditor = true
|
|
}) {
|
|
HStack(spacing: 8) {
|
|
LoadIconView(
|
|
remoteIconURLString: loadRemoteIconURLString,
|
|
fallbackSystemName: loadIcon,
|
|
fallbackColor: loadColor,
|
|
size: 24)
|
|
|
|
Text(calculator.loadName)
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var badgesSection: some View {
|
|
HStack(spacing: 12) {
|
|
HStack(spacing: 4) {
|
|
Text("FUSE")
|
|
.font(.caption2)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.secondary)
|
|
Text("\(calculator.recommendedFuse)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 ?
|
|
"%.1fft @ %.0f AWG" :
|
|
"%.1fm @ %.1fmm²",
|
|
unitSettings.unitSystem == .imperial ? calculator.length * 3.28084 : calculator.length,
|
|
calculator.crossSection(for: unitSettings.unitSystem)))
|
|
.font(.subheadline)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.blue)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.blue.opacity(0.1))
|
|
.cornerRadius(6)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|
|
|
|
private var circuitDiagram: some View {
|
|
HStack(spacing: 0) {
|
|
voltageSection
|
|
wiringSection
|
|
loadBox
|
|
}
|
|
.padding()
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|
|
|
|
private var voltageSection: some View {
|
|
VStack(spacing: 0) {
|
|
VStack() {
|
|
HStack {
|
|
Button(action: {
|
|
editingValue = .voltage
|
|
}) {
|
|
Text(String(format: "%.1fV", calculator.voltage))
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: 60)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var wiringSection: some View {
|
|
VStack {
|
|
HStack(alignment: .center, spacing: 2) {
|
|
Circle()
|
|
.fill(.red)
|
|
.frame(width: 8, height: 8)
|
|
Rectangle()
|
|
.fill(.red)
|
|
.frame(height: 4)
|
|
.frame(width: 10)
|
|
Rectangle()
|
|
.fill(.green)
|
|
.frame(height: 30)
|
|
.frame(width:80)
|
|
.overlay(
|
|
Text("\(calculator.recommendedFuse)A")
|
|
.foregroundColor(.white)
|
|
.fontWeight(.bold)
|
|
)
|
|
.cornerRadius(6)
|
|
|
|
Rectangle()
|
|
.fill(.red)
|
|
.frame(height: 4)
|
|
.frame(maxWidth: .infinity)
|
|
}.offset(x: 0, y: -11)
|
|
|
|
HStack(alignment: .center, spacing: 2) {
|
|
Circle()
|
|
.fill(Color.primary)
|
|
.frame(width: 8, height: 8)
|
|
|
|
Rectangle()
|
|
.fill(Color.primary)
|
|
.frame(height: 4)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var loadBox: some View {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color.gray.opacity(0.2))
|
|
.frame(width: 120, height: 90)
|
|
.overlay(loadContent)
|
|
.overlay(connectionPoints)
|
|
}
|
|
}
|
|
|
|
private var loadContent: some View {
|
|
VStack {
|
|
Text(calculator.loadName.uppercased())
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Button(action: {
|
|
editingValue = isWattMode ? .power : .current
|
|
}) {
|
|
Text(String(format: "%.1fA", calculator.current))
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button(action: {
|
|
editingValue = isWattMode ? .current : .power
|
|
}) {
|
|
Text(String(format: "%.0fW", calculator.calculatedPower))
|
|
.font(.caption)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private var connectionPoints: some View {
|
|
VStack {
|
|
HStack {
|
|
Circle()
|
|
.fill(.red)
|
|
.frame(width: 8, height: 8)
|
|
.offset(x: -4, y: 17)
|
|
Spacer()
|
|
}
|
|
Spacer()
|
|
HStack {
|
|
Circle()
|
|
.fill(Color.primary)
|
|
.frame(width: 8, height: 8)
|
|
.offset(x: -4, y: -17)
|
|
Spacer()
|
|
}
|
|
}
|
|
.frame(width: 120, height: 80)
|
|
}
|
|
|
|
private var resultsSection: some View {
|
|
HStack {
|
|
Group {
|
|
Button(action: {}) {
|
|
Text(unitSettings.unitSystem == .imperial ?
|
|
String(format: "%.0f AWG", calculator.crossSection(for: unitSettings.unitSystem)) :
|
|
String(format: "%.1f mm²", calculator.crossSection(for: unitSettings.unitSystem)))
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.blue)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Text("•").foregroundColor(.secondary)
|
|
|
|
Button(action: { editingValue = .length }) {
|
|
Text(String(format: "%.1f %@", calculator.length, unitSettings.unitSystem.lengthUnit))
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Text("•").foregroundColor(.secondary)
|
|
|
|
Button(action: {}) {
|
|
Text(String(format: "%.1fV (%.1f%%)", calculator.voltageDrop(for: unitSettings.unitSystem), calculator.voltageDropPercentage(for: unitSettings.unitSystem)))
|
|
.fontWeight(.medium)
|
|
.foregroundColor(calculator.voltageDropPercentage(for: unitSettings.unitSystem) > 5 ? .orange : .primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Text("•").foregroundColor(.secondary)
|
|
|
|
Button(action: {}) {
|
|
Text(String(format: "%.1fW", calculator.powerLoss(for: unitSettings.unitSystem)))
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.font(.caption)
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
private var mainContent: some View {
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
Divider().padding(.horizontal)
|
|
slidersSection
|
|
if let info = affiliateLinkInfo {
|
|
affiliateLinkSection(info: info)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func affiliateLinkSection(info: AffiliateLinkInfo) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Button {
|
|
presentedAffiliateLink = info
|
|
} label: {
|
|
Label(info.buttonTitle, systemImage: "cart")
|
|
.font(.callout.weight(.semibold))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, 16)
|
|
.foregroundColor(.accentColor)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(Color.accentColor.opacity(0.12))
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Text(info.affiliateURL != nil ? "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan." : "Tapping above shows a full bill of materials with shopping searches to help you source parts.")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding()
|
|
.background(Color(.secondarySystemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
private func buildBillOfMaterialsItems(deviceLink: URL?) -> [BOMItem] {
|
|
let unitSystem = unitSettings.unitSystem
|
|
let lengthValue = calculator.length
|
|
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
|
|
|
|
let crossSectionValue = calculator.crossSection(for: unitSystem)
|
|
let crossSectionLabel: String
|
|
if unitSystem == .imperial {
|
|
crossSectionLabel = String(format: "AWG %.0f", crossSectionValue)
|
|
} else {
|
|
crossSectionLabel = String(format: "%.1f mm²", crossSectionValue)
|
|
}
|
|
|
|
let cableDetail = "\(lengthLabel) • \(crossSectionLabel)"
|
|
let powerDetail = String(format: "%.0f W @ %.1f V", calculator.calculatedPower, calculator.voltage)
|
|
|
|
let fuseRating = calculator.recommendedFuse
|
|
let fuseDetail = "Inline holder and \(fuseRating)A fuse"
|
|
|
|
let cableShoesDetail = "Ring or spade terminals sized for \(crossSectionLabel) wiring"
|
|
|
|
let cableGaugeQuery: String
|
|
if unitSystem == .imperial {
|
|
cableGaugeQuery = String(format: "AWG %.0f", crossSectionValue)
|
|
} else {
|
|
cableGaugeQuery = String(format: "%.1f mm2", crossSectionValue)
|
|
}
|
|
|
|
let redCableQuery = "\(cableGaugeQuery) red battery cable"
|
|
let blackCableQuery = "\(cableGaugeQuery) black battery cable"
|
|
let fuseQuery = "inline fuse holder \(fuseRating)A"
|
|
let terminalQuery = "\(cableGaugeQuery) cable shoes"
|
|
let deviceQueryBase = calculator.loadName.isEmpty
|
|
? String(format: "DC device %.0fW %.0fV", calculator.calculatedPower, calculator.voltage)
|
|
: calculator.loadName
|
|
|
|
var items: [BOMItem] = []
|
|
|
|
items.append(
|
|
BOMItem(
|
|
id: "component",
|
|
title: calculator.loadName.isEmpty ? "Component" : calculator.loadName,
|
|
detail: powerDetail,
|
|
iconSystemName: "bolt.fill",
|
|
destination: deviceLink.map { .affiliate($0) } ?? .amazonSearch(deviceQueryBase),
|
|
isPrimaryComponent: true
|
|
)
|
|
)
|
|
|
|
items.append(
|
|
BOMItem(
|
|
id: "cable-red",
|
|
title: "Power Cable (Red)",
|
|
detail: cableDetail,
|
|
iconSystemName: "bolt.horizontal.circle",
|
|
destination: .amazonSearch(redCableQuery),
|
|
isPrimaryComponent: false
|
|
)
|
|
)
|
|
|
|
items.append(
|
|
BOMItem(
|
|
id: "cable-black",
|
|
title: "Power Cable (Black)",
|
|
detail: cableDetail,
|
|
iconSystemName: "bolt.horizontal.circle",
|
|
destination: .amazonSearch(blackCableQuery),
|
|
isPrimaryComponent: false
|
|
)
|
|
)
|
|
|
|
items.append(
|
|
BOMItem(
|
|
id: "fuse",
|
|
title: "Fuse & Holder",
|
|
detail: fuseDetail,
|
|
iconSystemName: "bolt.shield",
|
|
destination: .amazonSearch(fuseQuery),
|
|
isPrimaryComponent: false
|
|
)
|
|
)
|
|
|
|
items.append(
|
|
BOMItem(
|
|
id: "terminals",
|
|
title: "Cable Shoes / Terminals",
|
|
detail: cableShoesDetail,
|
|
iconSystemName: "wrench.and.screwdriver",
|
|
destination: .amazonSearch(terminalQuery),
|
|
isPrimaryComponent: false
|
|
)
|
|
)
|
|
|
|
return items
|
|
}
|
|
|
|
private var slidersSection: some View {
|
|
VStack(spacing: 30) {
|
|
voltageSlider
|
|
currentPowerSlider
|
|
lengthSlider
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
private var voltageSlider: some View {
|
|
SliderSection(title: "Voltage",
|
|
value: $calculator.voltage,
|
|
range: 3...48,
|
|
unit: "V",
|
|
tapAction: { editingValue = .voltage },
|
|
snapValues: [3.3, 5.0, 6.0, 9.0, 12.0, 13.8, 24.0, 28.0, 48.0])
|
|
.onChange(of: calculator.voltage) {
|
|
if isWattMode {
|
|
calculator.updateFromPower()
|
|
} else {
|
|
calculator.updateFromCurrent()
|
|
}
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var currentPowerSlider: some View {
|
|
if isWattMode {
|
|
SliderSection(title: "Power",
|
|
value: $calculator.power,
|
|
range: 0...2000,
|
|
unit: "W",
|
|
buttonText: "Watt",
|
|
buttonAction: {
|
|
isWattMode = false
|
|
calculator.updateFromPower()
|
|
autoUpdateSavedLoad()
|
|
},
|
|
tapAction: { editingValue = .power },
|
|
snapValues: [5, 10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 125, 150, 200, 250, 300, 400, 500, 600, 750, 1000, 1250, 1500, 2000])
|
|
.onChange(of: calculator.power) {
|
|
calculator.updateFromPower()
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} else {
|
|
SliderSection(title: "Current",
|
|
value: $calculator.current,
|
|
range: 0...100,
|
|
unit: "A",
|
|
buttonText: "Ampere",
|
|
buttonAction: {
|
|
isWattMode = true
|
|
calculator.updateFromCurrent()
|
|
autoUpdateSavedLoad()
|
|
},
|
|
tapAction: { editingValue = .current },
|
|
snapValues: [0.5, 1, 2, 3, 4, 5, 7.5, 10, 12, 15, 20, 25, 30, 40, 50, 60, 75, 80, 100])
|
|
.onChange(of: calculator.current) {
|
|
calculator.updateFromCurrent()
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var lengthSlider: some View {
|
|
SliderSection(title: "Cable Length (\(unitSettings.unitSystem.lengthUnit))",
|
|
value: $calculator.length,
|
|
range: 0...20,
|
|
unit: unitSettings.unitSystem.lengthUnit,
|
|
tapAction: { editingValue = .length },
|
|
snapValues: unitSettings.unitSystem == .metric ?
|
|
[0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7.5, 10, 12, 15, 20] :
|
|
[1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60])
|
|
.onChange(of: calculator.length) {
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
}
|
|
|
|
|
|
private func saveCurrentLoad() {
|
|
let savedLoad = SavedLoad(
|
|
name: calculator.loadName.isEmpty ? "Unnamed Load" : calculator.loadName,
|
|
voltage: calculator.voltage,
|
|
current: calculator.current,
|
|
power: calculator.power,
|
|
length: calculator.length,
|
|
crossSection: calculator.crossSection(for: .metric), // Always save in metric
|
|
iconName: "lightbulb",
|
|
colorName: "blue",
|
|
isWattMode: isWattMode,
|
|
system: nil, // For now, new loads aren't associated with a system
|
|
remoteIconURLString: nil
|
|
)
|
|
modelContext.insert(savedLoad)
|
|
}
|
|
|
|
private func loadConfiguration(from savedLoad: SavedLoad) {
|
|
calculator.loadName = savedLoad.name
|
|
calculator.voltage = savedLoad.voltage
|
|
calculator.current = savedLoad.current
|
|
calculator.power = savedLoad.power
|
|
calculator.length = savedLoad.length
|
|
isWattMode = savedLoad.isWattMode
|
|
completedItemIDs = Set(savedLoad.bomCompletedItemIDs)
|
|
}
|
|
|
|
private func autoUpdateSavedLoad() {
|
|
guard let savedLoad = savedLoad else { return }
|
|
|
|
savedLoad.name = calculator.loadName.isEmpty ? "Unnamed Load" : calculator.loadName
|
|
savedLoad.voltage = calculator.voltage
|
|
savedLoad.current = calculator.current
|
|
savedLoad.power = calculator.power
|
|
savedLoad.length = calculator.length
|
|
savedLoad.crossSection = calculator.crossSection(for: .metric)
|
|
savedLoad.timestamp = Date()
|
|
savedLoad.isWattMode = isWattMode
|
|
savedLoad.bomCompletedItemIDs = Array(completedItemIDs).sorted()
|
|
// Icon and color are updated directly through bindings in the editor
|
|
}
|
|
|
|
private func persistCompletedItems() {
|
|
guard let savedLoad = savedLoad else { return }
|
|
savedLoad.bomCompletedItemIDs = Array(completedItemIDs).sorted()
|
|
}
|
|
}
|
|
|
|
// MARK: - Supporting Views
|
|
|
|
private struct BillOfMaterialsView: View {
|
|
let info: CalculatorView.AffiliateLinkInfo
|
|
let items: [CalculatorView.BOMItem]
|
|
@Binding var completedItemIDs: Set<String>
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.openURL) private var openURL
|
|
@State private var suppressRowTapForID: String?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section("Components") {
|
|
ForEach(items) { item in
|
|
let isCompleted = completedItemIDs.contains(item.id)
|
|
let destinationURL = destinationURL(for: item)
|
|
|
|
HStack(spacing: 12) {
|
|
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
|
|
.foregroundColor(isCompleted ? .accentColor : .secondary)
|
|
.imageScale(.large)
|
|
.onTapGesture {
|
|
if isCompleted {
|
|
completedItemIDs.remove(item.id)
|
|
} else {
|
|
completedItemIDs.insert(item.id)
|
|
}
|
|
suppressRowTapForID = item.id
|
|
}
|
|
.accessibilityLabel(isCompleted ? "Mark \(item.title) incomplete" : "Mark \(item.title) complete")
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(item.title)
|
|
.font(.headline)
|
|
.foregroundStyle(isCompleted ? Color.primary.opacity(0.7) : Color.primary)
|
|
.strikethrough(isCompleted, color: .accentColor.opacity(0.6))
|
|
|
|
if item.isPrimaryComponent {
|
|
Text("Component")
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundColor(.accentColor)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.accentColor.opacity(0.15), in: Capsule())
|
|
}
|
|
|
|
Text(item.detail)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
if destinationURL != nil {
|
|
Image(systemName: "arrow.up.right")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 10)
|
|
.contentShape(Rectangle())
|
|
.listRowInsets(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 16))
|
|
.listRowBackground(
|
|
Color(.secondarySystemGroupedBackground)
|
|
)
|
|
.onTapGesture {
|
|
if suppressRowTapForID == item.id {
|
|
suppressRowTapForID = nil
|
|
return
|
|
}
|
|
if let destinationURL {
|
|
openURL(destinationURL)
|
|
}
|
|
completedItemIDs.insert(item.id)
|
|
suppressRowTapForID = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Text("Purchases through affiliate links may support VoltPlan.")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.navigationTitle("Bill of Materials")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Close") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func destinationURL(for item: CalculatorView.BOMItem) -> URL? {
|
|
switch item.destination {
|
|
case .affiliate(let url):
|
|
return url
|
|
case .amazonSearch(let query):
|
|
return AmazonAffiliate.searchURL(query: query, countryCode: info.countryCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SliderSection: View {
|
|
let title: String
|
|
@Binding var value: Double
|
|
let range: ClosedRange<Double>
|
|
let unit: String
|
|
var buttonText: String?
|
|
var buttonAction: (() -> Void)?
|
|
var tapAction: (() -> Void)?
|
|
var snapValues: [Double]?
|
|
|
|
init(title: String, value: Binding<Double>, range: ClosedRange<Double>, unit: String, buttonText: String? = nil, buttonAction: (() -> Void)? = nil, tapAction: (() -> Void)? = nil, snapValues: [Double]? = nil) {
|
|
self.title = title
|
|
self._value = value
|
|
self.range = range
|
|
self.unit = unit
|
|
self.buttonText = buttonText
|
|
self.buttonAction = buttonAction
|
|
self.tapAction = tapAction
|
|
self.snapValues = snapValues
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text(title)
|
|
.font(.headline)
|
|
Spacer()
|
|
if let buttonText = buttonText {
|
|
Button(buttonText) {
|
|
buttonAction?()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
|
|
Button(action: {
|
|
tapAction?()
|
|
}) {
|
|
Text(String(format: "%.1f %@", value, unit))
|
|
.font(.title)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(tapAction == nil)
|
|
|
|
HStack {
|
|
Text(String(format: "%.0f%@", range.lowerBound, unit))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Slider(value: $value, in: range)
|
|
.onChange(of: value) {
|
|
// Always round to 1 decimal place first
|
|
value = round(value * 10) / 10
|
|
|
|
if let snapValues = snapValues {
|
|
// Find the closest snap value
|
|
let closest = snapValues.min { abs($0 - value) < abs($1 - value) }
|
|
if let closest = closest, abs(closest - value) < 0.5 {
|
|
value = closest
|
|
}
|
|
}
|
|
}
|
|
|
|
Text(String(format: "%.0f%@", range.upperBound, unit))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct LoadLibraryView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Query private var savedLoads: [SavedLoad]
|
|
let calculator: CableCalculator
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(savedLoads) { load in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(load.name)
|
|
.fontWeight(.medium)
|
|
HStack {
|
|
Text(String(format: "%.1fV", load.voltage))
|
|
Text("•")
|
|
Text(String(format: "%.1fA", load.current))
|
|
Text("•")
|
|
Text(String(format: "%.1fm", load.length))
|
|
Spacer()
|
|
Text(load.timestamp, format: .dateTime.month().day())
|
|
.foregroundColor(.secondary)
|
|
.font(.caption)
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
loadConfiguration(from: load)
|
|
dismiss()
|
|
}
|
|
}
|
|
.onDelete(perform: deleteLoads)
|
|
}
|
|
.navigationTitle("Load Library")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
EditButton()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadConfiguration(from savedLoad: SavedLoad) {
|
|
calculator.loadName = savedLoad.name
|
|
calculator.voltage = savedLoad.voltage
|
|
calculator.current = savedLoad.current
|
|
calculator.length = savedLoad.length
|
|
}
|
|
|
|
private func deleteLoads(offsets: IndexSet) {
|
|
for index in offsets {
|
|
modelContext.delete(savedLoads[index])
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
CalculatorView()
|
|
.modelContainer(for: SavedLoad.self, inMemory: true)
|
|
.environmentObject(UnitSystemSettings())
|
|
}
|