automated screenshot generation

This commit is contained in:
Stefan Lange-Hegermann
2025-10-13 09:38:22 +02:00
parent cfcaab149f
commit dd13178f0e
28 changed files with 1883 additions and 2004 deletions

View File

@@ -35,3 +35,11 @@
"system.list.no.components" = "No components yet";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Metric (mm², m)";
"sample.system.rv.name" = "Adventure Van";
"sample.system.rv.location" = "12V living circuit";
"sample.system.workshop.name" = "Workshop Bench";
"sample.system.workshop.location" = "Tool corner";
"sample.load.fridge.name" = "Compressor fridge";
"sample.load.lighting.name" = "LED strip lighting";
"sample.load.compressor.name" = "Air compressor";
"sample.load.charger.name" = "Tool charger";

View File

@@ -30,6 +30,12 @@ struct CableApp: App {
}
}()
init() {
#if DEBUG
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
#endif
}
var body: some Scene {
WindowGroup {
ContentView()

View File

@@ -346,6 +346,7 @@ struct ComponentLibraryView: View {
Button("Close") {
dismiss()
}
.accessibilityIdentifier("library-view-close-button")
}
}
}

View File

@@ -79,6 +79,7 @@ struct ComponentsOnboardingView: View {
.stroke(Color.blue.opacity(0.24), lineWidth: 1)
)
}
.accessibilityIdentifier("select-component-button")
.buttonStyle(.plain)
}
.padding(.horizontal, 24)

File diff suppressed because it is too large Load Diff

386
Cable/LoadsView.swift Normal file
View File

