systems first

This commit is contained in:
Stefan Lange-Hegermann
2025-09-16 10:23:34 +02:00
parent 974aa47cbb
commit 177d5c350e
13 changed files with 1881 additions and 57 deletions

View File

@@ -294,6 +294,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Cable/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -304,7 +305,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.Cable;
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@@ -324,6 +325,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Cable/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -334,7 +336,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.Cable;
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;

View File

@@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "ios-marketing.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,20 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -7,9 +7,7 @@
<key>com.apple.developer.icloud-container-identifiers</key>
<array/>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<array/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>

View File

@@ -10,22 +10,30 @@ import SwiftData
@main
struct CableApp: App {
@StateObject private var unitSettings = UnitSystemSettings()
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Item.self,
])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
// Try the simple approach first
return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, Item.self)
} catch {
fatalError("Could not create ModelContainer: \(error)")
print("Failed to create ModelContainer with simple approach: \(error)")
// Try in-memory as fallback
do {
let schema = Schema([ElectricalSystem.self, SavedLoad.self, Item.self])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError("Could not create even in-memory ModelContainer: \(error)")
}
}
}()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(unitSettings)
}
.modelContainer(sharedModelContainer)
}

150
Cable/CableCalculator.swift Normal file
View File

@@ -0,0 +1,150 @@
//
// CableCalculator.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 11.09.25.
//
import Foundation
import SwiftData
class CableCalculator: ObservableObject {
@Published var voltage: Double = 12.0
@Published var current: Double = 5.0
@Published var power: Double = 60.0
@Published var length: Double = 10.0
@Published var loadName: String = "My Load"
var calculatedPower: Double {
voltage * current
}
var calculatedCurrent: Double {
voltage > 0 ? power / voltage : 0
}
func updateFromCurrent() {
power = voltage * current
}
func updateFromPower() {
current = voltage > 0 ? power / voltage : 0
}
func recommendedCrossSection(for unitSystem: UnitSystem) -> Double {
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048 // ft to m
// Simplified calculation: minimum cross-section based on current and voltage drop
let maxVoltageDrop = voltage * 0.05 // 5% voltage drop limit
let resistivity = 0.017 // Copper resistivity at 20°C (Ωmm²/m)
let calculatedMinCrossSection = (2 * current * lengthInMeters * resistivity) / maxVoltageDrop
if unitSystem == .imperial {
// Standard AWG wire sizes
let standardAWG = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, 0, 00, 000, 0000]
let awgCrossSections = [0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0]
// Find the smallest AWG that meets the requirement
for (index, crossSection) in awgCrossSections.enumerated() {
if crossSection >= calculatedMinCrossSection {
return Double(standardAWG[index])
}
}
return Double(standardAWG.last!) // Largest available
} else {
// Standard metric cable cross-sections in mm²
let standardSizes = [0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0, 50.0, 70.0, 95.0, 120.0, 150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0]
// Find the smallest standard size that meets the requirement
return standardSizes.first { $0 >= max(0.75, calculatedMinCrossSection) } ?? standardSizes.last!
}
}
func crossSection(for unitSystem: UnitSystem) -> Double {
recommendedCrossSection(for: unitSystem)
}
func voltageDrop(for unitSystem: UnitSystem) -> Double {
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048
let crossSectionMM2 = unitSystem == .metric ? crossSection(for: unitSystem) : crossSectionFromAWG(crossSection(for: unitSystem))
let resistivity = 0.017
let effectiveCurrent = current // Always use the current property which gets updated
return (2 * effectiveCurrent * lengthInMeters * resistivity) / crossSectionMM2
}
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
(voltageDrop(for: unitSystem) / voltage) * 100
}
func powerLoss(for unitSystem: UnitSystem) -> Double {
let effectiveCurrent = current
return effectiveCurrent * voltageDrop(for: unitSystem)
}
var recommendedFuse: Int {
let targetFuse = 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!
}
// AWG conversion helper for voltage drop calculations
private func crossSectionFromAWG(_ awg: 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]
// Handle 00, 000, 0000 AWG (represented as negative values)
if awg == 00 { return 67.4 }
if awg == 000 { return 85.0 }
if awg == 0000 { return 107.0 }
return awgSizes[Int(awg)] ?? 0.75
}
}
@Model
class ElectricalSystem {
var name: String = ""
var location: String = ""
var timestamp: Date = Date()
var iconName: String = "building.2"
var colorName: String = "blue"
init(name: String, location: String = "", iconName: String = "building.2", colorName: String = "blue") {
self.name = name
self.location = location
self.timestamp = Date()
self.iconName = iconName
self.colorName = colorName
}
}
@Model
class SavedLoad {
var name: String = ""
var voltage: Double = 0.0
var current: Double = 0.0
var power: Double = 0.0
var length: Double = 0.0
var crossSection: Double = 0.0
var timestamp: Date = Date()
var iconName: String = "lightbulb"
var colorName: String = "blue"
var isWattMode: Bool = false
var system: ElectricalSystem?
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) {
self.name = name
self.voltage = voltage
self.current = current
self.power = power
self.length = length
self.crossSection = crossSection
self.timestamp = Date()
self.iconName = iconName
self.colorName = colorName
self.isWattMode = isWattMode
self.system = system
}
}

