adds component library
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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 |
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "powered.png",
|
||||
"idiom" : "universal",
|
||||
"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 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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
|
||||
@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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
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