@@ -0,0 +1,386 @@
//
// LoadsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct LoadsView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var unitSettings: UnitSystemSettings
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
@State private var newLoadToEdit: SavedLoad?
@State private var showingSystemEditor = false
@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
private let loadToOpenOnAppear: SavedLoad?
init(system: ElectricalSystem, presentSystemEditorOnAppear: Bool = false, loadToOpenOnAppear: SavedLoad? = nil) {
self.system = system
self.presentSystemEditorOnAppear = presentSystemEditorOnAppear
self.loadToOpenOnAppear = loadToOpenOnAppear
}
private var savedLoads: [SavedLoad] {
allLoads.filter { $0.system == system }
}
var body: some View {
VStack(spacing: 0) {
if savedLoads.isEmpty {
emptyStateView
} else {
librarySection
List {
ForEach(savedLoads) { load in
NavigationLink(destination: CalculatorView(savedLoad: load)) {
HStack(spacing: 12) {
LoadIconView(
remoteIconURLString: load.remoteIconURLString,
fallbackSystemName: load.iconName,
fallbackColor: colorForName(load.colorName),
size: 44)
VStack(alignment: .leading, spacing: 6) {
Text(load.name)
.fontWeight(.medium)
.lineLimit(1)
.truncationMode(.tail)
// Secondary info
HStack {
Group {
Text(String(format: "%.1fV", load.voltage))
Text("•")
if load.isWattMode {
Text(String(format: "%.0fW", load.power))
} else {
Text(String(format: "%.1fA", load.current))
}
Text("•")
Text(String(format: "%.1f%@",
unitSettings.unitSystem == .metric ? load.length : load.length * 3.28084,
unitSettings.unitSystem.lengthUnit))
}
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
// Prominent fuse and wire gauge display
HStack(spacing: 12) {
HStack(spacing: 4) {
Text("FUSE")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.secondary)
Text("\(recommendedFuse(for: load))A")
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(.orange)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.orange.opacity(0.1))
.cornerRadius(6)
HStack(spacing: 4) {
Text("WIRE")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.secondary)
Text(String(format: unitSettings.unitSystem == .imperial ? "%.0f AWG" : "%.1fmm²",
unitSettings.unitSystem == .imperial ? awgFromCrossSection(load.crossSection) : load.crossSection))
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(.blue)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.cornerRadius(6)
Spacer()
}
}
}
.padding(.vertical, 4)
}
}
.onDelete(perform: deleteLoads)
}
.accessibilityIdentifier("loads-list")
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Button(action: {
showingSystemEditor = true
}) {
HStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(colorForName(system.colorName))
.frame(width: 24, height: 24)
Image(systemName: system.iconName)
.font(.system(size: 12))
.foregroundColor(.white)
}
Text(system.name)
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
if !savedLoads.isEmpty {
Button(action: {
showingSystemBOM = true
}) {
Image(systemName: "list.bullet.rectangle")
}
.accessibilityIdentifier("system-bom-button")
}
Button(action: {
createNewLoad()
}) {
Image(systemName: "plus")
}
EditButton()
}
}
}
.navigationDestination(item: $newLoadToEdit) { load in
CalculatorView(savedLoad: load)
}
.sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in
addComponent(item)
}
}
.sheet(isPresented: $showingSystemBOM) {
SystemBillOfMaterialsView(
systemName: system.name,
loads: savedLoads,
unitSystem: unitSettings.unitSystem
)
}
.sheet(isPresented: $showingSystemEditor) {
SystemEditorView(
systemName: Binding(
get: { system.name },
set: { system.name = $0 }
),
location: Binding(
get: { system.location },
set: { system.location = $0 }
),
iconName: Binding(
get: { system.iconName },
set: { system.iconName = $0 }
),
colorName: Binding(
get: { system.colorName },
set: { system.colorName = $0 }
)
)
}
.onAppear {
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
hasPresentedSystemEditorOnAppear = true
DispatchQueue.main.async {
showingSystemEditor = true
}
}
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
hasOpenedLoadOnAppear = true
DispatchQueue.main.async {
newLoadToEdit = loadToOpen
}
}
}
}
private var librarySection: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Component Library")
.font(.headline)
.fontWeight(.semibold)
Text("Browse electrical components from VoltPlan")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button(action: {
showingComponentLibrary = true
}) {
HStack(spacing: 6) {
Text("Browse")
.font(.subheadline)
.fontWeight(.medium)
Image(systemName: "arrow.up.right")
.font(.caption)
}
.foregroundColor(.blue)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemGroupedBackground))
Divider()
}
}
private var emptyStateView: some View {
ComponentsOnboardingView(
onCreate: { createNewLoad() },
onBrowse: { showingComponentLibrary = true }
)
}
private func deleteLoads(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(savedLoads[index])
}
}
}
private func createNewLoad() {
let defaultName = String(localized: "default.load.new", comment: "Default name when creating a new load from system view")
let loadName = uniqueLoadName(startingWith: defaultName)
let newLoad = SavedLoad(
name: loadName,
voltage: 12.0,
current: 5.0,
power: 60.0, // 12V * 5A = 60W
length: 10.0,
crossSection: 1.0,
iconName: "lightbulb",
colorName: "blue",
isWattMode: false,
system: system,
remoteIconURLString: nil
)
modelContext.insert(newLoad)
// Navigate to the new load
newLoadToEdit = newLoad
}
private func addComponent(_ item: ComponentLibraryItem) {
let baseName = item.name.isEmpty ? "Library Load" : item.name
let loadName = uniqueLoadName(startingWith: baseName)
let voltage = item.displayVoltage ?? 12.0
let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0)
let current: Double
if let explicitCurrent = item.current {
current = explicitCurrent
} else if voltage > 0 {
current = power / voltage
} else {
current = 0
}
let affiliateLink = item.primaryAffiliateLink
let newLoad = SavedLoad(
name: loadName,
voltage: voltage,
current: current,
power: power,
length: 10.0,
crossSection: 1.0,
iconName: "lightbulb",
colorName: "blue",
isWattMode: item.watt != nil,
system: system,
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,
affiliateCountryCode: affiliateLink?.country
)
modelContext.insert(newLoad)
newLoadToEdit = newLoad
}
private func uniqueLoadName(startingWith baseName: String) -> String {
let existingNames = Set(savedLoads.map { $0.name })
if !existingNames.contains(baseName) {
return baseName
}
var counter = 2
var candidate = "\(baseName) \(counter)"
while existingNames.contains(candidate) {
counter += 1
candidate = "\(baseName) \(counter)"
}
return candidate
}
private func colorForName(_ colorName: String) -> Color {
switch colorName {
case "blue": return .blue
case "green": return .green
case "orange": return .orange
case "red": return .red
case "purple": return .purple
case "yellow": return .yellow
case "pink": return .pink
case "teal": return .teal
case "indigo": return .indigo
case "mint": return .mint
case "cyan": return .cyan
case "brown": return .brown
case "gray": return .gray
default: return .blue
}
}
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
let awgSizes = [(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)]
// Find the closest AWG size
let closest = awgSizes.min { abs($0.1 - crossSectionMM2) < abs($1.1 - crossSectionMM2) }
return Double(closest?.0 ?? 20)
}
private func recommendedFuse(for load: SavedLoad) -> Int {
let targetFuse = load.current * 1.25 // 125% of load current for safety
// Common fuse values in amperes
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]
// Find the smallest standard fuse that's >= target
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
}
}