693
Cable/CalculatorView.swift Normal file
View File

@@ -0,0 +1,693 @@
//
// CalculatorView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 11.09.25.
//
import SwiftUI
import SwiftData
struct CalculatorView: View {
@EnvironmentObject var unitSettings: UnitSystemSettings
@StateObject private var calculator = CableCalculator()
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Query private var savedLoads: [SavedLoad]
@State private var showingLibrary = false
@State private var isWattMode = false
@State private var editingValue: EditingValue? = nil
@State private var showingLoadEditor = false
let savedLoad: SavedLoad?
init(savedLoad: SavedLoad? = nil) {
self.savedLoad = savedLoad
}
enum EditingValue {
case voltage, current, power, length
}
var body: some View {
VStack(spacing: 0) {
badgesSection
circuitDiagram
resultsSection
mainContent
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .principal) {
navigationTitle
}
if savedLoad == nil {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
saveCurrentLoad()
}
}
}
}
.sheet(isPresented: $showingLibrary) {
LoadLibraryView(calculator: calculator)
}
.sheet(isPresented: $showingLoadEditor) {
LoadEditorView(
loadName: Binding(
get: { calculator.loadName },
set: {
calculator.loadName = $0
autoUpdateSavedLoad()
}
),
iconName: Binding(
get: { savedLoad?.iconName ?? "lightbulb" },
set: {
savedLoad?.iconName = $0
autoUpdateSavedLoad()
}
),
colorName: Binding(
get: { savedLoad?.colorName ?? "blue" },
set: {
savedLoad?.colorName = $0
autoUpdateSavedLoad()
}
)
)
}
.alert("Edit Length", isPresented: Binding(
get: { editingValue == .length },
set: { if !$0 { editingValue = nil } }
)) {
TextField("Length", value: $calculator.length, format: .number)
.keyboardType(.decimalPad)
Button("Cancel", role: .cancel) { editingValue = nil }
Button("Save") {
editingValue = nil
calculator.objectWillChange.send()
autoUpdateSavedLoad()
}
} message: {
Text("Enter length in \(unitSettings.unitSystem.lengthUnit)")
}
.alert("Edit Voltage", isPresented: Binding(
get: { editingValue == .voltage },
set: { if !$0 { editingValue = nil } }
)) {
TextField("Voltage", value: $calculator.voltage, format: .number)
.keyboardType(.decimalPad)
Button("Cancel", role: .cancel) { editingValue = nil }
Button("Save") {
editingValue = nil
if isWattMode {
calculator.updateFromPower()
} else {
calculator.updateFromCurrent()
}
calculator.objectWillChange.send()
autoUpdateSavedLoad()
}
} message: {
Text("Enter voltage in volts (V)")
}
.alert("Edit Current", isPresented: Binding(
get: { editingValue == .current },
set: { if !$0 { editingValue = nil } }
)) {
TextField("Current", value: $calculator.current, format: .number)
.keyboardType(.decimalPad)
Button("Cancel", role: .cancel) { editingValue = nil }
Button("Save") {
editingValue = nil
calculator.updateFromCurrent()
calculator.objectWillChange.send()
autoUpdateSavedLoad()
}
} message: {
Text("Enter current in amperes (A)")
}
.alert("Edit Power", isPresented: Binding(
get: { editingValue == .power },
set: { if !$0 { editingValue = nil } }
)) {
TextField("Power", value: $calculator.power, format: .number)
.keyboardType(.decimalPad)
Button("Cancel", role: .cancel) { editingValue = nil }
Button("Save") {
editingValue = nil
calculator.updateFromPower()
calculator.objectWillChange.send()
autoUpdateSavedLoad()
}
} message: {
Text("Enter power in watts (W)")
}
.onAppear {
if let savedLoad = savedLoad {
loadConfiguration(from: savedLoad)
}
}
}
private var loadIcon: String {
savedLoad?.iconName ?? "lightbulb"
}
private var loadColor: Color {
let colorName = savedLoad?.colorName ?? "blue"
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 var navigationTitle: some View {
Button(action: {
showingLoadEditor = true
}) {
HStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(loadColor)
.frame(width: 24, height: 24)
Image(systemName: loadIcon)
.font(.system(size: 12))
.foregroundColor(.white)
}
Text(calculator.loadName)
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
}
}
.buttonStyle(.plain)
}
private var badgesSection: some View {
HStack(spacing: 12) {
HStack(spacing: 4) {
Text("FUSE")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.secondary)
Text("\(calculator.recommendedFuse)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 ?
"%.1fft @ %.0f AWG" :
"%.1fm @ %.1fmm²",
unitSettings.unitSystem == .imperial ? calculator.length * 3.28084 : calculator.length,
calculator.crossSection(for: unitSettings.unitSystem)))
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(.blue)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.cornerRadius(6)
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color(.systemGroupedBackground))
}
private var circuitDiagram: some View {
HStack(spacing: 0) {
voltageSection
wiringSection
loadBox
}
.padding()
.background(Color(.systemGroupedBackground))
}
private var voltageSection: some View {
VStack(spacing: 0) {
VStack() {
HStack {
Button(action: {
editingValue = .voltage
}) {
Text(String(format: "%.1fV", calculator.voltage))
.font(.caption)
.fontWeight(.medium)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.frame(width: 60)
}
}
}
}
private var wiringSection: some View {
VStack {
HStack(alignment: .center, spacing: 2) {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
Rectangle()
.fill(.red)
.frame(height: 4)
.frame(width: 10)
Rectangle()
.fill(.green)
.frame(height: 30)
.frame(width:80)
.overlay(
Text("\(calculator.recommendedFuse)A")
.foregroundColor(.white)
.fontWeight(.bold)
)
.cornerRadius(6)
Rectangle()
.fill(.red)
.frame(height: 4)
.frame(maxWidth: .infinity)
}.offset(x: 0, y: -11)
HStack(alignment: .center, spacing: 2) {
Circle()
.fill(Color.primary)
.frame(width: 8, height: 8)
Rectangle()
.fill(Color.primary)
.frame(height: 4)
.frame(maxWidth: .infinity)
}
}
}
private var loadBox: some View {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
.frame(width: 120, height: 90)
.overlay(loadContent)
.overlay(connectionPoints)
}
}
private var loadContent: some View {
VStack {
Text(calculator.loadName.uppercased())
.font(.caption2)
.foregroundColor(.secondary)
Button(action: {
editingValue = isWattMode ? .power : .current
}) {
Text(String(format: "%.1fA", calculator.current))
.fontWeight(.semibold)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
Button(action: {
editingValue = isWattMode ? .current : .power
}) {
Text(String(format: "%.0fW", calculator.calculatedPower))
.font(.caption)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
}
}
private var connectionPoints: some View {
VStack {
HStack {
Circle()
.fill(.red)
.frame(width: 8, height: 8)
.offset(x: -4, y: 17)
Spacer()
}
Spacer()
HStack {
Circle()
.fill(Color.primary)
.frame(width: 8, height: 8)
.offset(x: -4, y: -17)
Spacer()
}
}
.frame(width: 120, height: 80)
}
private var resultsSection: some View {
HStack {
Group {
Button(action: {}) {
Text(unitSettings.unitSystem == .imperial ?
String(format: "%.0f AWG", calculator.crossSection(for: unitSettings.unitSystem)) :
String(format: "%.1f mm²", calculator.crossSection(for: unitSettings.unitSystem)))
.fontWeight(.medium)
.foregroundColor(.blue)
}
.buttonStyle(.plain)
Text("").foregroundColor(.secondary)
Button(action: { editingValue = .length }) {
Text(String(format: "%.1f %@", calculator.length, unitSettings.unitSystem.lengthUnit))
.fontWeight(.medium)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
Text("").foregroundColor(.secondary)
Button(action: {}) {
Text(String(format: "%.1fV (%.1f%%)", calculator.voltageDrop(for: unitSettings.unitSystem), calculator.voltageDropPercentage(for: unitSettings.unitSystem)))
.fontWeight(.medium)
.foregroundColor(calculator.voltageDropPercentage(for: unitSettings.unitSystem) > 5 ? .orange : .primary)
}
.buttonStyle(.plain)
Text("").foregroundColor(.secondary)
Button(action: {}) {
Text(String(format: "%.1fW", calculator.powerLoss(for: unitSettings.unitSystem)))
.fontWeight(.medium)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
}
.font(.caption)
}
.padding(.horizontal)
}
private var mainContent: some View {
ScrollView {
VStack(spacing: 20) {
Divider().padding(.horizontal)
slidersSection
}
}
}
private var slidersSection: some View {
VStack(spacing: 30) {
voltageSlider
currentPowerSlider
lengthSlider
}
.padding(.horizontal)
}
private var voltageSlider: some View {
SliderSection(title: "Voltage",
value: $calculator.voltage,
range: 3...48,
unit: "V",
tapAction: { editingValue = .voltage },
snapValues: [3.3, 5.0, 6.0, 9.0, 12.0, 13.8, 24.0, 28.0, 48.0])
.onChange(of: calculator.voltage) {
if isWattMode {
calculator.updateFromPower()
} else {
calculator.updateFromCurrent()
}
calculator.objectWillChange.send()
autoUpdateSavedLoad()
}
}
@ViewBuilder
private var currentPowerSlider: some View {
if isWattMode {
SliderSection(title: "Power",
value: $calculator.power,
range: 0...2000,
unit: "W",
buttonText: "Watt",
buttonAction: {
isWattMode = false
calculator.updateFromPower()
autoUpdateSavedLoad()
},
tapAction: { editingValue = .power },
snapValues: [5, 10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 125, 150, 200, 250, 300, 400, 500, 600, 750, 1000, 1250, 1500, 2000])
.onChange(of: calculator.power) {
calculator.updateFromPower()
calculator.objectWillChange.send()
autoUpdateSavedLoad()
}
} else {
SliderSection(title: "Current",
value: $calculator.current,
range: 0...100,
unit: "A",
buttonText: "Ampere",
buttonAction: {
isWattMode = true
calculator.updateFromCurrent()
autoUpdateSavedLoad()
},
tapAction: { editingValue = .current },
snapValues: [0.5, 1, 2, 3, 4, 5, 7.5, 10, 12, 15, 20, 25, 30, 40, 50, 60, 75, 80, 100])
.onChange(of: calculator.current) {
calculator.updateFromCurrent()
calculator.objectWillChange.send()
autoUpdateSavedLoad()
}
}
}
private var lengthSlider: some View {
SliderSection(title: "Cable Length (\(unitSettings.unitSystem.lengthUnit))",
value: $calculator.length,
range: 0...20,
unit: unitSettings.unitSystem.lengthUnit,
tapAction: { editingValue = .length },
snapValues: unitSettings.unitSystem == .metric ?
[0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7.5, 10, 12, 15, 20] :
[1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60])
.onChange(of: calculator.length) {
calculator.objectWillChange.send()
autoUpdateSavedLoad()
}
}
private func saveCurrentLoad() {
let savedLoad = SavedLoad(
name: calculator.loadName.isEmpty ? "Unnamed Load" : calculator.loadName,
voltage: calculator.voltage,
current: calculator.current,
power: calculator.power,
length: calculator.length,
crossSection: calculator.crossSection(for: .metric), // Always save in metric
iconName: "lightbulb",
colorName: "blue",
isWattMode: isWattMode,
system: nil // For now, new loads aren't associated with a system
)
modelContext.insert(savedLoad)
}
private func loadConfiguration(from savedLoad: SavedLoad) {
calculator.loadName = savedLoad.name
calculator.voltage = savedLoad.voltage
calculator.current = savedLoad.current
calculator.power = savedLoad.power
calculator.length = savedLoad.length
isWattMode = savedLoad.isWattMode
}
private func autoUpdateSavedLoad() {
guard let savedLoad = savedLoad else { return }
savedLoad.name = calculator.loadName.isEmpty ? "Unnamed Load" : calculator.loadName
savedLoad.voltage = calculator.voltage
savedLoad.current = calculator.current
savedLoad.power = calculator.power
savedLoad.length = calculator.length
savedLoad.crossSection = calculator.crossSection(for: .metric)
savedLoad.timestamp = Date()
savedLoad.isWattMode = isWattMode
// Icon and color are updated directly through bindings in the editor
}
}
// MARK: - Supporting Views
struct SliderSection: View {
let title: String
@Binding var value: Double
let range: ClosedRange<Double>
let unit: String
var buttonText: String?
var buttonAction: (() -> Void)?
var tapAction: (() -> Void)?
var snapValues: [Double]?
init(title: String, value: Binding<Double>, range: ClosedRange<Double>, unit: String, buttonText: String? = nil, buttonAction: (() -> Void)? = nil, tapAction: (() -> Void)? = nil, snapValues: [Double]? = nil) {
self.title = title
self._value = value
self.range = range
self.unit = unit
self.buttonText = buttonText
self.buttonAction = buttonAction
self.tapAction = tapAction
self.snapValues = snapValues
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(title)
.font(.headline)
Spacer()
if let buttonText = buttonText {
Button(buttonText) {
buttonAction?()
}
.buttonStyle(.borderedProminent)
}
}
Button(action: {
tapAction?()
}) {
Text(String(format: "%.1f %@", value, unit))
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
}
.buttonStyle(.plain)
.disabled(tapAction == nil)
HStack {
Text(String(format: "%.0f%@", range.lowerBound, unit))
.font(.caption)
.foregroundColor(.secondary)
Slider(value: $value, in: range)
.onChange(of: value) {
// Always round to 1 decimal place first
value = round(value * 10) / 10
if let snapValues = snapValues {
// Find the closest snap value
let closest = snapValues.min { abs($0 - value) < abs($1 - value) }
if let closest = closest, abs(closest - value) < 0.5 {
value = closest
}
}
}
Text(String(format: "%.0f%@", range.upperBound, unit))
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
struct LoadLibraryView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
@Query private var savedLoads: [SavedLoad]
let calculator: CableCalculator
var body: some View {
NavigationStack {
List {
ForEach(savedLoads) { load in
VStack(alignment: .leading, spacing: 4) {
Text(load.name)
.fontWeight(.medium)
HStack {
Text(String(format: "%.1fV", load.voltage))
Text("")
Text(String(format: "%.1fA", load.current))
Text("")
Text(String(format: "%.1fm", load.length))
Spacer()
Text(load.timestamp, format: .dateTime.month().day())
.foregroundColor(.secondary)
.font(.caption)
}
.font(.caption)
.foregroundColor(.secondary)
}
.contentShape(Rectangle())
.onTapGesture {
loadConfiguration(from: load)
dismiss()
}
}
.onDelete(perform: deleteLoads)
}
.navigationTitle("Load Library")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
}
}
}
private func loadConfiguration(from savedLoad: SavedLoad) {
calculator.loadName = savedLoad.name
calculator.voltage = savedLoad.voltage
calculator.current = savedLoad.current
calculator.length = savedLoad.length
}
private func deleteLoads(offsets: IndexSet) {
for index in offsets {
modelContext.delete(savedLoads[index])
}
}
}
#Preview {
CalculatorView()
.modelContainer(for: SavedLoad.self, inMemory: true)
.environmentObject(UnitSystemSettings())
}

View File

@@ -1,61 +1,674 @@
//
// ContentView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 11.09.25.
//
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var items: [Item]
@EnvironmentObject var unitSettings: UnitSystemSettings
@State private var selection = 1
var body: some View {
NavigationSplitView {
List {
ForEach(items) { item in
NavigationLink {
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
} label: {
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
}
TabView(selection: $selection) {
SystemsView()
.tabItem {
Label("Systems", systemImage: "building.2")
}
.onDelete(perform: deleteItems)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
.tag(1)
SystemView()
.tabItem {
Label("System", systemImage: "square.grid.3x2")
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
.tag(2)
SettingsView()
.tabItem {
Label("Settings", systemImage: "gearshape")
}
}
} detail: {
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Item(timestamp: Date())
modelContext.insert(newItem)
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(items[index])
}
.tag(3)
}
}
}
struct SystemsView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var unitSettings: UnitSystemSettings
@Query(sort: \ElectricalSystem.timestamp, order: .reverse) private var systems: [ElectricalSystem]
@State private var newSystemToEdit: ElectricalSystem?
@State private var systemToEdit: ElectricalSystem?
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)
}
.onTapGesture {
systemToEdit = system
}
VStack(alignment: .leading, spacing: 4) {
Text(system.name)
.fontWeight(.medium)
if !system.location.isEmpty {
Text(system.location)
.font(.caption)
.foregroundColor(.secondary)
}
Text(system.timestamp, format: .dateTime.month().day().hour().minute())
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
.onDelete(perform: deleteSystems)
}
}
}
.navigationTitle("Systems")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
createNewSystem()
}) {
Image(systemName: "plus")
}
EditButton()
}
}
}
.navigationDestination(item: $newSystemToEdit) { system in
LoadsView(system: system)
}
.sheet(item: $systemToEdit) { system in
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 }
)
)
}
}
}
private var systemsEmptyState: some View {
VStack(spacing: 0) {
Spacer()
VStack(spacing: 24) {
ZStack {
Circle()
.fill(Color.blue.opacity(0.1))
.frame(width: 80, height: 80)
Image(systemName: "building.2")
.font(.system(size: 40))
.foregroundColor(.blue)
}
VStack(spacing: 8) {
Text("No Systems Yet")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text("Create your first electrical system to start managing loads and calculations.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
Button(action: {
createNewSystem()
}) {
HStack(spacing: 8) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 16))
Text("Create System")
.fontWeight(.medium)
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(Color.blue)
.cornerRadius(12)
}
.buttonStyle(.plain)
.padding(.horizontal, 32)
}
Spacer()
Spacer()
}
}
private func createNewSystem() {
let existingNames = Set(systems.map { $0.name })
var systemName = "New System"
var counter = 1
while existingNames.contains(systemName) {
counter += 1
systemName = "New System \(counter)"
}
let newSystem = ElectricalSystem(
name: systemName,
location: "",
iconName: "building.2",
colorName: "blue"
)
modelContext.insert(newSystem)
newSystemToEdit = newSystem
}
private func deleteSystems(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(systems[index])
}
}
}
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
}
}
}
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?
let system: ElectricalSystem
init(system: ElectricalSystem) {
self.system = system
}
private var savedLoads: [SavedLoad] {
allLoads.filter { $0.system == system }
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
librarySection
if savedLoads.isEmpty {
emptyStateView
} else {
List {
ForEach(savedLoads) { load in
NavigationLink(destination: CalculatorView(savedLoad: load)) {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(colorForName(load.colorName))
.frame(width: 44, height: 44)
Image(systemName: load.iconName)
.font(.title3)
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(load.name)
.fontWeight(.medium)
Spacer()
Text(load.timestamp, format: .dateTime.month().day().hour().minute())
.foregroundColor(.secondary)
.font(.caption)
}
// 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)
}
}
}
.navigationTitle(system.name)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
createNewLoad()
}) {
Image(systemName: "plus")
}
EditButton()
}
}
}
.navigationDestination(item: $newLoadToEdit) { load in
CalculatorView(savedLoad: load)
}
}
}
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: {
// TODO: Open VoltPlan component library
print("Opening VoltPlan component library...")
}) {
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 {
VStack(spacing: 0) {
Spacer()
VStack(spacing: 24) {
// Icon
ZStack {
Circle()
.fill(Color.blue.opacity(0.1))
.frame(width: 80, height: 80)
Image(systemName: "bolt.circle")
.font(.system(size: 40))
.foregroundColor(.blue)
}
// Title and subtitle
VStack(spacing: 8) {
Text("No Loads Yet")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text("Get started by creating a new electrical load calculation or browse components from the VoltPlan library.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
}
// Action buttons
VStack(spacing: 12) {
Button(action: {
createNewLoad()
}) {
HStack(spacing: 8) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 16))
Text("Create New Load")
.fontWeight(.medium)
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(Color.blue)
.cornerRadius(12)
}
.buttonStyle(.plain)
Button(action: {
// TODO: Open VoltPlan component library
print("Opening VoltPlan component library...")
}) {
HStack(spacing: 8) {
Image(systemName: "square.grid.3x3")
.font(.system(size: 16))
Text("Browse VoltPlan Library")
.fontWeight(.medium)
Image(systemName: "arrow.up.right")
.font(.system(size: 12))
}
.foregroundColor(.blue)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(Color.blue.opacity(0.1))
.cornerRadius(12)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 32)
}
Spacer()
// Safety disclaimer
VStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.system(size: 16))
Text("Important Safety Notice")
.font(.headline)
.fontWeight(.semibold)
}
Text("This app provides estimates for educational purposes only. Always consult qualified electricians and follow local electrical codes for actual installations. Electrical work can be dangerous and should only be performed by licensed professionals.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
.padding(.bottom, 32)
}
}
private func deleteLoads(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(savedLoads[index])
}
}
}
private func createNewLoad() {
let existingNames = Set(savedLoads.map { $0.name })
var loadName = "New Load"
var counter = 1
while existingNames.contains(loadName) {
counter += 1
loadName = "New Load \(counter)"
}
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
)
modelContext.insert(newLoad)
// Navigate to the new load
newLoadToEdit = newLoad
}
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!
}
}
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")
}
}
}
struct SettingsView: View {
@EnvironmentObject var unitSettings: UnitSystemSettings
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")
}
}
}
#Preview {
ContentView()
.modelContainer(for: Item.self, inMemory: true)
.environmentObject(UnitSystemSettings())
}

