more consitancy

This commit is contained in:
Stefan Lange-Hegermann
2025-10-22 22:43:03 +02:00
parent 802b111aa7
commit 6258a6a66f
25 changed files with 448 additions and 260 deletions

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
}
}

View File

@@ -0,0 +1,163 @@
import Foundation
import SwiftUI
import SwiftData
struct SystemComponentsPersistence {
static func createDefaultLoad(
for system: ElectricalSystem,
in context: ModelContext,
existingLoads: [SavedLoad],
existingBatteries: [SavedBattery]
) -> SavedLoad {
let defaultName = String(
localized: "default.load.new",
comment: "Default name when creating a new load from system view"
)
let loadName = uniqueName(
startingWith: defaultName,
loads: existingLoads,
batteries: existingBatteries
)
let newLoad = SavedLoad(
name: loadName,
voltage: 12.0,
current: 5.0,
power: 60.0,
length: 10.0,
crossSection: 1.0,
iconName: "lightbulb",
colorName: "blue",
isWattMode: false,
system: system,
remoteIconURLString: nil
)
context.insert(newLoad)
return newLoad
}
static func createLoad(
from item: ComponentLibraryItem,
for system: ElectricalSystem,
in context: ModelContext,
existingLoads: [SavedLoad],
existingBatteries: [SavedBattery]
) -> SavedLoad {
let localizedName = item.localizedName
let baseName = localizedName.isEmpty ? "Library Load" : localizedName
let loadName = uniqueName(
startingWith: baseName,
loads: existingLoads,
batteries: existingBatteries
)
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
)
context.insert(newLoad)
return newLoad
}
static func makeBatteryDraft(
for system: ElectricalSystem,
existingLoads: [SavedLoad],
existingBatteries: [SavedBattery]
) -> BatteryConfiguration {
let defaultName = NSLocalizedString(
"battery.editor.default_name",
bundle: .main,
value: "New Battery",
comment: "Default name when configuring a new battery"
)
let batteryName = uniqueName(
startingWith: defaultName,
loads: existingLoads,
batteries: existingBatteries
)
return BatteryConfiguration(
name: batteryName,
iconName: "battery.100.bolt",
colorName: system.colorName,
system: system
)
}
static func saveBattery(
_ configuration: BatteryConfiguration,
for system: ElectricalSystem,
existingBatteries: [SavedBattery],
in context: ModelContext
) {
if let existing = existingBatteries.first(where: { $0.id == configuration.id }) {
configuration.apply(to: existing)
} else {
let newBattery = SavedBattery(
id: configuration.id,
name: configuration.name,
nominalVoltage: configuration.nominalVoltage,
capacityAmpHours: configuration.capacityAmpHours,
chemistry: configuration.chemistry,
iconName: configuration.iconName,
colorName: configuration.colorName,
system: system
)
context.insert(newBattery)
}
}
static func deleteBatteries(
at offsets: IndexSet,
from batteries: [SavedBattery],
in context: ModelContext
) {
for index in offsets {
context.delete(batteries[index])
}
}
static func uniqueName(
startingWith baseName: String,
loads: [SavedLoad],
batteries: [SavedBattery]
) -> String {
let existingNames = Set(loads.map { $0.name } + batteries.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
}
}

View File

@@ -0,0 +1,70 @@
//
// 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", "tent", "sailboat",
"airplane", "ferry", "bus", "truck.box",
"server.rack", "externaldrive", "cpu", "gear", "wrench.adjustable", "hammer",
"lightbulb", "bolt", "powerplug", "battery.100","sun.max",
"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 {
let editorTitle = String(localized: "editor.system.title", comment: "Title for the system editor")
let namePlaceholder = String(localized: "editor.system.name_field", comment: "Label for the system name text field")
let locationPlaceholder = String(localized: "editor.system.location.optional", comment: "Placeholder text shown when no location is specified")
ItemEditorView(
title: editorTitle,
nameFieldLabel: namePlaceholder,
previewSubtitle: tempLocation.isEmpty ? locationPlaceholder : tempLocation,
icons: systemIcons,
name: $systemName,
iconName: $iconName,
colorName: $colorName,
additionalFields: {
AnyView(
TextField(locationPlaceholder, text: $tempLocation)
.autocapitalization(.words)
.onChange(of: tempLocation) { _, newValue in
location = newValue
}
)
}
)
.onAppear {
tempLocation = location
}
}
}
#Preview {
@Previewable @State var name = "My System"
@Previewable @State var location = "Main Building"
@Previewable @State var icon = "building.2"
@Previewable @State var color = "blue"
return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color)
}