91
Cable/SettingsView.swift Normal file
View File

@@ -0,0 +1,91 @@
//
// SettingsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct SettingsView: View {
@EnvironmentObject var unitSettings: UnitSystemSettings
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section("Units") {
Picker("Unit System", selection: $unitSettings.unitSystem) {
ForEach(UnitSystem.allCases, id: \.self) { system in
Text(system.displayName).tag(system)
}
}
.pickerStyle(.segmented)
}
Section {
HStack {
Text("Wire Cross-Section:")
Spacer()
Text(unitSettings.unitSystem.wireAreaUnit)
.foregroundColor(.secondary)
}
HStack {
Text("Length:")
Spacer()
Text(unitSettings.unitSystem.lengthUnit)
.foregroundColor(.secondary)
}
} header: {
Text("Current Units")
} footer: {
Text("Changing the unit system will apply to all calculations in the app.")
}
Section {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.system(size: 18))
Text("Safety Disclaimer")
.font(.headline)
.fontWeight(.semibold)
}
VStack(alignment: .leading, spacing: 8) {
Text("This application provides electrical calculations for educational and estimation purposes only.")
.font(.body)
Text("Important:")
.font(.subheadline)
.fontWeight(.semibold)
VStack(alignment: .leading, spacing: 4) {
Text("• Always consult qualified electricians for actual installations")
Text("• Follow all local electrical codes and regulations")
Text("• Electrical work should only be performed by licensed professionals")
Text("• These calculations may not account for all environmental factors")
Text("• The app developers assume no liability for electrical installations")
}
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
}
}
}
}
}

View File

@@ -0,0 +1,382 @@
//
// 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
}
}

40
Cable/SystemView.swift Normal file
View File

@@ -0,0 +1,40 @@
//
// SystemView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct SystemView: View {
var body: some View {
NavigationStack {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 16) {
Image(systemName: "square.grid.3x2")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("System View")
.font(.title2)
.fontWeight(.semibold)
Text("Coming soon - manage your electrical systems and panels here.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 48)
}
Spacer()
Spacer()
}
.navigationTitle("System")
}
}
}

373
Cable/SystemsView.swift Normal file
View File

