BOM with checklist for shopping
This commit is contained in:
@@ -136,8 +136,10 @@ class SavedLoad {
|
||||
var remoteIconURLString: String? = nil
|
||||
var affiliateURLString: String? = nil
|
||||
var affiliateCountryCode: String? = nil
|
||||
var bomCompletedItemIDs: [String] = []
|
||||
var identifier: String = UUID().uuidString
|
||||
|
||||
init(name: String, voltage: Double, current: Double, power: Double, length: Double, crossSection: Double, iconName: String = "lightbulb", colorName: String = "blue", isWattMode: Bool = false, system: ElectricalSystem? = nil, remoteIconURLString: String? = nil, affiliateURLString: String? = nil, affiliateCountryCode: String? = nil) {
|
||||
init(name: String, voltage: Double, current: Double, power: Double, length: Double, crossSection: Double, iconName: String = "lightbulb", colorName: String = "blue", isWattMode: Bool = false, system: ElectricalSystem? = nil, remoteIconURLString: String? = nil, affiliateURLString: String? = nil, affiliateCountryCode: String? = nil, bomCompletedItemIDs: [String] = [], identifier: String = UUID().uuidString) {
|
||||
self.name = name
|
||||
self.voltage = voltage
|
||||
self.current = current
|
||||
@@ -152,5 +154,7 @@ class SavedLoad {
|
||||
self.remoteIconURLString = remoteIconURLString
|
||||
self.affiliateURLString = affiliateURLString
|
||||
self.affiliateCountryCode = affiliateCountryCode
|
||||
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||
self.identifier = identifier
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ struct CalculatorView: View {
|
||||
@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 {
|
||||
@@ -38,17 +40,18 @@ struct CalculatorView: View {
|
||||
let countryCode: String?
|
||||
}
|
||||
|
||||
struct BOMItem: Identifiable {
|
||||
struct BOMItem: Identifiable, Equatable {
|
||||
enum Destination: Equatable {
|
||||
case affiliate(URL)
|
||||
case amazonSearch(String)
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
let id: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let iconSystemName: String
|
||||
let destination: Destination
|
||||
let isPrimaryComponent: Bool
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -114,7 +117,8 @@ struct CalculatorView: View {
|
||||
.sheet(item: $presentedAffiliateLink) { info in
|
||||
BillOfMaterialsView(
|
||||
info: info,
|
||||
items: buildBillOfMaterialsItems(deviceLink: info.affiliateURL)
|
||||
items: buildBillOfMaterialsItems(deviceLink: info.affiliateURL),
|
||||
completedItemIDs: $completedItemIDs
|
||||
)
|
||||
}
|
||||
.alert("Edit Length", isPresented: Binding(
|
||||
@@ -189,6 +193,9 @@ struct CalculatorView: View {
|
||||
loadConfiguration(from: savedLoad)
|
||||
}
|
||||
}
|
||||
.onChange(of: completedItemIDs) { _ in
|
||||
persistCompletedItems()
|
||||
}
|
||||
}
|
||||
|
||||
private var loadIcon: String {
|
||||
@@ -569,38 +576,64 @@ struct CalculatorView: View {
|
||||
? String(format: "DC device %.0fW %.0fV", calculator.calculatedPower, calculator.voltage)
|
||||
: calculator.loadName
|
||||
|
||||
return [
|
||||
BOMItem(
|
||||
title: "Power Cable (Red)",
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(redCableQuery)
|
||||
),
|
||||
BOMItem(
|
||||
title: "Power Cable (Black)",
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(blackCableQuery)
|
||||
),
|
||||
BOMItem(
|
||||
title: "Fuse & Holder",
|
||||
detail: fuseDetail,
|
||||
iconSystemName: "bolt.shield",
|
||||
destination: .amazonSearch(fuseQuery)
|
||||
),
|
||||
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)
|
||||
),
|
||||
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)
|
||||
destination: .amazonSearch(terminalQuery),
|
||||
isPrimaryComponent: false
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private var slidersSection: some View {
|
||||
@@ -711,6 +744,7 @@ struct CalculatorView: View {
|
||||
calculator.power = savedLoad.power
|
||||
calculator.length = savedLoad.length
|
||||
isWattMode = savedLoad.isWattMode
|
||||
completedItemIDs = Set(savedLoad.bomCompletedItemIDs)
|
||||
}
|
||||
|
||||
private func autoUpdateSavedLoad() {
|
||||
@@ -724,8 +758,14 @@ struct CalculatorView: View {
|
||||
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
|
||||
@@ -733,45 +773,79 @@ struct CalculatorView: View {
|
||||
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
|
||||
Button {
|
||||
if let destinationURL = destinationURL(for: item) {
|
||||
openURL(destinationURL)
|
||||
}
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: item.iconSystemName)
|
||||
.font(.title3)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(width: 28)
|
||||
let isCompleted = completedItemIDs.contains(item.id)
|
||||
let destinationURL = destinationURL(for: item)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
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")
|
||||
|
||||
Text(item.detail)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
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())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
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(.top, 4)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -413,6 +413,7 @@ struct LoadsView: View {
|
||||
@State private var hasPresentedSystemEditorOnAppear = false
|
||||
@State private var hasOpenedLoadOnAppear = false
|
||||
@State private var showingComponentLibrary = false
|
||||
@State private var showingSystemBOM = false
|
||||
|
||||
let system: ElectricalSystem
|
||||
private let presentSystemEditorOnAppear: Bool
|
||||
@@ -542,6 +543,13 @@ struct LoadsView: View {
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack {
|
||||
if !savedLoads.isEmpty {
|
||||
Button(action: {
|
||||
showingSystemBOM = true
|
||||
}) {
|
||||
Image(systemName: "list.bullet.rectangle")
|
||||
}
|
||||
}
|
||||
Button(action: {
|
||||
createNewLoad()
|
||||
}) {
|
||||
@@ -559,6 +567,13 @@ struct LoadsView: View {
|
||||
addComponent(item)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSystemBOM) {
|
||||
SystemBillOfMaterialsView(
|
||||
systemName: system.name,
|
||||
loads: savedLoads,
|
||||
unitSystem: unitSettings.unitSystem
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingSystemEditor) {
|
||||
SystemEditorView(
|
||||
systemName: Binding(
|
||||
@@ -920,6 +935,365 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SystemBillOfMaterialsView: View {
|
||||
let systemName: String
|
||||
let loads: [SavedLoad]
|
||||
let unitSystem: UnitSystem
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var completedItemIDs: Set<String>
|
||||
@State private var suppressRowTapForID: String?
|
||||
|
||||
private struct Item: Identifiable {
|
||||
enum Destination {
|
||||
case affiliate(URL)
|
||||
case amazonSearch(String)
|
||||
}
|
||||
|
||||
let id: String
|
||||
let logicalID: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let iconSystemName: String
|
||||
let destination: Destination
|
||||
let isPrimaryComponent: Bool
|
||||
}
|
||||
|
||||
init(systemName: String, loads: [SavedLoad], unitSystem: UnitSystem) {
|
||||
self.systemName = systemName
|
||||
self.loads = loads
|
||||
self.unitSystem = unitSystem
|
||||
let initialKeys = loads.flatMap { load in
|
||||
load.bomCompletedItemIDs.map { SystemBillOfMaterialsView.storageKey(for: load, itemID: $0) }
|
||||
}
|
||||
_completedItemIDs = State(initialValue: Set(initialKeys))
|
||||
_suppressRowTapForID = State(initialValue: nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if sortedLoads.isEmpty {
|
||||
Section("Components") {
|
||||
Text("No loads saved in this system yet.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
ForEach(sortedLoads) { load in
|
||||
Section(header: sectionHeader(for: load)) {
|
||||
ForEach(items(for: load)) { item in
|
||||
let isCompleted = completedItemIDs.contains(item.id)
|
||||
let destinationURL = destinationURL(for: item.destination, load: load)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(isCompleted ? .accentColor : .secondary)
|
||||
.imageScale(.large)
|
||||
.onTapGesture {
|
||||
setCompletion(!isCompleted, for: load, item: item)
|
||||
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)
|
||||
}
|
||||
setCompletion(true, for: load, item: item)
|
||||
suppressRowTapForID = nil
|
||||
suppressRowTapForID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Text(footerMessage)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle("BOM – \(systemName)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
refreshCompletedItems()
|
||||
suppressRowTapForID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sortedLoads: [SavedLoad] {
|
||||
loads.sorted { lhs, rhs in
|
||||
lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(for load: SavedLoad) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(load.name.isEmpty ? "Component" : load.name)
|
||||
.font(.headline)
|
||||
Text(dateFormatter.string(from: load.timestamp))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func items(for load: SavedLoad) -> [Item] {
|
||||
let lengthValue: Double
|
||||
if unitSystem == .imperial {
|
||||
lengthValue = load.length * 3.28084
|
||||
} else {
|
||||
lengthValue = load.length
|
||||
}
|
||||
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
|
||||
|
||||
let crossSectionLabel: String
|
||||
let gaugeQuery: String
|
||||
if unitSystem == .imperial {
|
||||
let awg = awgFromCrossSection(load.crossSection)
|
||||
if awg > 0 {
|
||||
crossSectionLabel = String(format: "AWG %.0f", awg)
|
||||
gaugeQuery = String(format: "AWG %.0f", awg)
|
||||
} else {
|
||||
crossSectionLabel = "Size TBD"
|
||||
gaugeQuery = "battery cable"
|
||||
}
|
||||
} else {
|
||||
if load.crossSection > 0 {
|
||||
crossSectionLabel = String(format: "%.1f mm²", load.crossSection)
|
||||
gaugeQuery = String(format: "%.1f mm2", load.crossSection)
|
||||
} else {
|
||||
crossSectionLabel = "Size TBD"
|
||||
gaugeQuery = "battery cable"
|
||||
}
|
||||
}
|
||||
|
||||
let cableDetail = "\(lengthLabel) • \(crossSectionLabel)"
|
||||
|
||||
let calculatedPower = load.power > 0 ? load.power : load.voltage * load.current
|
||||
let powerDetail = String(format: "%.0f W @ %.1f V", calculatedPower, load.voltage)
|
||||
|
||||
let fuseRating = recommendedFuse(for: load)
|
||||
let fuseDetail = "Inline holder and \(fuseRating)A fuse"
|
||||
|
||||
let cableShoesDetail = "Ring or spade terminals sized for \(crossSectionLabel.lowercased()) wiring"
|
||||
|
||||
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
|
||||
let deviceQuery = load.name.isEmpty
|
||||
? String(format: "DC device %.0fW %.0fV", calculatedPower, load.voltage)
|
||||
: load.name
|
||||
|
||||
let redCableQuery = "\(gaugeQuery) red battery cable"
|
||||
let blackCableQuery = "\(gaugeQuery) black battery cable"
|
||||
let fuseQuery = "inline fuse holder \(fuseRating)A"
|
||||
let terminalQuery = "\(gaugeQuery) cable shoes"
|
||||
|
||||
let items: [Item] = [
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "component"),
|
||||
logicalID: "component",
|
||||
title: load.name.isEmpty ? "Component" : load.name,
|
||||
detail: powerDetail,
|
||||
iconSystemName: "bolt.fill",
|
||||
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(deviceQuery),
|
||||
isPrimaryComponent: true
|
||||
),
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "cable-red"),
|
||||
logicalID: "cable-red",
|
||||
title: "Power Cable (Red)",
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(redCableQuery),
|
||||
isPrimaryComponent: false
|
||||
),
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "cable-black"),
|
||||
logicalID: "cable-black",
|
||||
title: "Power Cable (Black)",
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(blackCableQuery),
|
||||
isPrimaryComponent: false
|
||||
),
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "fuse"),
|
||||
logicalID: "fuse",
|
||||
title: "Fuse & Holder",
|
||||
detail: fuseDetail,
|
||||
iconSystemName: "bolt.shield",
|
||||
destination: .amazonSearch(fuseQuery),
|
||||
isPrimaryComponent: false
|
||||
),
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "terminals"),
|
||||
logicalID: "terminals",
|
||||
title: "Cable Shoes / Terminals",
|
||||
detail: cableShoesDetail,
|
||||
iconSystemName: "wrench.and.screwdriver",
|
||||
destination: .amazonSearch(terminalQuery),
|
||||
isPrimaryComponent: false
|
||||
)
|
||||
]
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private func destinationURL(for destination: Item.Destination, load: SavedLoad) -> URL? {
|
||||
switch destination {
|
||||
case .affiliate(let url):
|
||||
return url
|
||||
case .amazonSearch(let query):
|
||||
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return nil }
|
||||
let domain = amazonDomain(for: load.affiliateCountryCode ?? Locale.current.regionCode)
|
||||
return URL(string: "https://\(domain)/s?k=\(encoded)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func storageKey(for load: SavedLoad, itemID: String) -> String {
|
||||
if load.identifier.isEmpty {
|
||||
load.identifier = UUID().uuidString
|
||||
}
|
||||
return "\(load.identifier)::\(itemID)"
|
||||
}
|
||||
|
||||
private func setCompletion(_ isCompleted: Bool, for load: SavedLoad, item: Item) {
|
||||
if isCompleted {
|
||||
completedItemIDs.insert(item.id)
|
||||
} else {
|
||||
completedItemIDs.remove(item.id)
|
||||
}
|
||||
|
||||
if load.identifier.isEmpty {
|
||||
load.identifier = UUID().uuidString
|
||||
}
|
||||
|
||||
var stored = Set(load.bomCompletedItemIDs)
|
||||
if isCompleted {
|
||||
stored.insert(item.logicalID)
|
||||
} else {
|
||||
stored.remove(item.logicalID)
|
||||
}
|
||||
load.bomCompletedItemIDs = Array(stored).sorted()
|
||||
}
|
||||
|
||||
private func refreshCompletedItems() {
|
||||
let keys = loads.flatMap { load in
|
||||
load.bomCompletedItemIDs.map { Self.storageKey(for: load, itemID: $0) }
|
||||
}
|
||||
completedItemIDs = Set(keys)
|
||||
}
|
||||
|
||||
private func amazonDomain(for countryCode: String?) -> String {
|
||||
guard let code = countryCode?.uppercased() else { return "www.amazon.com" }
|
||||
|
||||
switch code {
|
||||
case "DE": return "www.amazon.de"
|
||||
case "FR": return "www.amazon.fr"
|
||||
case "ES": return "www.amazon.es"
|
||||
case "IT": return "www.amazon.it"
|
||||
case "GB", "UK": return "www.amazon.co.uk"
|
||||
case "CA": return "www.amazon.ca"
|
||||
case "JP": return "www.amazon.co.jp"
|
||||
case "AU": return "www.amazon.com.au"
|
||||
case "NL": return "www.amazon.nl"
|
||||
case "SE": return "www.amazon.se"
|
||||
case "PL": return "www.amazon.pl"
|
||||
case "MX": return "www.amazon.com.mx"
|
||||
case "BR": return "www.amazon.com.br"
|
||||
case "IN": return "www.amazon.in"
|
||||
default: return "www.amazon.com"
|
||||
}
|
||||
}
|
||||
|
||||
private func recommendedFuse(for load: SavedLoad) -> Int {
|
||||
let targetFuse = load.current * 1.25
|
||||
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
|
||||
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last ?? 0
|
||||
}
|
||||
|
||||
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
|
||||
let mapping: [(awg: Double, area: Double)] = [
|
||||
(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), (10, 5.26),
|
||||
(8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), (1, 42.4), (0, 53.5),
|
||||
(00, 67.4), (000, 85.0), (0000, 107.0)
|
||||
]
|
||||
|
||||
guard crossSectionMM2 > 0 else { return 0 }
|
||||
|
||||
let closest = mapping.min { lhs, rhs in
|
||||
abs(lhs.area - crossSectionMM2) < abs(rhs.area - crossSectionMM2)
|
||||
}
|
||||
|
||||
return closest?.awg ?? 0
|
||||
}
|
||||
|
||||
private var footerMessage: String {
|
||||
if loads.contains(where: { $0.affiliateURLString != nil }) {
|
||||
return "Affiliate links may support VoltPlan; other rows open Amazon searches for sourcing guidance."
|
||||
} else {
|
||||
return "Amazon searches are provided to help you source comparable parts for this system."
|
||||
}
|
||||
}
|
||||
|
||||
private var dateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
|
||||
Reference in New Issue
Block a user