View File

@@ -0,0 +1,129 @@
import SwiftUI
struct SystemsOnboardingView: View {
@State private var systemName: String = String(localized: "default.system.name", comment: "Default placeholder name for a system")
@State private var carouselStep = 0
@FocusState private var isFieldFocused: Bool
let onCreate: (String) -> Void
private let imageNames = [
"van-onboarding",
"cabin-onboarding",
"boat-onboarding"
]
private let timer = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
private let animationDuration = 0.8
private var loopingImages: [String] {
guard let first = imageNames.first else { return [] }
return imageNames + [first]
}
var body: some View {
VStack() {
Spacer(minLength: 32)
OnboardingCarouselView(images: loopingImages, step: carouselStep)
.frame(minHeight: 80, maxHeight: 240)
.padding(.horizontal, 0)
VStack(spacing: 12) {
Text("Create your first system")
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
Text("Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place.")
.font(.body)
.foregroundStyle(Color.secondary)
.multilineTextAlignment(.center)
.frame(minHeight: 96)
.padding(.horizontal, 12)
}
.padding(.horizontal, 24)
Spacer()
VStack(spacing: 16) {
HStack(spacing: 12) {
Image(systemName: "sparkles")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isFieldFocused ? Color.accentColor : Color.accentColor.opacity(0.6))
TextField("System Name", text: $systemName)
.textInputAutocapitalization(.words)
.disableAutocorrection(true)
.focused($isFieldFocused)
.submitLabel(.done)
.onSubmit(createSystem)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(isFieldFocused ? Color.accentColor : Color.clear, lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.08), radius: 12, x: 0, y: 6)
.animation(.easeInOut(duration: 0.2), value: isFieldFocused)
Button(action: createSystem) {
HStack(spacing:8) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 16))
Text("Create System")
.font(.headline.weight(.semibold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(Color.blue)
.cornerRadius(12)
}
.accessibilityIdentifier("create-system-button")
.buttonStyle(.plain)
}
.padding(.horizontal, 24)
}
.padding(.bottom, 32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.onAppear(perform: resetState)
.onReceive(timer) { _ in advanceCarousel() }
}
private func resetState() {
systemName = String(localized: "default.system.name", comment: "Default placeholder name for a system")
carouselStep = 0
}
private func createSystem() {
isFieldFocused = false
let trimmed = systemName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
onCreate(trimmed)
}
private func advanceCarousel() {
guard imageNames.count > 1 else { return }
let next = carouselStep + 1
withAnimation(.easeInOut(duration: animationDuration)) {
carouselStep = next
}
if next == imageNames.count {
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
withAnimation(.none) {
carouselStep = 0
}
}
}
}
}
#Preview {
SystemsOnboardingView { _ in }
}

View File

