578 lines
21 KiB
Swift
578 lines
21 KiB
Swift
//
|
|
// SystemsView.swift
|
|
// Cable
|
|
//
|
|
// Created by Stefan Lange-Hegermann on 09.10.25.
|
|
//
|
|
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import PostHog
|
|
|
|
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(Color.componentColor(named: 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)
|
|
}
|
|
.simultaneousGesture(
|
|
TapGesture().onEnded {
|
|
PostHogSDK.shared.capture(
|
|
"System Opened",
|
|
properties: [
|
|
"name": system.name,
|
|
"source": "list"
|
|
]
|
|
)
|
|
}
|
|
)
|
|
}
|
|
.onDelete(perform: deleteSystems)
|
|
}
|
|
.accessibilityIdentifier("systems-list")
|
|
}
|
|
}
|
|
.navigationTitle("Systems")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button {
|
|
openSettings()
|
|
} label: {
|
|
Image(systemName: "gearshape")
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
HStack {
|
|
Button(action: {
|
|
PostHogSDK.shared.capture("System Create Navigation")
|
|
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 openSettings() {
|
|
PostHogSDK.shared.capture("Settings Opened")
|
|
showingSettings = true
|
|
}
|
|
|
|
private func createNewSystem() {
|
|
let system = makeSystem()
|
|
PostHogSDK.shared.capture(
|
|
"System Created",
|
|
properties: [
|
|
"name": system.name,
|
|
"source": "toolbar"
|
|
]
|
|
)
|
|
navigateToSystem(
|
|
system,
|
|
presentSystemEditor: true,
|
|
loadToOpen: nil,
|
|
source: "created"
|
|
)
|
|
}
|
|
|
|
private func createNewSystem(named name: String) {
|
|
let system = makeSystem(preferredName: name)
|
|
PostHogSDK.shared.capture(
|
|
"System Created",
|
|
properties: [
|
|
"name": system.name,
|
|
"source": "named"
|
|
]
|
|
)
|
|
navigateToSystem(
|
|
system,
|
|
presentSystemEditor: true,
|
|
loadToOpen: nil,
|
|
source: "created-named"
|
|
)
|
|
}
|
|
|
|
private func createOnboardingSystem(named name: String) {
|
|
let system = makeSystem(
|
|
preferredName: name,
|
|
colorName: randomSystemColorName()
|
|
)
|
|
navigateToSystem(
|
|
system,
|
|
presentSystemEditor: false,
|
|
loadToOpen: nil,
|
|
source: "onboarding"
|
|
)
|
|
}
|
|
|
|
private func navigateToSystem(
|
|
_ system: ElectricalSystem,
|
|
presentSystemEditor: Bool,
|
|
loadToOpen: SavedLoad?,
|
|
animated: Bool = true,
|
|
source: String = "programmatic"
|
|
) {
|
|
PostHogSDK.shared.capture(
|
|
"System Opened",
|
|
properties: [
|
|
"name": system.name,
|
|
"source": source,
|
|
"loads": loads(for: system).count
|
|
]
|
|
)
|
|
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,
|
|
source: "auto"
|
|
)
|
|
}
|
|
|
|
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
|
|
let system = makeSystem()
|
|
PostHogSDK.shared.capture(
|
|
"System Created",
|
|
properties: [
|
|
"name": system.name,
|
|
"source": "library"
|
|
]
|
|
)
|
|
let load = createLoad(from: item, in: system)
|
|
PostHogSDK.shared.capture(
|
|
"Library Load Added",
|
|
properties: [
|
|
"id": item.id,
|
|
"name": item.localizedName,
|
|
"system": system.name
|
|
]
|
|
)
|
|
navigateToSystem(
|
|
system,
|
|
presentSystemEditor: false,
|
|
loadToOpen: load,
|
|
animated: false,
|
|
source: "library"
|
|
)
|
|
}
|
|
|
|
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) {
|
|
let systemsToDelete = offsets.map { systems[$0] }
|
|
withAnimation {
|
|
for system in systemsToDelete {
|
|
PostHogSDK.shared.capture(
|
|
"System Deleted",
|
|
properties: [
|
|
"name": system.name,
|
|
"loads": loads(for: system).count
|
|
]
|
|
)
|
|
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 {
|
|
PostHogSDK.shared.capture(
|
|
"Load Deleted",
|
|
properties: [
|
|
"name": load.name,
|
|
"system": system.name,
|
|
"source": "system-delete"
|
|
]
|
|
)
|
|
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
|
|
}
|
|
|
|
}
|
|
|
|
#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())
|
|
}
|