BOM for individual parts
This commit is contained in:
@@ -18,6 +18,7 @@ struct CalculatorView: View {
|
|||||||
@State private var isWattMode = false
|
@State private var isWattMode = false
|
||||||
@State private var editingValue: EditingValue? = nil
|
@State private var editingValue: EditingValue? = nil
|
||||||
@State private var showingLoadEditor = false
|
@State private var showingLoadEditor = false
|
||||||
|
@State private var presentedAffiliateLink: AffiliateLinkInfo?
|
||||||
|
|
||||||
let savedLoad: SavedLoad?
|
let savedLoad: SavedLoad?
|
||||||
|
|
||||||
@@ -29,10 +30,25 @@ struct CalculatorView: View {
|
|||||||
case voltage, current, power, length
|
case voltage, current, power, length
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct AffiliateLinkInfo {
|
struct AffiliateLinkInfo: Identifiable, Equatable {
|
||||||
let url: URL
|
let id: String
|
||||||
|
let affiliateURL: URL?
|
||||||
let buttonTitle: String
|
let buttonTitle: String
|
||||||
let regionName: String?
|
let regionName: String?
|
||||||
|
let countryCode: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BOMItem: Identifiable {
|
||||||
|
enum Destination: Equatable {
|
||||||
|
case affiliate(URL)
|
||||||
|
case amazonSearch(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let detail: String
|
||||||
|
let iconSystemName: String
|
||||||
|
let destination: Destination
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -95,6 +111,12 @@ struct CalculatorView: View {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $presentedAffiliateLink) { info in
|
||||||
|
BillOfMaterialsView(
|
||||||
|
info: info,
|
||||||
|
items: buildBillOfMaterialsItems(deviceLink: info.affiliateURL)
|
||||||
|
)
|
||||||
|
}
|
||||||
.alert("Edit Length", isPresented: Binding(
|
.alert("Edit Length", isPresented: Binding(
|
||||||
get: { editingValue == .length },
|
get: { editingValue == .length },
|
||||||
set: { if !$0 { editingValue = nil } }
|
set: { if !$0 { editingValue = nil } }
|
||||||
@@ -198,24 +220,29 @@ struct CalculatorView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var affiliateLinkInfo: AffiliateLinkInfo? {
|
private var affiliateLinkInfo: AffiliateLinkInfo? {
|
||||||
guard let savedLoad,
|
guard let savedLoad else { return nil }
|
||||||
let urlString = savedLoad.affiliateURLString,
|
|
||||||
let url = URL(string: urlString) else { return nil }
|
|
||||||
|
|
||||||
let countryCode = savedLoad.affiliateCountryCode?.uppercased()
|
let affiliateURL: URL?
|
||||||
let regionName: String?
|
if let urlString = savedLoad.affiliateURLString,
|
||||||
if let countryCode {
|
let parsedURL = URL(string: urlString) {
|
||||||
regionName = Locale.current.localizedString(forRegionCode: countryCode) ?? countryCode
|
affiliateURL = parsedURL
|
||||||
} else {
|
} else {
|
||||||
regionName = nil
|
affiliateURL = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let buttonTitle = regionName.map { "Buy in \($0)" } ?? "Buy this component"
|
let rawCountryCode = savedLoad.affiliateCountryCode ?? Locale.current.regionCode
|
||||||
|
let countryCode = rawCountryCode?.uppercased()
|
||||||
|
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 }
|
||||||
|
|
||||||
|
let buttonTitle = regionName.map { "Review parts for \($0)" } ?? "Review parts"
|
||||||
|
let identifier = "bom-\(savedLoad.name)-\(savedLoad.timestamp.timeIntervalSince1970)"
|
||||||
|
|
||||||
return AffiliateLinkInfo(
|
return AffiliateLinkInfo(
|
||||||
url: url,
|
id: identifier,
|
||||||
|
affiliateURL: affiliateURL,
|
||||||
buttonTitle: buttonTitle,
|
buttonTitle: buttonTitle,
|
||||||
regionName: regionName
|
regionName: regionName,
|
||||||
|
countryCode: countryCode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,7 +501,9 @@ struct CalculatorView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Link(destination: info.url) {
|
Button {
|
||||||
|
presentedAffiliateLink = info
|
||||||
|
} label: {
|
||||||
Label(info.buttonTitle, systemImage: "cart")
|
Label(info.buttonTitle, systemImage: "cart")
|
||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -486,14 +515,15 @@ struct CalculatorView: View {
|
|||||||
.fill(Color.accentColor.opacity(0.12))
|
.fill(Color.accentColor.opacity(0.12))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
if let regionName = info.regionName {
|
if let regionName = info.regionName {
|
||||||
Text("Available in \(regionName).")
|
Text("Configured for \(regionName).")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Affiliate link – purchases may support VoltPlan.")
|
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)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@@ -502,6 +532,76 @@ struct CalculatorView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
.padding(.horizontal)
|
.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
|
||||||
|
|
||||||
|
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)
|
||||||
|
),
|
||||||
|
BOMItem(
|
||||||
|
title: calculator.loadName.isEmpty ? "Component" : calculator.loadName,
|
||||||
|
detail: powerDetail,
|
||||||
|
iconSystemName: "bolt.fill",
|
||||||
|
destination: deviceLink.map { .affiliate($0) } ?? .amazonSearch(deviceQueryBase)
|
||||||
|
),
|
||||||
|
BOMItem(
|
||||||
|
title: "Cable Shoes / Terminals",
|
||||||
|
detail: cableShoesDetail,
|
||||||
|
iconSystemName: "wrench.and.screwdriver",
|
||||||
|
destination: .amazonSearch(terminalQuery)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
private var slidersSection: some View {
|
private var slidersSection: some View {
|
||||||
VStack(spacing: 30) {
|
VStack(spacing: 30) {
|
||||||
@@ -630,6 +730,105 @@ struct CalculatorView: View {
|
|||||||
|
|
||||||
// MARK: - Supporting Views
|
// MARK: - Supporting Views
|
||||||
|
|
||||||
|
private struct BillOfMaterialsView: View {
|
||||||
|
let info: CalculatorView.AffiliateLinkInfo
|
||||||
|
let items: [CalculatorView.BOMItem]
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(item.title)
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Text(item.detail)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Text(info.affiliateURL != nil ? "Purchases through the affiliate link may support VoltPlan." : "Amazon searches are provided to help you source comparable parts.")
|
||||||
|
.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):
|
||||||
|
let domain = amazonDomain(for: info.countryCode)
|
||||||
|
guard let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return nil }
|
||||||
|
return URL(string: "https://\(domain)/s?k=\(encodedQuery)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func amazonDomain(for countryCode: String?) -> String {
|
||||||
|
guard let countryCode else { return "www.amazon.com" }
|
||||||
|
|
||||||
|
switch countryCode.uppercased() {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SliderSection: View {
|
struct SliderSection: View {
|
||||||
let title: String
|
let title: String
|
||||||
@Binding var value: Double
|
@Binding var value: Double
|
||||||
|
|||||||
Reference in New Issue
Block a user