import SwiftUI struct MeterDetailView: View { @EnvironmentObject private var appData: AppData @Environment(\.dismiss) private var dismiss @State private var editorVisibility = false @State private var deleteConfirmationVisibility = false let meterSummary: AppData.MeterSummary var body: some View { ScrollView { VStack(spacing: 18) { headerCard statusCard identifiersCard chargedDevicesCard chargersCard } .padding() } .background( LinearGradient( colors: [meterSummary.tint.opacity(0.18), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationTitle(meterSummary.displayName) .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button("Edit") { editorVisibility = true } Button(role: .destructive) { deleteConfirmationVisibility = true } label: { Image(systemName: "trash") } } } .sheet(isPresented: $editorVisibility) { MeterEditorSheetView(existingMeterSummary: meterSummary) .environmentObject(appData) } .alert("Delete Meter?", isPresented: $deleteConfirmationVisibility) { Button("Delete", role: .destructive) { if appData.deleteMeter(macAddress: meterSummary.macAddress) { dismiss() } } Button("Cancel", role: .cancel) {} } message: { Text("This removes the stored meter entry and its saved metadata from the sidebar until the meter is discovered again.") } } private var headerCard: some View { VStack(alignment: .leading, spacing: 8) { Text(meterSummary.displayName) .font(.title2.weight(.semibold)) Text(meterSummary.modelSummary) .font(.subheadline) .foregroundColor(.secondary) if let advertisedName = meterSummary.advertisedName { Text("Advertised as " + advertisedName) .font(.caption2) .foregroundColor(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: meterSummary.tint, fillOpacity: 0.22, strokeOpacity: 0.28, cornerRadius: 20) } private var statusCard: some View { VStack(alignment: .leading, spacing: 10) { Text("Status") .font(.headline) HStack(spacing: 8) { Circle() .fill(meterSummary.tint) .frame(width: 10, height: 10) Text("Offline") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } Text("The meter is not currently connected. Bring it within Bluetooth range or wake it up to open live diagnostics.") .font(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: meterSummary.tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) } private var identifiersCard: some View { VStack(alignment: .leading, spacing: 10) { Text("Identifiers") .font(.headline) infoRow(label: "MAC Address", value: meterSummary.macAddress) if let advertisedName = meterSummary.advertisedName { infoRow(label: "Advertised as", value: advertisedName) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18) } private var chargedDevicesCard: some View { let chargedDevices = appData.chargedDevices(for: meterSummary.macAddress) return VStack(alignment: .leading, spacing: 10) { Text("Devices") .font(.headline) if chargedDevices.isEmpty { Text("No devices are linked to this meter yet. Connect it, open Charge Record, and select the device being charged to start learning capacity and charge curves.") .font(.caption) .foregroundColor(.secondary) } else { ForEach(chargedDevices.prefix(3)) { chargedDevice in NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) { HStack(spacing: 12) { ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 52) VStack(alignment: .leading, spacing: 4) { Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName) .font(.subheadline.weight(.semibold)) Text(chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Capacity: learning") .font(.caption) .foregroundColor(.secondary) } Spacer() } } .buttonStyle(.plain) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18) } private var chargersCard: some View { let chargers = appData.chargers(for: meterSummary.macAddress) return VStack(alignment: .leading, spacing: 10) { Text("Chargers") .font(.headline) if chargers.isEmpty { Text("No chargers are linked to this meter yet. Pick one from Charge Record when you monitor a wireless charging session.") .font(.caption) .foregroundColor(.secondary) } else { ForEach(chargers.prefix(3)) { charger in NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: charger.id)) { HStack(spacing: 12) { ChargedDeviceQRCodeView(qrIdentifier: charger.qrIdentifier, side: 52) VStack(alignment: .leading, spacing: 4) { Label(charger.name, systemImage: charger.deviceClass.symbolName) .font(.subheadline.weight(.semibold)) Text(charger.chargerMaximumPowerWatts.map { "Max power: \($0.format(decimalDigits: 2)) W" } ?? "Wireless charger") .font(.caption) .foregroundColor(.secondary) } Spacer() } } .buttonStyle(.plain) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .pink, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18) } private func infoRow(label: String, value: String) -> some View { HStack { Text(label) Spacer() Text(value) .foregroundColor(.secondary) .font(.caption) } } } struct MeterDetailView_Previews: PreviewProvider { static var previews: some View { MeterDetailView( meterSummary: AppData.MeterSummary( macAddress: "AA:BB:CC:DD:EE:FF", displayName: "Desk Meter", modelSummary: "UM25C", advertisedName: "UM25C-123", lastSeen: Date(), lastConnected: Date().addingTimeInterval(-3600), meter: nil ) ) .environmentObject(appData) } } struct MeterEditorSheetView: View { @EnvironmentObject private var appData: AppData @Environment(\.dismiss) private var dismiss let existingMeterSummary: AppData.MeterSummary? @State private var customName: String @State private var macAddress: String @State private var advertisedName: String @State private var selectedModel: Model init(existingMeterSummary: AppData.MeterSummary? = nil) { self.existingMeterSummary = existingMeterSummary _customName = State(initialValue: existingMeterSummary?.displayName ?? "") _macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "") _advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "") _selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary)) } var body: some View { NavigationView { Form { Section(header: Text("Identity")) { TextField("Display name", text: $customName) TextField("MAC Address", text: $macAddress) .textInputAutocapitalization(.characters) .disableAutocorrection(true) .disabled(existingMeterSummary != nil) Picker("Model", selection: $selectedModel) { ForEach(Model.allCases, id: \.self) { model in Text(model.canonicalName) .tag(model) } } TextField("Advertised name", text: $advertisedName) } Section { Text("Use the real BLE MAC address format `AA:BB:CC:DD:EE:FF`. Saved meters remain visible in the sidebar even when they are currently offline.") .font(.footnote) .foregroundColor(.secondary) } } .navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button(existingMeterSummary == nil ? "Save" : "Update") { let normalizedMAC = AppData.normalizedMACAddress(macAddress) let didSave = appData.createKnownMeter( macAddress: normalizedMAC, customName: customName, modelName: selectedModel.canonicalName, advertisedName: advertisedName ) if didSave { dismiss() } } .disabled(isSaveDisabled) } } } .navigationViewStyle(StackNavigationViewStyle()) } private var isSaveDisabled: Bool { AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false } private static func model(for summary: String?) -> Model { if summary?.contains("UM34C") == true { return .UM34C } if summary?.contains("TC66C") == true { return .TC66C } return .UM25C } }