@@ -0,0 +1,373 @@
//
// SystemsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct SystemsView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var unitSettings: UnitSystemSettings
@Query(sort: \ElectricalSystem.timestamp, order: .reverse) private var systems: [ElectricalSystem]
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
@State private var systemNavigationTarget: SystemNavigationTarget?
@State private var showingComponentLibrary = false
@State private var showingSettings = false
private let systemColorOptions = [
"blue", "green", "orange", "red", "purple", "yellow",
"pink", "teal", "indigo", "mint", "cyan", "brown", "gray"
]
private let defaultSystemIconName = "building.2"
private let systemIconMappings: [(keywords: [String], icon: String)] = [
(["rv", "van", "camper", "motorhome", "coach"], "bus"),
(["truck", "trailer", "rig"], "truck.box"),
(["boat", "marine", "yacht", "sail"], "sailboat"),
(["plane", "air", "flight"], "airplane"),
(["ferry", "ship"], "ferry"),
(["house", "home", "cabin", "cottage", "lodge"], "house"),
(["building", "office", "warehouse", "factory", "facility"], "building"),
(["camp", "tent", "outdoor"], "tent"),
(["solar", "sun"], "sun.max"),
(["battery", "storage"], "battery.100"),
(["server", "data", "network", "rack"], "server.rack"),
(["computer", "electronics", "lab", "tech"], "cpu"),
(["gear", "mechanic", "machine", "workshop"], "gear"),
(["tool", "maintenance", "repair", "shop"], "wrench.adjustable"),
(["hammer", "carpentry"], "hammer"),
(["light", "lighting", "lamp"], "lightbulb"),
(["bolt", "power", "electric"], "bolt"),
(["plug"], "powerplug"),
(["engine", "generator", "motor"], "engine.combustion"),
(["fuel", "diesel", "gas"], "fuelpump"),
(["water", "pump", "tank"], "drop"),
(["heat", "heater", "furnace"], "flame"),
(["cold", "freeze", "cool"], "snowflake"),
(["climate", "hvac", "temperature"], "thermometer")
]
private struct SystemNavigationTarget: Identifiable, Hashable {
let id = UUID()
let system: ElectricalSystem
let presentSystemEditor: Bool
let loadToOpenOnAppear: SavedLoad?
static func == (lhs: SystemNavigationTarget, rhs: SystemNavigationTarget) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
var body: some View {
NavigationStack {
Group {
if systems.isEmpty {
systemsEmptyState
} else {
List {
ForEach(systems) { system in
NavigationLink(destination: LoadsView(system: system)) {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(colorForName(system.colorName))
.frame(width: 44, height: 44)
Image(systemName: system.iconName)
.font(.title3)
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 4) {
Text(system.name)
.fontWeight(.medium)
if !system.location.isEmpty {
Text(system.location)
.font(.caption)
.foregroundColor(.secondary)
}
Text(componentSummary(for: system))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
.onDelete(perform: deleteSystems)
}
.accessibilityIdentifier("systems-list")
}
}
.navigationTitle("Systems")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
showingSettings = true
} label: {
Image(systemName: "gearshape")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
createNewSystem()
}) {
Image(systemName: "plus")
}
EditButton()
}
}
}
.navigationDestination(item: $systemNavigationTarget) { target in
LoadsView(
system: target.system,
presentSystemEditorOnAppear: target.presentSystemEditor,
loadToOpenOnAppear: target.loadToOpenOnAppear
)
}
}
.sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in
addComponentFromLibrary(item)
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
.environmentObject(unitSettings)
}
}
private var systemsEmptyState: some View {
SystemsOnboardingView { name in
createOnboardingSystem(named: name)
}
}
private func createNewSystem() {
let system = makeSystem()
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
}
private func createNewSystem(named name: String) {
let system = makeSystem(preferredName: name)
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
}
private func createOnboardingSystem(named name: String) {
let system = makeSystem(
preferredName: name,
colorName: randomSystemColorName()
)
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil)
}
private func navigateToSystem(_ system: ElectricalSystem, presentSystemEditor: Bool, loadToOpen: SavedLoad?, animated: Bool = true) {
let target = SystemNavigationTarget(
system: system,
presentSystemEditor: presentSystemEditor,
loadToOpenOnAppear: loadToOpen
)
if animated {
systemNavigationTarget = target
} else {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
systemNavigationTarget = target
}
}
}
@discardableResult
private func makeSystem(preferredName: String? = nil, colorName: String? = nil, iconName: String? = nil) -> ElectricalSystem {
let existingNames = Set(systems.map { $0.name })
let trimmedPreferred = preferredName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let baseName = trimmedPreferred.isEmpty ? String(localized: "default.system.new", comment: "Default name for a newly created system") : trimmedPreferred
var systemName = baseName
var counter = 2
while existingNames.contains(systemName) {
systemName = "\(baseName) \(counter)"
counter += 1
}
let resolvedColorName = colorName ?? "blue"
let resolvedIconName = iconName ?? systemIconName(for: systemName)
let newSystem = ElectricalSystem(
name: systemName,
location: "",
iconName: resolvedIconName,
colorName: resolvedColorName
)
modelContext.insert(newSystem)
return newSystem
}
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
let system = makeSystem()
let load = createLoad(from: item, in: system)
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
}
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
let baseName = item.name.isEmpty ? String(localized: "default.load.library", comment: "Default name when importing a library load") : item.name
let loadName = uniqueLoadName(for: system, startingWith: baseName)
let voltage = item.displayVoltage ?? 12.0
let power: Double
if let watt = item.watt {
power = watt
} else if let derivedCurrent = item.current, voltage > 0 {
power = derivedCurrent * voltage
} else {
power = 0
}
let current: Double
if let explicitCurrent = item.current {
current = explicitCurrent
} else if voltage > 0 {
current = power / voltage
} else {
current = 0
}
let affiliateLink = item.primaryAffiliateLink
let newLoad = SavedLoad(
name: loadName,
voltage: voltage,
current: current,
power: power,
length: 10.0,
crossSection: 1.0,
iconName: "lightbulb",
colorName: "blue",
isWattMode: item.watt != nil,
system: system,
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,
affiliateCountryCode: affiliateLink?.country
)
modelContext.insert(newLoad)
return newLoad
}
private func uniqueLoadName(for system: ElectricalSystem, startingWith baseName: String) -> String {
let descriptor = FetchDescriptor<SavedLoad>()
let fetchedLoads = (try? modelContext.fetch(descriptor)) ?? []
let existingNames = Set(fetchedLoads.filter { $0.system == system }.map { $0.name })
if !existingNames.contains(baseName) {
return baseName
}
var counter = 2
var candidate = "\(baseName) \(counter)"
while existingNames.contains(candidate) {
counter += 1
candidate = "\(baseName) \(counter)"
}
return candidate
}
private func deleteSystems(offsets: IndexSet) {
withAnimation {
for index in offsets {
let system = systems[index]
deleteLoads(for: system)
modelContext.delete(system)
}
}
}
private func deleteLoads(for system: ElectricalSystem) {
let descriptor = FetchDescriptor<SavedLoad>()
if let loads = try? modelContext.fetch(descriptor) {
for load in loads where load.system == system {
modelContext.delete(load)
}
}
}
private func loads(for system: ElectricalSystem) -> [SavedLoad] {
allLoads.filter { $0.system == system }
}
private func componentSummary(for system: ElectricalSystem) -> String {
let systemLoads = loads(for: system)
guard !systemLoads.isEmpty else {
return String(localized: "system.list.no.components", comment: "Message shown when a system has no components yet")
}
let count = systemLoads.count
let totalPower = systemLoads.reduce(0.0) { $0 + $1.power }
let formattedPower: String
if totalPower >= 1000 {
formattedPower = String(format: "%.1fkW", totalPower / 1000)
} else {
formattedPower = String(format: "%.0fW", totalPower)
}
let format = NSLocalizedString(
"system.list.component.summary",
comment: "Summary showing number of components and the total power"
)
return String.localizedStringWithFormat(format, count, formattedPower)
}
private func randomSystemColorName() -> String {
systemColorOptions.randomElement() ?? "blue"
}
private func systemIconName(for name: String) -> String {
let normalized = name
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
for mapping in systemIconMappings {
if mapping.keywords.contains(where: { keyword in normalized.contains(keyword) }) {
return mapping.icon
}
}
return defaultSystemIconName
}
private func colorForName(_ colorName: String) -> Color {
switch colorName {
case "blue": return .blue
case "green": return .green
case "orange": return .orange
case "red": return .red
case "purple": return .purple
case "yellow": return .yellow
case "pink": return .pink
case "teal": return .teal
case "indigo": return .indigo
case "mint": return .mint
case "cyan": return .cyan
case "brown": return .brown
case "gray": return .gray
default: return .blue
}
}
}

