diff --git a/Cable/Assets.xcassets/AppIcon.appiconset/Cable-iOS-Default-1024x1024@1x.png b/Cable/Assets.xcassets/AppIcon.appiconset/Cable-iOS-Default-1024x1024@1x.png new file mode 100644 index 0000000..0d2b1ec Binary files /dev/null and b/Cable/Assets.xcassets/AppIcon.appiconset/Cable-iOS-Default-1024x1024@1x.png differ diff --git a/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json b/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json index 24f5e37..fc4e57c 100644 --- a/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Cable/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "ios-marketing.png", + "filename" : "Cable-iOS-Default-1024x1024@1x.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/Cable/Assets.xcassets/AppIcon.appiconset/ios-marketing.png b/Cable/Assets.xcassets/AppIcon.appiconset/ios-marketing.png deleted file mode 100644 index 82bea8f..0000000 Binary files a/Cable/Assets.xcassets/AppIcon.appiconset/ios-marketing.png and /dev/null differ diff --git a/Cable/Assets.xcassets/PoweredByVoltplan.imageset/Contents.json b/Cable/Assets.xcassets/PoweredByVoltplan.imageset/Contents.json index a19a549..67ecb8b 100644 --- a/Cable/Assets.xcassets/PoweredByVoltplan.imageset/Contents.json +++ b/Cable/Assets.xcassets/PoweredByVoltplan.imageset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "powered.png", "idiom" : "universal", "scale" : "1x" }, diff --git a/Cable/Assets.xcassets/PoweredByVoltplan.imageset/powered.png b/Cable/Assets.xcassets/PoweredByVoltplan.imageset/powered.png new file mode 100644 index 0000000..a74bd4a Binary files /dev/null and b/Cable/Assets.xcassets/PoweredByVoltplan.imageset/powered.png differ diff --git a/Cable/CableCalculator.swift b/Cable/CableCalculator.swift index d3de485..6a14544 100644 --- a/Cable/CableCalculator.swift +++ b/Cable/CableCalculator.swift @@ -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 } -} \ No newline at end of file +} diff --git a/Cable/CalculatorView.swift b/Cable/CalculatorView.swift index 3053597..b9266a4 100644 --- a/Cable/CalculatorView.swift +++ b/Cable/CalculatorView.swift @@ -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) } diff --git a/Cable/ComponentLibraryView.swift b/Cable/ComponentLibraryView.swift new file mode 100644 index 0000000..b840c8d --- /dev/null +++ b/Cable/ComponentLibraryView.swift @@ -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) + } + } +} diff --git a/Cable/ContentView.swift b/Cable/ContentView.swift index cfa1557..9a44262 100644 --- a/Cable/ContentView.swift +++ b/Cable/ContentView.swift @@ -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() + 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 { diff --git a/Cable/ItemEditorView.swift b/Cable/ItemEditorView.swift index d8da622..e7f2917 100644 --- a/Cable/ItemEditorView.swift +++ b/Cable/ItemEditorView.swift @@ -16,6 +16,7 @@ struct ItemEditorView: View { let previewSubtitle: String let icons: [String] let additionalFields: () -> AnyView + private let remoteIconURLStringBinding: Binding? @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, iconName: Binding, colorName: Binding, + remoteIconURLString: Binding? = 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 } } diff --git a/Cable/LoadEditorView.swift b/Cable/LoadEditorView.swift index 881c973..cf29c9e 100644 --- a/Cable/LoadEditorView.swift +++ b/Cable/LoadEditorView.swift @@ -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) } diff --git a/Cable/LoadIconView.swift b/Cable/LoadIconView.swift new file mode 100644 index 0000000..1a56f77 --- /dev/null +++ b/Cable/LoadIconView.swift @@ -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) + } + } +}