more consitancy
This commit is contained in:
382
Cable/Systems/SystemBillOfMaterialsView.swift
Normal file
382
Cable/Systems/SystemBillOfMaterialsView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
163
Cable/Systems/SystemComponentsPersistence.swift
Normal file
163
Cable/Systems/SystemComponentsPersistence.swift
Normal 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
|
||||
}
|
||||
}
|
||||
70
Cable/Systems/SystemEditorView.swift
Normal file
70
Cable/Systems/SystemEditorView.swift
Normal 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)
|
||||
}
|
||||
129
Cable/Systems/SystemsOnboardingView.swift
Normal file
129
Cable/Systems/SystemsOnboardingView.swift
Normal 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 }
|
||||
}
|
||||
492
Cable/Systems/SystemsView.swift
Normal file
492
Cable/Systems/SystemsView.swift
Normal 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())
|
||||
}
|
||||
Reference in New Issue
Block a user