From b100bd061794913850c64a1302899a90ab3a2a3d Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Wed, 24 Sep 2025 20:10:35 +0200 Subject: [PATCH] BOM for individual parts --- Cable/CalculatorView.swift | 231 ++++++++++++++++++++++++++++++++++--- 1 file changed, 215 insertions(+), 16 deletions(-) diff --git a/Cable/CalculatorView.swift b/Cable/CalculatorView.swift index be2f538..92e0f05 100644 --- a/Cable/CalculatorView.swift +++ b/Cable/CalculatorView.swift @@ -18,6 +18,7 @@ struct CalculatorView: View { @State private var isWattMode = false @State private var editingValue: EditingValue? = nil @State private var showingLoadEditor = false + @State private var presentedAffiliateLink: AffiliateLinkInfo? let savedLoad: SavedLoad? @@ -29,10 +30,25 @@ struct CalculatorView: View { case voltage, current, power, length } - private struct AffiliateLinkInfo { - let url: URL + struct AffiliateLinkInfo: Identifiable, Equatable { + let id: String + let affiliateURL: URL? let buttonTitle: 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 { @@ -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( get: { editingValue == .length }, set: { if !$0 { editingValue = nil } } @@ -198,24 +220,29 @@ struct CalculatorView: View { } private var affiliateLinkInfo: AffiliateLinkInfo? { - guard let savedLoad, - let urlString = savedLoad.affiliateURLString, - let url = URL(string: urlString) else { return nil } + guard let savedLoad else { return nil } - let countryCode = savedLoad.affiliateCountryCode?.uppercased() - let regionName: String? - if let countryCode { - regionName = Locale.current.localizedString(forRegionCode: countryCode) ?? countryCode + let affiliateURL: URL? + if let urlString = savedLoad.affiliateURLString, + let parsedURL = URL(string: urlString) { + affiliateURL = parsedURL } 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( - url: url, + id: identifier, + affiliateURL: affiliateURL, buttonTitle: buttonTitle, - regionName: regionName + regionName: regionName, + countryCode: countryCode ) } @@ -474,7 +501,9 @@ struct CalculatorView: View { .font(.caption) .foregroundColor(.secondary) - Link(destination: info.url) { + Button { + presentedAffiliateLink = info + } label: { Label(info.buttonTitle, systemImage: "cart") .font(.callout.weight(.semibold)) .frame(maxWidth: .infinity) @@ -486,14 +515,15 @@ struct CalculatorView: View { .fill(Color.accentColor.opacity(0.12)) ) } + .buttonStyle(.plain) if let regionName = info.regionName { - Text("Available in \(regionName).") + Text("Configured for \(regionName).") .font(.caption2) .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) .foregroundColor(.secondary) } @@ -502,6 +532,76 @@ struct CalculatorView: View { .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 + + 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 { VStack(spacing: 30) { @@ -630,6 +730,105 @@ struct CalculatorView: View { // 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 { let title: String @Binding var value: Double