View File

@@ -0,0 +1,128 @@
//
// UITestSampleData.swift
// Cable
// Created by Stefan Lange-Hegermann on 06.10.25.
import Foundation
import SwiftData
enum UITestSampleData {
static let argument = "--uitest-sample-data"
static func prepareIfNeeded(container: ModelContainer) {
#if DEBUG
guard ProcessInfo.processInfo.arguments.contains(argument) else { return }
let context = ModelContext(container)
do {
try clearExistingData(in: context)
try seedSampleData(in: context)
try context.save()
} catch {
assertionFailure("Failed to seed UI test sample data: \(error)")
}
#endif
}
}
#if DEBUG
private extension UITestSampleData {
static func clearExistingData(in context: ModelContext) throws {
let systemDescriptor = FetchDescriptor<ElectricalSystem>()
let loadDescriptor = FetchDescriptor<SavedLoad>()
let itemDescriptor = FetchDescriptor<Item>()
let systems = try context.fetch(systemDescriptor)
let loads = try context.fetch(loadDescriptor)
let items = try context.fetch(itemDescriptor)
systems.forEach { context.delete($0) }
loads.forEach { context.delete($0) }
items.forEach { context.delete($0) }
}
static func seedSampleData(in context: ModelContext) throws {
let adventureVan = ElectricalSystem(
name: String(localized: "sample.system.rv.name", comment: "Sample data name for the adventure van system"),
location: String(localized: "sample.system.rv.location", comment: "Sample data location for the adventure van system"),
iconName: "bus",
colorName: "orange"
)
adventureVan.timestamp = Date(timeIntervalSinceReferenceDate: 3000)
let workshopBench = ElectricalSystem(
name: String(localized: "sample.system.workshop.name", comment: "Sample data name for the workshop system"),
location: String(localized: "sample.system.workshop.location", comment: "Sample data location for the workshop system"),
iconName: "wrench.adjustable",
colorName: "teal"
)
workshopBench.timestamp = Date(timeIntervalSinceReferenceDate: 2000)
context.insert(adventureVan)
context.insert(workshopBench)
let vanFridge = SavedLoad(
name: String(localized: "sample.load.fridge.name", comment: "Sample data load name for a compressor fridge"),
voltage: 12.0,
current: 4.2,
power: 50.0,
length: 6.0,
crossSection: 6.0,
iconName: "snowflake",
colorName: "blue",
isWattMode: true,
system: adventureVan,
identifier: "sample.load.fridge"
)
vanFridge.timestamp = Date(timeIntervalSinceReferenceDate: 1100)
let vanLighting = SavedLoad(
name: String(localized: "sample.load.lighting.name", comment: "Sample data load name for LED strip lighting"),
voltage: 12.0,
current: 2.0,
power: 24.0,
length: 10.0,
crossSection: 2.5,
iconName: "lightbulb",
colorName: "yellow",
isWattMode: false,
system: adventureVan,
identifier: "sample.load.lighting"
)
vanLighting.timestamp = Date(timeIntervalSinceReferenceDate: 1200)
let workshopCompressor = SavedLoad(
name: String(localized: "sample.load.compressor.name", comment: "Sample data load name for an air compressor"),
voltage: 120.0,
current: 8.0,
power: 960.0,
length: 15.0,
crossSection: 16.0,
iconName: "hammer",
colorName: "red",
isWattMode: true,
system: workshopBench,
identifier: "sample.load.compressor"
)
workshopCompressor.timestamp = Date(timeIntervalSinceReferenceDate: 2100)
let workshopCharger = SavedLoad(
name: String(localized: "sample.load.charger.name", comment: "Sample data load name for a tool charger"),
voltage: 120.0,
current: 3.5,
power: 420.0,
length: 8.0,
crossSection: 10.0,
iconName: "battery.100",
colorName: "green",
isWattMode: false,
system: workshopBench,
identifier: "sample.load.charger"
)
workshopCharger.timestamp = Date(timeIntervalSinceReferenceDate: 2200)
[vanFridge, vanLighting, workshopCompressor, workshopCharger].forEach { context.insert($0) }
}
}
#endif

