adds component library

This commit is contained in:
Stefan Lange-Hegermann
2025-09-17 23:14:36 +02:00
parent e081ca8b3b
commit c313c4e94b
12 changed files with 529 additions and 53 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "powered.png",
"idiom" : "universal",
"scale" : "1x"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -133,8 +133,9 @@ class SavedLoad {
var colorName: String = "blue"
var isWattMode: Bool = false
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.voltage = voltage
self.current = current
@@ -146,5 +147,6 @@ class SavedLoad {
self.colorName = colorName
self.isWattMode = isWattMode
self.system = system
self.remoteIconURLString = remoteIconURLString
}
}
}

View File

@@ -65,8 +65,10 @@ struct CalculatorView: View {
),
iconName: Binding(
get: { savedLoad?.iconName ?? "lightbulb" },
set: {
savedLoad?.iconName = $0
set: { newValue in
guard let savedLoad else { return }
savedLoad.iconName = newValue
savedLoad.remoteIconURLString = nil
autoUpdateSavedLoad()
}
),
@@ -76,6 +78,14 @@ struct CalculatorView: View {
savedLoad?.colorName = $0
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 {
savedLoad?.iconName ?? "lightbulb"
}
private var loadColor: Color {
let colorName = savedLoad?.colorName ?? "blue"
switch colorName {
@@ -176,21 +186,21 @@ struct CalculatorView: View {
default: return .blue
}
}
private var loadRemoteIconURLString: String? {
savedLoad?.remoteIconURLString
}
private var navigationTitle: some View {
Button(action: {
showingLoadEditor = true
}) {
HStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(loadColor)
.frame(width: 24, height: 24)
Image(systemName: loadIcon)
.font(.system(size: 12))
.foregroundColor(.white)
}
LoadIconView(
remoteIconURLString: loadRemoteIconURLString,
fallbackSystemName: loadIcon,
fallbackColor: loadColor,
size: 24)
Text(calculator.loadName)
.font(.headline)
@@ -518,7 +528,8 @@ struct CalculatorView: View {
iconName: "lightbulb",
colorName: "blue",
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)
}

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

View File

@@ -31,6 +31,7 @@ struct SystemsView: View {
@EnvironmentObject var unitSettings: UnitSystemSettings
@Query(sort: \ElectricalSystem.timestamp, order: .reverse) private var systems: [ElectricalSystem]
@State private var systemNavigationTarget: SystemNavigationTarget?
@State private var showingComponentLibrary = false
private struct SystemNavigationTarget: Identifiable, Hashable {
let id = UUID()
@@ -112,6 +113,11 @@ struct SystemsView: View {
)
}
}
.sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in
addComponentFromLibrary(item)
}
}
}
private var systemsEmptyState: some View {
@@ -162,8 +168,7 @@ struct SystemsView: View {
.buttonStyle(.plain)
Button(action: {
// TODO: Open VoltPlan component library
print("Opening VoltPlan component library...")
showingComponentLibrary = true
}) {
HStack(spacing: 8) {
Image(systemName: "square.grid.3x3")
@@ -256,6 +261,12 @@ struct SystemsView: View {
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 {
let newLoad = SavedLoad(
name: "New Load",
@@ -267,11 +278,73 @@ struct SystemsView: View {
iconName: "lightbulb",
colorName: "blue",
isWattMode: false,
system: system
system: system,
remoteIconURLString: nil
)
modelContext.insert(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) {
withAnimation {
@@ -320,6 +393,7 @@ struct LoadsView: View {
@State private var showingSystemEditor = false
@State private var hasPresentedSystemEditorOnAppear = false
@State private var hasOpenedLoadOnAppear = false
@State private var showingComponentLibrary = false
let system: ElectricalSystem
private let presentSystemEditorOnAppear: Bool
@@ -346,15 +420,11 @@ struct LoadsView: View {
ForEach(savedLoads) { load in
NavigationLink(destination: CalculatorView(savedLoad: load)) {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(colorForName(load.colorName))
.frame(width: 44, height: 44)
Image(systemName: load.iconName)
.font(.title3)
.foregroundColor(.white)
}
LoadIconView(
remoteIconURLString: load.remoteIconURLString,
fallbackSystemName: load.iconName,
fallbackColor: colorForName(load.colorName),
size: 44)
VStack(alignment: .leading, spacing: 6) {
HStack {
@@ -470,6 +540,11 @@ struct LoadsView: View {
.navigationDestination(item: $newLoadToEdit) { load in
CalculatorView(savedLoad: load)
}
.sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in
addComponent(item)
}
}
.sheet(isPresented: $showingSystemEditor) {
SystemEditorView(
systemName: Binding(
@@ -522,8 +597,7 @@ struct LoadsView: View {
Spacer()
Button(action: {
// TODO: Open VoltPlan component library
print("Opening VoltPlan component library...")
showingComponentLibrary = true
}) {
HStack(spacing: 6) {
Text("Browse")
@@ -604,15 +678,7 @@ struct LoadsView: View {
}
private func createNewLoad() {
let existingNames = Set(savedLoads.map { $0.name })
var loadName = "New Load"
var counter = 1
while existingNames.contains(loadName) {
counter += 1
loadName = "New Load \(counter)"
}
let loadName = uniqueLoadName(startingWith: "New Load")
let newLoad = SavedLoad(
name: loadName,
voltage: 12.0,
@@ -623,13 +689,64 @@ struct LoadsView: View {
iconName: "lightbulb",
colorName: "blue",
isWattMode: false,
system: system
system: system,
remoteIconURLString: nil
)
modelContext.insert(newLoad)
// Navigate to the new load
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 {
switch colorName {

View File

@@ -16,6 +16,7 @@ struct ItemEditorView: View {
let previewSubtitle: String
let icons: [String]
let additionalFields: () -> AnyView
private let remoteIconURLStringBinding: Binding<String?>?
@Binding var name: String
@Binding var iconName: String
@@ -24,6 +25,7 @@ struct ItemEditorView: View {
@State private var tempName: String
@State private var tempIconName: String
@State private var tempColorName: String
@State private var tempRemoteIconURLString: String?
private let curatedColors: [(String, Color)] = [
("blue", .blue),
@@ -49,6 +51,7 @@ struct ItemEditorView: View {
name: Binding<String>,
iconName: Binding<String>,
colorName: Binding<String>,
remoteIconURLString: Binding<String?>? = nil,
@ViewBuilder additionalFields: @escaping () -> AnyView = { AnyView(EmptyView()) }
) {
self.title = title
@@ -56,12 +59,14 @@ struct ItemEditorView: View {
self.previewSubtitle = previewSubtitle
self.icons = icons
self.additionalFields = additionalFields
self.remoteIconURLStringBinding = remoteIconURLString
self._name = name
self._iconName = iconName
self._colorName = colorName
self._tempName = State(initialValue: name.wrappedValue)
self._tempIconName = State(initialValue: iconName.wrappedValue)
self._tempColorName = State(initialValue: colorName.wrappedValue)
self._tempRemoteIconURLString = State(initialValue: remoteIconURLString?.wrappedValue)
}
var body: some View {
@@ -69,15 +74,12 @@ struct ItemEditorView: View {
Form {
Section("Preview") {
HStack {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(selectedColor)
.frame(width: 60, height: 60)
Image(systemName: tempIconName)
.font(.title2)
.foregroundColor(.white)
}
LoadIconView(
remoteIconURLString: tempRemoteIconURLString,
fallbackSystemName: tempIconName,
fallbackColor: selectedColor,
size: 60
)
VStack(alignment: .leading) {
Text(tempName.isEmpty ? nameFieldLabel : tempName)
@@ -108,15 +110,16 @@ struct ItemEditorView: View {
ForEach(icons, id: \.self) { icon in
Button(action: {
tempIconName = icon
tempRemoteIconURLString = nil
}) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(tempIconName == icon ? selectedColor : Color(.systemGray5))
.fill(tempIconName == icon && tempRemoteIconURLString == nil ? selectedColor : Color(.systemGray5))
.frame(width: 50, height: 50)
Image(systemName: icon)
.font(.title3)
.foregroundColor(tempIconName == icon ? .white : .primary)
.foregroundColor(tempIconName == icon && tempRemoteIconURLString == nil ? .white : .primary)
}
}
.buttonStyle(.plain)
@@ -177,6 +180,7 @@ struct ItemEditorView: View {
name = tempName
iconName = tempIconName
colorName = tempColorName
remoteIconURLStringBinding?.wrappedValue = tempRemoteIconURLString
}
}

View File

@@ -11,6 +11,7 @@ struct LoadEditorView: View {
@Binding var loadName: String
@Binding var iconName: String
@Binding var colorName: String
@Binding var remoteIconURLString: String?
private let loadIcons = [
"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,
name: $loadName,
iconName: $iconName,
colorName: $colorName
colorName: $colorName,
remoteIconURLString: $remoteIconURLString
)
}
}
@@ -37,6 +39,7 @@ struct LoadEditorView: View {
@State var name = "My Load"
@State var icon = "lightbulb"
@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
View 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)
}
}
}