383 lines
16 KiB
Swift
383 lines
16 KiB
Swift
//
|
|
// SystemBillOfMaterialsView.swift
|
|
// Cable
|
|
//
|
|
// Created by Stefan Lange-Hegermann on 09.10.25.
|
|
//
|
|
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
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) {
|
|
let accessibilityLabel: String = {
|
|
if isCompleted {
|
|
let format = NSLocalizedString(
|
|
"bom.accessibility.mark.incomplete",
|
|
comment: "Accessibility label instructing VoiceOver to mark an item incomplete"
|
|
)
|
|
return String.localizedStringWithFormat(format, item.title)
|
|
} else {
|
|
let format = NSLocalizedString(
|
|
"bom.accessibility.mark.complete",
|
|
comment: "Accessibility label instructing VoiceOver to mark an item complete"
|
|
)
|
|
return String.localizedStringWithFormat(format, item.title)
|
|
}
|
|
}()
|
|
|
|
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
|
|
.foregroundColor(isCompleted ? .accentColor : .secondary)
|
|
.imageScale(.large)
|
|
.onTapGesture {
|
|
setCompletion(!isCompleted, for: load, item: item)
|
|
suppressRowTapForID = item.id
|
|
}
|
|
.accessibilityLabel(accessibilityLabel)
|
|
|
|
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(String(localized: "component.fallback.name", comment: "Tag label marking an item as the component itself"))
|
|
.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(
|
|
String(
|
|
format: NSLocalizedString(
|
|
"bom.navigation.title.system",
|
|
comment: "Navigation title for the bill of materials view"
|
|
),
|
|
locale: Locale.current,
|
|
systemName
|
|
)
|
|
)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Close") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
refreshCompletedItems()
|
|
suppressRowTapForID = nil
|
|
}
|
|
}
|
|
.accessibilityIdentifier("system-bom-view")
|
|
}
|
|
|
|
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) {
|
|
let fallbackTitle = String(localized: "component.fallback.name", comment: "Fallback title for a component that lacks a name")
|
|
Text(load.name.isEmpty ? fallbackTitle : 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
|
|
let unknownSizeLabel = String(localized: "bom.size.unknown", comment: "Fallback label when the cable size is not yet determined")
|
|
|
|
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 = unknownSizeLabel
|
|
gaugeQuery = "battery cable"
|
|
}
|
|
} else {
|
|
if load.crossSection > 0 {
|
|
crossSectionLabel = String(format: "%.1f mm²", load.crossSection)
|
|
gaugeQuery = String(format: "%.1f mm2", load.crossSection)
|
|
} else {
|
|
crossSectionLabel = unknownSizeLabel
|
|
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 fuseDetailFormat = NSLocalizedString(
|
|
"bom.fuse.detail",
|
|
comment: "Description for the fuse item in the BOM list"
|
|
)
|
|
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
|
|
|
|
let cableShoesDetailFormat = NSLocalizedString(
|
|
"bom.terminals.detail",
|
|
comment: "Description for the cable terminals item in the BOM list"
|
|
)
|
|
let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased())
|
|
|
|
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 ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : 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: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
|
|
detail: cableDetail,
|
|
iconSystemName: "bolt.horizontal.circle",
|
|
destination: .amazonSearch(redCableQuery),
|
|
isPrimaryComponent: false
|
|
),
|
|
Item(
|
|
id: Self.storageKey(for: load, itemID: "cable-black"),
|
|
logicalID: "cable-black",
|
|
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
|
|
detail: cableDetail,
|
|
iconSystemName: "bolt.horizontal.circle",
|
|
destination: .amazonSearch(blackCableQuery),
|
|
isPrimaryComponent: false
|
|
),
|
|
Item(
|
|
id: Self.storageKey(for: load, itemID: "fuse"),
|
|
logicalID: "fuse",
|
|
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
|
|
detail: fuseDetail,
|
|
iconSystemName: "bolt.shield",
|
|
destination: .amazonSearch(fuseQuery),
|
|
isPrimaryComponent: false
|
|
),
|
|
Item(
|
|
id: Self.storageKey(for: load, itemID: "terminals"),
|
|
logicalID: "terminals",
|
|
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
|
|
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):
|
|
let countryCode = load.affiliateCountryCode ?? Locale.current.region?.identifier
|
|
return AmazonAffiliate.searchURL(query: query, countryCode: countryCode)
|
|
}
|
|
}
|
|
|
|
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 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 {
|
|
NSLocalizedString(
|
|
"affiliate.disclaimer",
|
|
comment: "Footer note reminding users that affiliate purchases may support the app"
|
|
)
|
|
}
|
|
|
|
private var dateFormatter: DateFormatter {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
formatter.timeStyle = .short
|
|
return formatter
|
|
}
|
|
}
|