177
Cable/ItemEditorView.swift Normal file
View File

@@ -0,0 +1,177 @@
//
// ItemEditorView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 16.09.25.
//
import SwiftUI
struct ItemEditorView: View {
@Environment(\.dismiss) private var dismiss
let title: String
let nameFieldLabel: String
let previewSubtitle: String
let icons: [String]
let additionalFields: () -> AnyView
@Binding var name: String
@Binding var iconName: String
@Binding var colorName: String
@State private var tempName: String
@State private var tempIconName: String
@State private var tempColorName: String
private let curatedColors: [(String, Color)] = [
("blue", .blue),
("green", .green),
("orange", .orange),
("red", .red),
("purple", .purple),
("yellow", .yellow),
("pink", .pink),
("teal", .teal),
("indigo", .indigo),
("mint", .mint),
("cyan", .cyan),
("brown", .brown),
("gray", .gray)
]
init(
title: String,
nameFieldLabel: String,
previewSubtitle: String,
icons: [String],
name: Binding<String>,
iconName: Binding<String>,
colorName: Binding<String>,
@ViewBuilder additionalFields: @escaping () -> AnyView = { AnyView(EmptyView()) }
) {
self.title = title
self.nameFieldLabel = nameFieldLabel
self.previewSubtitle = previewSubtitle
self.icons = icons
self.additionalFields = additionalFields
self._name = name
self._iconName = iconName
self._colorName = colorName
self._tempName = State(initialValue: name.wrappedValue)
self._tempIconName = State(initialValue: iconName.wrappedValue)
self._tempColorName = State(initialValue: colorName.wrappedValue)
}
var body: some View {
NavigationStack {
Form {
Section("Preview") {
HStack {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(selectedColor)
.frame(width: 60, height: 60)
Image(systemName: tempIconName)
.font(.title2)
.foregroundColor(.white)
}
VStack(alignment: .leading) {
Text(tempName.isEmpty ? nameFieldLabel : tempName)
.font(.headline)
.foregroundColor(.primary)
Text(previewSubtitle)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 8)
}
Section("Details") {
TextField(nameFieldLabel, text: $tempName)
.autocapitalization(.words)
additionalFields()
}
Section("Icon") {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 5), spacing: 16) {
ForEach(icons, id: \.self) { icon in
Button(action: {
tempIconName = icon
}) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(tempIconName == icon ? selectedColor : Color(.systemGray5))
.frame(width: 50, height: 50)
Image(systemName: icon)
.font(.title3)
.foregroundColor(tempIconName == icon ? .white : .primary)
}
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 8)
}
Section("Color") {
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: 16) {
ForEach(curatedColors, id: \.0) { colorName, color in
Button(action: {
tempColorName = colorName
}) {
ZStack {
Circle()
.fill(color)
.frame(width: 40, height: 40)
if tempColorName == colorName {
Image(systemName: "checkmark")
.font(.headline)
.fontWeight(.bold)
.foregroundColor(.white)
}
}
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 8)
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
saveChanges()
dismiss()
}
.fontWeight(.semibold)
}
}
}
}
private var selectedColor: Color {
curatedColors.first { $0.0 == tempColorName }?.1 ?? .blue
}
private func saveChanges() {
name = tempName
iconName = tempIconName
colorName = tempColorName
}
}