@@ -0,0 +1,492 @@
//
// 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
@State private var hasPerformedInitialAutoNavigation = false
private let systemColorOptions = [
"blue", "green", "orange", "red", "purple", "yellow",
"pink", "teal", "indigo", "mint", "cyan", "brown", "gray"
]
private let defaultSystemIconName = "building.2"
private var systemIconMappings: [(keywords: [String], icon: String)] {
[
(keywords(for: "system.icon.keywords.rv", fallback: ["rv", "van", "camper", "motorhome", "coach"]), "bus"),
(keywords(for: "system.icon.keywords.truck", fallback: ["truck", "trailer", "rig"]), "truck.box"),
(keywords(for: "system.icon.keywords.boat", fallback: ["boat", "marine", "yacht", "sail"]), "sailboat"),
(keywords(for: "system.icon.keywords.plane", fallback: ["plane", "air", "flight"]), "airplane"),
(keywords(for: "system.icon.keywords.ferry", fallback: ["ferry", "ship"]), "ferry"),
(keywords(for: "system.icon.keywords.house", fallback: ["house", "home", "cabin", "cottage", "lodge"]), "house"),
(keywords(for: "system.icon.keywords.building", fallback: ["building", "office", "warehouse", "factory", "facility"]), "building"),
(keywords(for: "system.icon.keywords.tent", fallback: ["camp", "tent", "outdoor"]), "tent"),
(keywords(for: "system.icon.keywords.solar", fallback: ["solar", "sun"]), "sun.max"),
(keywords(for: "system.icon.keywords.battery", fallback: ["battery", "storage"]), "battery.100"),
(keywords(for: "system.icon.keywords.server", fallback: ["server", "data", "network", "rack"]), "server.rack"),
(keywords(for: "system.icon.keywords.computer", fallback: ["computer", "electronics", "lab", "tech"]), "cpu"),
(keywords(for: "system.icon.keywords.gear", fallback: ["gear", "mechanic", "machine", "workshop"]), "gear"),
(keywords(for: "system.icon.keywords.tool", fallback: ["tool", "maintenance", "repair", "shop"]), "wrench.adjustable"),
(keywords(for: "system.icon.keywords.hammer", fallback: ["hammer", "carpentry"]), "hammer"),
(keywords(for: "system.icon.keywords.light", fallback: ["light", "lighting", "lamp"]), "lightbulb"),
(keywords(for: "system.icon.keywords.bolt", fallback: ["bolt", "power", "electric"]), "bolt"),
(keywords(for: "system.icon.keywords.plug", fallback: ["plug"]), "powerplug"),
(keywords(for: "system.icon.keywords.engine", fallback: ["engine", "generator", "motor"]), "engine.combustion"),
(keywords(for: "system.icon.keywords.fuel", fallback: ["fuel", "diesel", "gas"]), "fuelpump"),
(keywords(for: "system.icon.keywords.water", fallback: ["water", "pump", "tank"]), "drop"),
(keywords(for: "system.icon.keywords.heat", fallback: ["heat", "heater", "furnace"]), "flame"),
(keywords(for: "system.icon.keywords.cold", fallback: ["cold", "freeze", "cool"]), "snowflake"),
(keywords(for: "system.icon.keywords.climate", fallback: ["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
)
}
}
.onAppear {
performInitialAutoNavigationIfNeeded()
}
.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 performInitialAutoNavigationIfNeeded() {
guard !hasPerformedInitialAutoNavigation else { return }
hasPerformedInitialAutoNavigation = true
guard systems.count == 1, let system = systems.first else { return }
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil, animated: false)
}
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 localizedName = item.localizedName
let baseName = localizedName.isEmpty
? String(localized: "default.load.library", comment: "Default name when importing a library load")
: localizedName
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 keywords(for localizationKey: String, fallback: [String]) -> [String] {
let fallbackValue = fallback.joined(separator: ",")
let localizedKeywords = NSLocalizedString(
localizationKey,
tableName: nil,
bundle: .main,
value: fallbackValue,
comment: ""
)
let separators = CharacterSet(charactersIn: ",;")
let components = localizedKeywords
.components(separatedBy: separators)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter { !$0.isEmpty }
var uniqueKeywords: [String] = []
for keyword in fallback.map({ $0.lowercased() }) + components {
if !uniqueKeywords.contains(keyword) {
uniqueKeywords.append(keyword)
}
}
return uniqueKeywords
}
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
}
}
}
#Preview("Sample Systems") {
// An in-memory SwiftData container for previews so we don't persist anything
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: ElectricalSystem.self, SavedLoad.self, configurations: configuration)
// Seed sample data only once per preview session
if (try? ModelContext(container).fetch(FetchDescriptor<ElectricalSystem>()))?.isEmpty ?? true {
let context = ModelContext(container)
// Sample systems
let system1 = ElectricalSystem(name: "Camper Van", location: "Road Trip", iconName: "bus", colorName: "teal")
let system2 = ElectricalSystem(name: "Sailboat Aurora", location: "Marina 7", iconName: "sailboat", colorName: "blue")
context.insert(system1)
context.insert(system2)
// Sample loads for system 1
let load1 = SavedLoad(
name: "LED Cabin Light",
voltage: 12,
current: 0.5,
power: 6,
length: 5,
crossSection: 1.5,
iconName: "lightbulb",
colorName: "yellow",
isWattMode: false,
system: system1,
remoteIconURLString: nil,
affiliateURLString: nil,
affiliateCountryCode: nil
)
let load2 = SavedLoad(
name: "Water Pump",
voltage: 12,
current: 5,
power: 60,
length: 3,
crossSection: 2.5,
iconName: "drop",
colorName: "blue",
isWattMode: false,
system: system1,
remoteIconURLString: nil,
affiliateURLString: nil,
affiliateCountryCode: nil
)
// Sample loads for system 2
let load3 = SavedLoad(
name: "Navigation Lights",
voltage: 12,
current: 1.2,
power: 14.4,
length: 8,
crossSection: 1.5,
iconName: "lightbulb",
colorName: "green",
isWattMode: false,
system: system2,
remoteIconURLString: nil,
affiliateURLString: nil,
affiliateCountryCode: nil
)
context.insert(load1)
context.insert(load2)
context.insert(load3)
}
return SystemsView()
.modelContainer(container)
.environmentObject(UnitSystemSettings())
}