// // ChargedDeviceLibrarySheetView.swift // USB Meter // // Created by Codex on 10/04/2026. // import SwiftUI import UIKit enum ChargedDeviceLibraryMode { case device case charger var kind: ChargedDeviceKind { switch self { case .device: return .device case .charger: return .charger } } var title: String { switch self { case .device: return "Devices" case .charger: return "Chargers" } } var singularTitle: String { switch self { case .device: return "Device" case .charger: return "Charger" } } } struct ChargedDeviceLibrarySheetView: View { @EnvironmentObject private var appData: AppData @Environment(\.dismiss) private var dismiss let meterMACAddress: String let meterTint: Color let mode: ChargedDeviceLibraryMode /// true = standalone sheet with own NavigationView; false = pushed into parent nav stack let standalone: Bool @State private var showingNewEditor = false @State private var editingChargedDevice: ChargedDeviceSummary? @State private var pendingDeletion: ChargedDeviceSummary? init( meterMACAddress: String, meterTint: Color, mode: ChargedDeviceLibraryMode, standalone: Bool = true ) { self.meterMACAddress = meterMACAddress self.meterTint = meterTint self.mode = mode self.standalone = standalone } var body: some View { if standalone { NavigationView { listContent } .navigationViewStyle(StackNavigationViewStyle()) } else { listContent } } private var listContent: some View { List { if displayedChargedDevices.isEmpty { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Text("No \(mode.title.lowercased()) yet.") .font(.headline) ContextInfoButton( title: mode.title, message: emptyStateDescription ) } } .padding(.vertical, 10) .listRowBackground(Color.clear) } else { ForEach(displayedChargedDevices) { chargedDevice in Button { select(chargedDevice) dismiss() } label: { ChargedDeviceLibraryRowView( chargedDevice: chargedDevice, isSelected: chargedDevice.id == selectedDeviceID ) } .buttonStyle(.plain) .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { pendingDeletion = chargedDevice } label: { Label("Delete", systemImage: "trash") } Button { editingChargedDevice = chargedDevice } label: { Label("Edit", systemImage: "pencil") } .tint(.blue) } .contextMenu { Button { editingChargedDevice = chargedDevice } label: { Label("Edit \(mode.singularTitle)", systemImage: "pencil") } Button(role: .destructive) { pendingDeletion = chargedDevice } label: { Label("Delete \(mode.singularTitle)", systemImage: "trash") } } } } } .listStyle(InsetGroupedListStyle()) .background( LinearGradient( colors: [meterTint.opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationTitle(mode.title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { if standalone { Button("Done") { dismiss() } } } ToolbarItem(placement: .confirmationAction) { Button("New") { showingNewEditor = true } } } .sheet(isPresented: $showingNewEditor) { newEditorSheet } .sheet(item: $editingChargedDevice) { device in editEditorSheet(device) } .confirmationDialog( "Delete \(pendingDeletion?.name ?? mode.singularTitle)?", isPresented: Binding( get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } } ), titleVisibility: .visible ) { Button("Delete", role: .destructive) { if let device = pendingDeletion { _ = appData.deleteChargedDevice(id: device.id) pendingDeletion = nil } } Button("Cancel", role: .cancel) { pendingDeletion = nil } } message: { Text("This will permanently remove the \(mode.singularTitle.lowercased()) and all associated data.") } } @ViewBuilder private var newEditorSheet: some View { if mode == .charger { ChargerEditorSheetView( appData: appData, meterMACAddress: meterMACAddress ) } else { ChargedDeviceEditorSheetView(meterMACAddress: meterMACAddress) .environmentObject(appData) } } @ViewBuilder private func editEditorSheet(_ chargedDevice: ChargedDeviceSummary) -> some View { if chargedDevice.isCharger { ChargerEditorSheetView( appData: appData, chargedDevice: chargedDevice ) } else { ChargedDeviceEditorSheetView( meterMACAddress: nil, chargedDevice: chargedDevice ) .environmentObject(appData) } } private var displayedChargedDevices: [ChargedDeviceSummary] { switch mode { case .device: return appData.deviceSummaries case .charger: return appData.chargerSummaries } } private var selectedDeviceID: UUID? { switch mode { case .device: return appData.currentChargedDeviceSummary(for: meterMACAddress)?.id case .charger: return appData.currentChargerSummary(for: meterMACAddress)?.id } } private var emptyStateDescription: String { switch mode { case .device: return "Create one here, then select it before or during a charging session. The selected device becomes the default for this meter." case .charger: return "Create one here, then select it for wireless charging sessions. The selected charger becomes the default wireless source for this meter." } } private func select(_ chargedDevice: ChargedDeviceSummary) { switch mode { case .device: appData.assignChargedDevice(chargedDevice.id, to: meterMACAddress) case .charger: appData.assignCharger(chargedDevice.id, to: meterMACAddress) } } } private struct ChargedDeviceLibraryRowView: View { let chargedDevice: ChargedDeviceSummary let isSelected: Bool var body: some View { HStack(alignment: .top, spacing: 14) { ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 58) VStack(alignment: .leading, spacing: 6) { HStack { ChargedDeviceIdentityLabelView( chargedDevice: chargedDevice, iconPointSize: 17 ) .font(.headline) .foregroundColor(.primary) Spacer() if isSelected { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) } } Text(chargedDevice.identityTitle) .font(.caption.weight(.semibold)) .foregroundColor(.secondary) if chargedDevice.isCharger { if !chargedDevice.chargerObservedVoltageSelections.isEmpty { Text( chargedDevice.chargerObservedVoltageSelections .map { "\($0.format(decimalDigits: 1)) V" } .joined(separator: ", ") ) .font(.caption2) .foregroundColor(.secondary) } else if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts { Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W") .font(.caption2) .foregroundColor(.secondary) } else { Text("Wireless charger") .font(.caption2) .foregroundColor(.secondary) } } else { Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")) .font(.caption2) .foregroundColor(.secondary) if let capacity = chargedDevice.estimatedBatteryCapacityWh { Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh") .font(.caption) .foregroundColor(.secondary) } if let minimumCurrent = chargedDevice.minimumCurrentAmps { Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A") .font(.caption2) .foregroundColor(.secondary) } } } } .padding(.vertical, 4) } } struct ChargedDeviceIdentityLabelView: View { let chargedDevice: ChargedDeviceSummary var iconPointSize: CGFloat = 15 var body: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { ChargedDeviceTemplateIconView( icon: chargedDevice.identityIcon, fallbackSystemName: chargedDevice.fallbackIdentitySymbolName, pointSize: iconPointSize ) Text(chargedDevice.name) } } } struct ChargedDeviceTemplateLabelView: View { let template: ChargedDeviceTemplateDefinition var iconPointSize: CGFloat = 15 var body: some View { HStack(alignment: .firstTextBaseline, spacing: 8) { ChargedDeviceTemplateIconView( icon: template.icon, fallbackSystemName: template.deviceClass.symbolName, pointSize: iconPointSize ) Text(template.name) } } } struct ChargedDeviceTemplateIconView: View { let icon: ChargedDeviceTemplateIcon let fallbackSystemName: String var pointSize: CGFloat = 15 var body: some View { Group { if let assetName = resolvedAssetName { Image(assetName) .renderingMode(.template) .resizable() .scaledToFit() } else { Image(systemName: resolvedSystemSymbolName) .font(.system(size: pointSize)) } } .frame(width: pointSize + 2, height: pointSize + 2) } private var resolvedAssetName: String? { guard icon.type == .asset, UIImage(named: icon.name) != nil else { return nil } return icon.name } private var resolvedSystemSymbolName: String { let candidate = icon.resolvedSystemSymbolName(fallbackSystemName: fallbackSystemName) if UIImage(systemName: candidate) != nil { return candidate } if let fallbackSystemName = icon.fallbackSystemName, UIImage(systemName: fallbackSystemName) != nil { return fallbackSystemName } return fallbackSystemName } }