View File

@@ -36,6 +36,14 @@
"system.list.no.components" = "Noch keine Komponenten";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Metrisch (mm², m)";
"sample.system.rv.name" = "Abenteuer-Van";
"sample.system.rv.location" = "12V Wohnstromkreis";
"sample.system.workshop.name" = "Werkbank";
"sample.system.workshop.location" = "Werkzeugecke";
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
"sample.load.lighting.name" = "LED-Streifenbeleuchtung";
"sample.load.compressor.name" = "Luftkompressor";
"sample.load.charger.name" = "Werkzeugladegerät";
// Direct strings
"Systems" = "Systeme";

View File

@@ -36,6 +36,14 @@
"system.list.no.components" = "Aún no hay componentes";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Métrico (mm², m)";
"sample.system.rv.name" = "Furgoneta aventura";
"sample.system.rv.location" = "Circuito de vivienda 12V";
"sample.system.workshop.name" = "Banco de taller";
"sample.system.workshop.location" = "Rincón de herramientas";
"sample.load.fridge.name" = "Nevera de compresor";
"sample.load.lighting.name" = "Iluminación LED";
"sample.load.compressor.name" = "Compresor de aire";
"sample.load.charger.name" = "Cargador de herramientas";
// Direct strings
"Systems" = "Sistemas";

