adds component library
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "ios-marketing.png",
|
"filename" : "Cable-iOS-Default-1024x1024@1x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
|
"filename" : "powered.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
Cable/Assets.xcassets/PoweredByVoltplan.imageset/powered.png
vendored
Normal file
BIN
Cable/Assets.xcassets/PoweredByVoltplan.imageset/powered.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@@ -133,8 +133,9 @@ class SavedLoad {
|
|||||||
var colorName: String = "blue"
|
var colorName: String = "blue"
|
||||||
var isWattMode: Bool = false
|
var isWattMode: Bool = false
|
||||||
var system: ElectricalSystem?
|
var system: ElectricalSystem?
|
||||||
|
var remoteIconURLString: String? = nil
|
||||||
|
|
||||||
init(name: String, voltage: Double, current: Double, power: Double, length: Double, crossSection: Double, iconName: String = "lightbulb", colorName: String = "blue", isWattMode: Bool = false, system: ElectricalSystem? = nil) {
|
init(name: String, voltage: Double, current: Double, power: Double, length: Double, crossSection: Double, iconName: String = "lightbulb", colorName: String = "blue", isWattMode: Bool = false, system: ElectricalSystem? = nil, remoteIconURLString: String? = nil) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.voltage = voltage
|
self.voltage = voltage
|
||||||
self.current = current
|
self.current = current
|
||||||
@@ -146,5 +147,6 @@ class SavedLoad {
|
|||||||
self.colorName = colorName
|
self.colorName = colorName
|
||||||
self.isWattMode = isWattMode
|
self.isWattMode = isWattMode
|
||||||
self.system = system
|
self.system = system
|
||||||
|
self.remoteIconURLString = remoteIconURLString
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ struct CalculatorView: View {
|
|||||||
),
|
),
|
||||||
iconName: Binding(
|
iconName: Binding(
|
||||||
get: { savedLoad?.iconName ?? "lightbulb" },
|
get: { savedLoad?.iconName ?? "lightbulb" },
|
||||||
set: {
|
set: { newValue in
|
||||||
savedLoad?.iconName = $0
|
guard let savedLoad else { return }
|
||||||
|
savedLoad.iconName = newValue
|
||||||
|
savedLoad.remoteIconURLString = nil
|
||||||
autoUpdateSavedLoad()
|
autoUpdateSavedLoad()
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -76,6 +78,14 @@ struct CalculatorView: View {
|
|||||||
savedLoad?.colorName = $0
|
savedLoad?.colorName = $0
|
||||||
autoUpdateSavedLoad()
|
autoUpdateSavedLoad()
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
remoteIconURLString: Binding(
|
||||||
|
get: { savedLoad?.remoteIconURLString },
|
||||||
|
set: { newValue in
|
||||||
|
guard let savedLoad else { return }
|
||||||
|
savedLoad.remoteIconURLString = newValue
|
||||||
|
autoUpdateSavedLoad()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -156,7 +166,7 @@ struct CalculatorView: View {
|
|||||||
private var loadIcon: String {
|
private var loadIcon: String {
|
||||||
savedLoad?.iconName ?? "lightbulb"
|
savedLoad?.iconName ?? "lightbulb"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var loadColor: Color {
|
private var loadColor: Color {
|
||||||
let colorName = savedLoad?.colorName ?? "blue"
|
let colorName = savedLoad?.colorName ?? "blue"
|
||||||
switch colorName {
|
switch colorName {
|
||||||
@@ -176,21 +186,21 @@ struct CalculatorView: View {
|
|||||||
default: return .blue
|
default: return .blue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var loadRemoteIconURLString: String? {
|
||||||
|
savedLoad?.remoteIconURLString
|
||||||
|
}
|
||||||
|
|
||||||
private var navigationTitle: some View {
|
private var navigationTitle: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingLoadEditor = true
|
showingLoadEditor = true
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ZStack {
|
LoadIconView(
|
||||||
RoundedRectangle(cornerRadius: 6)
|
remoteIconURLString: loadRemoteIconURLString,
|
||||||
.fill(loadColor)
|
fallbackSystemName: loadIcon,
|
||||||
.frame(width: 24, height: 24)
|
fallbackColor: loadColor,
|
||||||
|
size: 24)
|
||||||
Image(systemName: loadIcon)
|
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(calculator.loadName)
|
Text(calculator.loadName)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -518,7 +528,8 @@ struct CalculatorView: View {
|
|||||||
iconName: "lightbulb",
|
iconName: "lightbulb",
|
||||||
colorName: "blue",
|
colorName: "blue",
|
||||||
isWattMode: isWattMode,
|
isWattMode: isWattMode,
|
||||||
system: nil // For now, new loads aren't associated with a system
|
system: nil, // For now, new loads aren't associated with a system
|
||||||
|
remoteIconURLString: nil
|
||||||
)
|
)
|
||||||
modelContext.insert(savedLoad)
|
modelContext.insert(savedLoad)
|
||||||
}
|
}
|
||||||
|
|||||||
287
Cable/ComponentLibraryView.swift
Normal file
287
Cable/ComponentLibraryView.swift
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ComponentLibraryItem: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let voltageIn: Double?
|
||||||
|
let voltageOut: Double?
|
||||||
|
let watt: Double?
|
||||||
|
let iconURL: URL?
|
||||||
|
|
||||||
|
var displayVoltage: Double? {
|
||||||
|
voltageIn ?? voltageOut
|
||||||
|
}
|
||||||
|
|
||||||
|
var current: Double? {
|
||||||
|
guard let power = watt, let voltage = displayVoltage, voltage > 0 else { return nil }
|
||||||
|
return power / voltage
|
||||||
|
}
|
||||||
|
|
||||||
|
var voltageLabel: String? {
|
||||||
|
guard let voltage = displayVoltage else { return nil }
|
||||||
|
return String(format: "%.1fV", voltage)
|
||||||
|
}
|
||||||
|
|
||||||
|
var powerLabel: String? {
|
||||||
|
guard let power = watt else { return nil }
|
||||||
|
return String(format: "%.0fW", power)
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentLabel: String? {
|
||||||
|
guard let current else { return nil }
|
||||||
|
return String(format: "%.1fA", current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ComponentLibraryViewModel: ObservableObject {
|
||||||
|
@Published private(set) var isLoading = false
|
||||||
|
@Published private(set) var items: [ComponentLibraryItem] = []
|
||||||
|
@Published private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
private let baseURL = URL(string: "https://base.voltplan.app")!
|
||||||
|
private let urlSession: URLSession
|
||||||
|
|
||||||
|
init(urlSession: URLSession = .shared) {
|
||||||
|
self.urlSession = urlSession
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
guard !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let fetchedItems = try await fetchComponents()
|
||||||
|
items = fetchedItems
|
||||||
|
} catch {
|
||||||
|
items = []
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
isLoading = false
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchComponents() async throws -> [ComponentLibraryItem] {
|
||||||
|
var components = URLComponents(url: baseURL.appendingPathComponent("api/collections/components/records"), resolvingAgainstBaseURL: false)
|
||||||
|
components?.queryItems = [
|
||||||
|
URLQueryItem(name: "filter", value: "(type='load')"),
|
||||||
|
URLQueryItem(name: "sort", value: "+name"),
|
||||||
|
URLQueryItem(name: "fields", value: "id,collectionId,name,icon,voltage_in,voltage_out,watt")
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let url = components?.url else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
|
||||||
|
let (data, response) = try await urlSession.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = try JSONDecoder().decode(PocketBaseResponse.self, from: data)
|
||||||
|
return decoded.items.map { record in
|
||||||
|
ComponentLibraryItem(
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
voltageIn: record.voltageIn,
|
||||||
|
voltageOut: record.voltageOut,
|
||||||
|
watt: record.watt,
|
||||||
|
iconURL: iconURL(for: record)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconURL(for record: PocketBaseRecord) -> URL? {
|
||||||
|
guard let icon = record.icon else { return nil }
|
||||||
|
|
||||||
|
return baseURL
|
||||||
|
.appendingPathComponent("api")
|
||||||
|
.appendingPathComponent("files")
|
||||||
|
.appendingPathComponent(record.collectionId)
|
||||||
|
.appendingPathComponent(record.id)
|
||||||
|
.appendingPathComponent(icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PocketBaseResponse: Decodable {
|
||||||
|
let items: [PocketBaseRecord]
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PocketBaseRecord: Decodable {
|
||||||
|
let id: String
|
||||||
|
let collectionId: String
|
||||||
|
let name: String
|
||||||
|
let icon: String?
|
||||||
|
let voltageIn: Double?
|
||||||
|
let voltageOut: Double?
|
||||||
|
let watt: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case collectionId
|
||||||
|
case name
|
||||||
|
case icon
|
||||||
|
case voltageIn = "voltage_in"
|
||||||
|
case voltageOut = "voltage_out"
|
||||||
|
case watt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ComponentLibraryView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var viewModel = ComponentLibraryViewModel()
|
||||||
|
let onSelect: (ComponentLibraryItem) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
content
|
||||||
|
.navigationTitle("VoltPlan Library")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Close") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
if viewModel.isLoading && viewModel.items.isEmpty {
|
||||||
|
ProgressView("Loading components")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
|
} else if let errorMessage = viewModel.errorMessage {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Text("Unable to load components")
|
||||||
|
.font(.headline)
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Retry") {
|
||||||
|
Task { await viewModel.refresh() }
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
|
} else if viewModel.items.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "sparkles.rectangle.stack")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("No components available")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Check back soon for new loads from VoltPlan.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||||
|
} else {
|
||||||
|
List(viewModel.items) { item in
|
||||||
|
Button {
|
||||||
|
onSelect(item)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
ComponentRow(item: item)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ComponentRow: View {
|
||||||
|
let item: ComponentLibraryItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
iconView
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(item.name)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
detailLine
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Color(.tertiaryLabel))
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconView: some View {
|
||||||
|
Group {
|
||||||
|
if let url = item.iconURL {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .empty:
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
case .failure:
|
||||||
|
placeholder
|
||||||
|
@unknown default:
|
||||||
|
placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholder: some View {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.blue.opacity(0.1))
|
||||||
|
Image(systemName: "bolt")
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var detailLine: some View {
|
||||||
|
let labels = [item.voltageLabel, item.powerLabel, item.currentLabel].compactMap { $0 }
|
||||||
|
|
||||||
|
if labels.isEmpty {
|
||||||
|
Text("Details coming soon")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
Text(labels.joined(separator: " • "))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ struct SystemsView: View {
|
|||||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||||
@Query(sort: \ElectricalSystem.timestamp, order: .reverse) private var systems: [ElectricalSystem]
|
@Query(sort: \ElectricalSystem.timestamp, order: .reverse) private var systems: [ElectricalSystem]
|
||||||
@State private var systemNavigationTarget: SystemNavigationTarget?
|
@State private var systemNavigationTarget: SystemNavigationTarget?
|
||||||
|
@State private var showingComponentLibrary = false
|
||||||
|
|
||||||
private struct SystemNavigationTarget: Identifiable, Hashable {
|
private struct SystemNavigationTarget: Identifiable, Hashable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
@@ -112,6 +113,11 @@ struct SystemsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingComponentLibrary) {
|
||||||
|
ComponentLibraryView { item in
|
||||||
|
addComponentFromLibrary(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var systemsEmptyState: some View {
|
private var systemsEmptyState: some View {
|
||||||
@@ -162,8 +168,7 @@ struct SystemsView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// TODO: Open VoltPlan component library
|
showingComponentLibrary = true
|
||||||
print("Opening VoltPlan component library...")
|
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Image(systemName: "square.grid.3x3")
|
Image(systemName: "square.grid.3x3")
|
||||||
@@ -256,6 +261,12 @@ struct SystemsView: View {
|
|||||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
|
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, 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 createNewLoad(in system: ElectricalSystem) -> SavedLoad {
|
private func createNewLoad(in system: ElectricalSystem) -> SavedLoad {
|
||||||
let newLoad = SavedLoad(
|
let newLoad = SavedLoad(
|
||||||
name: "New Load",
|
name: "New Load",
|
||||||
@@ -267,11 +278,73 @@ struct SystemsView: View {
|
|||||||
iconName: "lightbulb",
|
iconName: "lightbulb",
|
||||||
colorName: "blue",
|
colorName: "blue",
|
||||||
isWattMode: false,
|
isWattMode: false,
|
||||||
system: system
|
system: system,
|
||||||
|
remoteIconURLString: nil
|
||||||
)
|
)
|
||||||
modelContext.insert(newLoad)
|
modelContext.insert(newLoad)
|
||||||
return newLoad
|
return newLoad
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
|
||||||
|
let baseName = item.name.isEmpty ? "Library Load" : item.name
|
||||||
|
let loadName = uniqueLoadName(for: system, startingWith: baseName)
|
||||||
|
let voltage = item.displayVoltage ?? 12.0
|
||||||
|
|
||||||
|
let power: Double
|
||||||
|
if let watt = item.watt {
|
||||||
|
power = watt
|
||||||
|
} else if let derivedCurrent = item.current, voltage > 0 {
|
||||||
|
power = derivedCurrent * voltage
|
||||||
|
} else {
|
||||||
|
power = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: Double
|
||||||
|
if let explicitCurrent = item.current {
|
||||||
|
current = explicitCurrent
|
||||||
|
} else if voltage > 0 {
|
||||||
|
current = power / voltage
|
||||||
|
} else {
|
||||||
|
current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let 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
|
||||||
|
)
|
||||||
|
|
||||||
|
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) {
|
private func deleteSystems(offsets: IndexSet) {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@@ -320,6 +393,7 @@ struct LoadsView: View {
|
|||||||
@State private var showingSystemEditor = false
|
@State private var showingSystemEditor = false
|
||||||
@State private var hasPresentedSystemEditorOnAppear = false
|
@State private var hasPresentedSystemEditorOnAppear = false
|
||||||
@State private var hasOpenedLoadOnAppear = false
|
@State private var hasOpenedLoadOnAppear = false
|
||||||
|
@State private var showingComponentLibrary = false
|
||||||
|
|
||||||
let system: ElectricalSystem
|
let system: ElectricalSystem
|
||||||
private let presentSystemEditorOnAppear: Bool
|
private let presentSystemEditorOnAppear: Bool
|
||||||
@@ -346,15 +420,11 @@ struct LoadsView: View {
|
|||||||
ForEach(savedLoads) { load in
|
ForEach(savedLoads) { load in
|
||||||
NavigationLink(destination: CalculatorView(savedLoad: load)) {
|
NavigationLink(destination: CalculatorView(savedLoad: load)) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack {
|
LoadIconView(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
remoteIconURLString: load.remoteIconURLString,
|
||||||
.fill(colorForName(load.colorName))
|
fallbackSystemName: load.iconName,
|
||||||
.frame(width: 44, height: 44)
|
fallbackColor: colorForName(load.colorName),
|
||||||
|
size: 44)
|
||||||
Image(systemName: load.iconName)
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -470,6 +540,11 @@ struct LoadsView: View {
|
|||||||
.navigationDestination(item: $newLoadToEdit) { load in
|
.navigationDestination(item: $newLoadToEdit) { load in
|
||||||
CalculatorView(savedLoad: load)
|
CalculatorView(savedLoad: load)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingComponentLibrary) {
|
||||||
|
ComponentLibraryView { item in
|
||||||
|
addComponent(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
.sheet(isPresented: $showingSystemEditor) {
|
.sheet(isPresented: $showingSystemEditor) {
|
||||||
SystemEditorView(
|
SystemEditorView(
|
||||||
systemName: Binding(
|
systemName: Binding(
|
||||||
@@ -522,8 +597,7 @@ struct LoadsView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
// TODO: Open VoltPlan component library
|
showingComponentLibrary = true
|
||||||
print("Opening VoltPlan component library...")
|
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text("Browse")
|
Text("Browse")
|
||||||
@@ -604,15 +678,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func createNewLoad() {
|
private func createNewLoad() {
|
||||||
let existingNames = Set(savedLoads.map { $0.name })
|
let loadName = uniqueLoadName(startingWith: "New Load")
|
||||||
var loadName = "New Load"
|
|
||||||
var counter = 1
|
|
||||||
|
|
||||||
while existingNames.contains(loadName) {
|
|
||||||
counter += 1
|
|
||||||
loadName = "New Load \(counter)"
|
|
||||||
}
|
|
||||||
|
|
||||||
let newLoad = SavedLoad(
|
let newLoad = SavedLoad(
|
||||||
name: loadName,
|
name: loadName,
|
||||||
voltage: 12.0,
|
voltage: 12.0,
|
||||||
@@ -623,13 +689,64 @@ struct LoadsView: View {
|
|||||||
iconName: "lightbulb",
|
iconName: "lightbulb",
|
||||||
colorName: "blue",
|
colorName: "blue",
|
||||||
isWattMode: false,
|
isWattMode: false,
|
||||||
system: system
|
system: system,
|
||||||
|
remoteIconURLString: nil
|
||||||
)
|
)
|
||||||
modelContext.insert(newLoad)
|
modelContext.insert(newLoad)
|
||||||
|
|
||||||
// Navigate to the new load
|
// Navigate to the new load
|
||||||
newLoadToEdit = newLoad
|
newLoadToEdit = newLoad
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func addComponent(_ item: ComponentLibraryItem) {
|
||||||
|
let baseName = item.name.isEmpty ? "Library Load" : item.name
|
||||||
|
let loadName = uniqueLoadName(startingWith: baseName)
|
||||||
|
let voltage = item.displayVoltage ?? 12.0
|
||||||
|
let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0)
|
||||||
|
let current: Double
|
||||||
|
if let explicitCurrent = item.current {
|
||||||
|
current = explicitCurrent
|
||||||
|
} else if voltage > 0 {
|
||||||
|
current = power / voltage
|
||||||
|
} else {
|
||||||
|
current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let 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
|
||||||
|
)
|
||||||
|
|
||||||
|
modelContext.insert(newLoad)
|
||||||
|
newLoadToEdit = newLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uniqueLoadName(startingWith baseName: String) -> String {
|
||||||
|
let existingNames = Set(savedLoads.map { $0.name })
|
||||||
|
|
||||||
|
if !existingNames.contains(baseName) {
|
||||||
|
return baseName
|
||||||
|
}
|
||||||
|
|
||||||
|
var counter = 2
|
||||||
|
var candidate = "\(baseName) \(counter)"
|
||||||
|
|
||||||
|
while existingNames.contains(candidate) {
|
||||||
|
counter += 1
|
||||||
|
candidate = "\(baseName) \(counter)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
private func colorForName(_ colorName: String) -> Color {
|
private func colorForName(_ colorName: String) -> Color {
|
||||||
switch colorName {
|
switch colorName {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct ItemEditorView: View {
|
|||||||
let previewSubtitle: String
|
let previewSubtitle: String
|
||||||
let icons: [String]
|
let icons: [String]
|
||||||
let additionalFields: () -> AnyView
|
let additionalFields: () -> AnyView
|
||||||
|
private let remoteIconURLStringBinding: Binding<String?>?
|
||||||
|
|
||||||
@Binding var name: String
|
@Binding var name: String
|
||||||
@Binding var iconName: String
|
@Binding var iconName: String
|
||||||
@@ -24,6 +25,7 @@ struct ItemEditorView: View {
|
|||||||
@State private var tempName: String
|
@State private var tempName: String
|
||||||
@State private var tempIconName: String
|
@State private var tempIconName: String
|
||||||
@State private var tempColorName: String
|
@State private var tempColorName: String
|
||||||
|
@State private var tempRemoteIconURLString: String?
|
||||||
|
|
||||||
private let curatedColors: [(String, Color)] = [
|
private let curatedColors: [(String, Color)] = [
|
||||||
("blue", .blue),
|
("blue", .blue),
|
||||||
@@ -49,6 +51,7 @@ struct ItemEditorView: View {
|
|||||||
name: Binding<String>,
|
name: Binding<String>,
|
||||||
iconName: Binding<String>,
|
iconName: Binding<String>,
|
||||||
colorName: Binding<String>,
|
colorName: Binding<String>,
|
||||||
|
remoteIconURLString: Binding<String?>? = nil,
|
||||||
@ViewBuilder additionalFields: @escaping () -> AnyView = { AnyView(EmptyView()) }
|
@ViewBuilder additionalFields: @escaping () -> AnyView = { AnyView(EmptyView()) }
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -56,12 +59,14 @@ struct ItemEditorView: View {
|
|||||||
self.previewSubtitle = previewSubtitle
|
self.previewSubtitle = previewSubtitle
|
||||||
self.icons = icons
|
self.icons = icons
|
||||||
self.additionalFields = additionalFields
|
self.additionalFields = additionalFields
|
||||||
|
self.remoteIconURLStringBinding = remoteIconURLString
|
||||||
self._name = name
|
self._name = name
|
||||||
self._iconName = iconName
|
self._iconName = iconName
|
||||||
self._colorName = colorName
|
self._colorName = colorName
|
||||||
self._tempName = State(initialValue: name.wrappedValue)
|
self._tempName = State(initialValue: name.wrappedValue)
|
||||||
self._tempIconName = State(initialValue: iconName.wrappedValue)
|
self._tempIconName = State(initialValue: iconName.wrappedValue)
|
||||||
self._tempColorName = State(initialValue: colorName.wrappedValue)
|
self._tempColorName = State(initialValue: colorName.wrappedValue)
|
||||||
|
self._tempRemoteIconURLString = State(initialValue: remoteIconURLString?.wrappedValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -69,15 +74,12 @@ struct ItemEditorView: View {
|
|||||||
Form {
|
Form {
|
||||||
Section("Preview") {
|
Section("Preview") {
|
||||||
HStack {
|
HStack {
|
||||||
ZStack {
|
LoadIconView(
|
||||||
RoundedRectangle(cornerRadius: 12)
|
remoteIconURLString: tempRemoteIconURLString,
|
||||||
.fill(selectedColor)
|
fallbackSystemName: tempIconName,
|
||||||
.frame(width: 60, height: 60)
|
fallbackColor: selectedColor,
|
||||||
|
size: 60
|
||||||
Image(systemName: tempIconName)
|
)
|
||||||
.font(.title2)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(tempName.isEmpty ? nameFieldLabel : tempName)
|
Text(tempName.isEmpty ? nameFieldLabel : tempName)
|
||||||
@@ -108,15 +110,16 @@ struct ItemEditorView: View {
|
|||||||
ForEach(icons, id: \.self) { icon in
|
ForEach(icons, id: \.self) { icon in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
tempIconName = icon
|
tempIconName = icon
|
||||||
|
tempRemoteIconURLString = nil
|
||||||
}) {
|
}) {
|
||||||
ZStack {
|
ZStack {
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.fill(tempIconName == icon ? selectedColor : Color(.systemGray5))
|
.fill(tempIconName == icon && tempRemoteIconURLString == nil ? selectedColor : Color(.systemGray5))
|
||||||
.frame(width: 50, height: 50)
|
.frame(width: 50, height: 50)
|
||||||
|
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundColor(tempIconName == icon ? .white : .primary)
|
.foregroundColor(tempIconName == icon && tempRemoteIconURLString == nil ? .white : .primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -177,6 +180,7 @@ struct ItemEditorView: View {
|
|||||||
name = tempName
|
name = tempName
|
||||||
iconName = tempIconName
|
iconName = tempIconName
|
||||||
colorName = tempColorName
|
colorName = tempColorName
|
||||||
|
remoteIconURLStringBinding?.wrappedValue = tempRemoteIconURLString
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct LoadEditorView: View {
|
|||||||
@Binding var loadName: String
|
@Binding var loadName: String
|
||||||
@Binding var iconName: String
|
@Binding var iconName: String
|
||||||
@Binding var colorName: String
|
@Binding var colorName: String
|
||||||
|
@Binding var remoteIconURLString: String?
|
||||||
|
|
||||||
private let loadIcons = [
|
private let loadIcons = [
|
||||||
"lightbulb", "lamp.desk", "fan", "tv", "poweroutlet.strip","poweroutlet.type.c", "bolt", "xbox.logo", "playstation.logo", "batteryblock", "speaker.wave.2", "refrigerator",
|
"lightbulb", "lamp.desk", "fan", "tv", "poweroutlet.strip","poweroutlet.type.c", "bolt", "xbox.logo", "playstation.logo", "batteryblock", "speaker.wave.2", "refrigerator",
|
||||||
@@ -28,7 +29,8 @@ struct LoadEditorView: View {
|
|||||||
icons: loadIcons,
|
icons: loadIcons,
|
||||||
name: $loadName,
|
name: $loadName,
|
||||||
iconName: $iconName,
|
iconName: $iconName,
|
||||||
colorName: $colorName
|
colorName: $colorName,
|
||||||
|
remoteIconURLString: $remoteIconURLString
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +39,7 @@ struct LoadEditorView: View {
|
|||||||
@State var name = "My Load"
|
@State var name = "My Load"
|
||||||
@State var icon = "lightbulb"
|
@State var icon = "lightbulb"
|
||||||
@State var color = "blue"
|
@State var color = "blue"
|
||||||
|
@State var remoteIcon: String? = "https://example.com/icon.png"
|
||||||
|
|
||||||
return LoadEditorView(loadName: $name, iconName: $icon, colorName: $color)
|
return LoadEditorView(loadName: $name, iconName: $icon, colorName: $color, remoteIconURLString: $remoteIcon)
|
||||||
}
|
}
|
||||||
|
|||||||
51
Cable/LoadIconView.swift
Normal file
51
Cable/LoadIconView.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoadIconView: View {
|
||||||
|
let remoteIconURLString: String?
|
||||||
|
let fallbackSystemName: String
|
||||||
|
let fallbackColor: Color
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
private var cornerRadius: CGFloat {
|
||||||
|
max(6, size / 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let urlString = remoteIconURLString,
|
||||||
|
let url = URL(string: urlString) {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .empty:
|
||||||
|
ProgressView()
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
case .failure:
|
||||||
|
fallbackView
|
||||||
|
@unknown default:
|
||||||
|
fallbackView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fallbackView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fallbackView: some View {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.fill(fallbackColor)
|
||||||
|
Image(systemName: fallbackSystemName.isEmpty ? "lightbulb" : fallbackSystemName)
|
||||||
|
.font(.system(size: size * 0.5))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user