View File

@@ -0,0 +1,42 @@
//
// LoadEditorView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 11.09.25.
//
import SwiftUI
struct LoadEditorView: View {
@Binding var loadName: String
@Binding var iconName: String
@Binding var colorName: String
private let loadIcons = [
"lightbulb", "lamp.desk", "fan", "tv", "poweroutlet.strip","poweroutlet.type.c", "bolt", "xbox.logo", "playstation.logo", "batteryblock", "speaker.wave.2", "refrigerator",
"washer", "dishwasher", "stove", "microwave", "dryer", "cooktop",
"car", "bolt.car", "engine.combustion", "wrench.adjustable",
"cpu", "desktopcomputer", "laptopcomputer", "iphone", "camera",
"gamecontroller", "headphones", "printer", "wifi", "antenna.radiowaves.left.and.right"
]
var body: some View {
ItemEditorView(
title: "Edit Load",
nameFieldLabel: "Load name",
previewSubtitle: "Preview",
icons: loadIcons,
name: $loadName,
iconName: $iconName,
colorName: $colorName
)
}
}
#Preview {
@State var name = "My Load"
@State var icon = "lightbulb"
@State var color = "blue"
return LoadEditorView(loadName: $name, iconName: $icon, colorName: $color)
}

View File

@@ -0,0 +1,66 @@
//
// SystemEditorView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 16.09.25.
//
import SwiftUI
struct SystemEditorView: View {
@Binding var systemName: String
@Binding var location: String
@Binding var iconName: String
@Binding var colorName: String
@State private var tempLocation: String
private let systemIcons = [
"building.2", "house", "building", "factory", "office.building", "tent", "car.garage", "sailboat",
"airplane", "ferry", "bus", "truck.box", "van.side", "rv",
"server.rack", "externaldrive", "cpu", "gear", "wrench.adjustable", "hammer",
"lightbulb", "bolt", "powerplug", "battery.100", "solar.panel", "windmill",
"engine.combustion", "fuelpump", "drop", "flame", "snowflake", "thermometer"
]
init(systemName: Binding<String>, location: Binding<String>, iconName: Binding<String>, colorName: Binding<String>) {
self._systemName = systemName
self._location = location
self._iconName = iconName
self._colorName = colorName
self._tempLocation = State(initialValue: location.wrappedValue)
}
var body: some View {
ItemEditorView(
title: "Edit System",
nameFieldLabel: "System name",
previewSubtitle: tempLocation.isEmpty ? "Location (optional)" : tempLocation,
icons: systemIcons,
name: $systemName,
iconName: $iconName,
colorName: $colorName,
additionalFields: {
AnyView(
TextField("Location (optional)", text: $tempLocation)
.autocapitalization(.words)
.onChange(of: tempLocation) { _, newValue in
location = newValue
}
)
}
)
.onAppear {
tempLocation = location
}
}
}
#Preview {
@State var name = "My System"
@State var location = "Main Building"
@State var icon = "building.2"
@State var color = "blue"
return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color)
}

54
Cable/UnitSystem.swift Normal file
View File

@@ -0,0 +1,54 @@
//
// UnitSystem.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 11.09.25.
//
import Foundation
enum UnitSystem: String, CaseIterable {
case metric = "metric"
case imperial = "imperial"
var displayName: String {
switch self {
case .metric:
return "Metric (mm², m)"
case .imperial:
return "Imperial (AWG, ft)"
}
}
var wireAreaUnit: String {
switch self {
case .metric:
return "mm²"
case .imperial:
return "AWG"
}
}
var lengthUnit: String {
switch self {
case .metric:
return "m"
case .imperial:
return "ft"
}
}
}
@MainActor
class UnitSystemSettings: ObservableObject {
@Published var unitSystem: UnitSystem {
didSet {
UserDefaults.standard.set(unitSystem.rawValue, forKey: "unitSystem")
}
}
init() {
let savedSystem = UserDefaults.standard.string(forKey: "unitSystem") ?? UnitSystem.metric.rawValue
self.unitSystem = UnitSystem(rawValue: savedSystem) ?? .metric
}
}