View File

@@ -36,6 +36,14 @@
"system.list.no.components" = "Aucun composant pour l'instant";
"units.imperial.display" = "Impérial (AWG, ft)";
"units.metric.display" = "Métrique (mm², m)";
"sample.system.rv.name" = "Van d'aventure";
"sample.system.rv.location" = "Circuit de vie 12 V";
"sample.system.workshop.name" = "Établi d'atelier";
"sample.system.workshop.location" = "Coin outils";
"sample.load.fridge.name" = "Réfrigérateur à compresseur";
"sample.load.lighting.name" = "Éclairage LED";
"sample.load.compressor.name" = "Compresseur d'air";
"sample.load.charger.name" = "Chargeur d'outils";
// Direct strings
"Systems" = "Systèmes";

View File

@@ -36,6 +36,14 @@
"system.list.no.components" = "Nog geen componenten";
"units.imperial.display" = "Imperiaal (AWG, ft)";
"units.metric.display" = "Metrisch (mm², m)";
"sample.system.rv.name" = "Avonturenbus";
"sample.system.rv.location" = "12V leefcircuit";
"sample.system.workshop.name" = "Werkbank";
"sample.system.workshop.location" = "Gereedschapshoek";
"sample.load.fridge.name" = "Koelbox met compressor";
"sample.load.lighting.name" = "LED-strips";
"sample.load.compressor.name" = "Luchtcompressor";
"sample.load.charger.name" = "Gereedschapslader";
// Direct strings
"Systems" = "Systemen";