1 contributor
//
// 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
}
}