diff --git a/Cable/CableCalculator.swift b/Cable/CableCalculator.swift index 5f7afd7..df99621 100644 --- a/Cable/CableCalculator.swift +++ b/Cable/CableCalculator.swift @@ -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 } } diff --git a/Cable/CalculatorView.swift b/Cable/CalculatorView.swift index 92e0f05..9c279c1 100644 --- a/Cable/CalculatorView.swift +++ b/Cable/CalculatorView.swift @@ -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 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 @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 + } } } diff --git a/Cable/ContentView.swift b/Cable/ContentView.swift index 4e7361e..288a29d 100644 --- a/Cable/ContentView.swift +++ b/Cable/ContentView.swift @@ -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 + @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()