// // ContentView.swift // USB Meter // // Created by Bogdan Timofte on 01/03/2020. // Copyright © 2020 Bogdan Timofte. All rights reserved. // //MARK: Bluetooth Icon: https://upload.wikimedia.org/wikipedia/commons/d/da/Bluetooth.svg import SwiftUI import Combine import UIKit struct ContentView: View { private enum SidebarItem: Hashable { case overview case meter(String) case debug case bluetoothHelp case deviceChecklist case discoveryChecklist } private struct MeterSidebarEntry: Identifiable, Hashable { let id: String let macAddress: String let displayName: String let modelSummary: String let meterColor: Color let statusText: String let statusColor: Color let isLive: Bool let lastSeenAt: Date? } @EnvironmentObject private var appData: AppData @State private var selectedSidebarItem: SidebarItem? = .overview @State private var now = Date() private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private let noDevicesHelpDelay: TimeInterval = 12 var body: some View { NavigationView { sidebar detailContent(for: selectedSidebarItem ?? .overview) } .navigationViewStyle(DoubleColumnNavigationViewStyle()) .onAppear { appData.bluetoothManager.start() now = Date() } .onReceive(helpRefreshTimer) { currentDate in now = currentDate } .onChange(of: visibleMeterIDs) { _ in sanitizeSelection() } .onChange(of: appData.bluetoothManager.managerState) { _ in sanitizeSelection() } } private var sidebar: some View { List(selection: $selectedSidebarItem) { Section(header: Text("Start")) { NavigationLink(tag: SidebarItem.overview, selection: $selectedSidebarItem) { detailContent(for: .overview) } label: { Label("Overview", systemImage: "house.fill") } } Section(header: Text("Meters")) { if visibleMeters.isEmpty { HStack(spacing: 10) { Image(systemName: isWaitingForFirstDiscovery ? "dot.radiowaves.left.and.right" : "questionmark.circle") .foregroundColor(isWaitingForFirstDiscovery ? .blue : .secondary) Text(devicesEmptyStateText) .font(.footnote) .foregroundColor(.secondary) } } else { ForEach(visibleMeters) { meter in NavigationLink(tag: SidebarItem.meter(meter.id), selection: $selectedSidebarItem) { detailContent(for: .meter(meter.id)) } label: { meterSidebarRow(for: meter) } .buttonStyle(.plain) .listRowInsets(EdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10)) .listRowBackground(Color.clear) } } } if shouldShowAssistanceSection { Section(header: Text("Assistance")) { if shouldShowBluetoothHelpEntry { NavigationLink(tag: SidebarItem.bluetoothHelp, selection: $selectedSidebarItem) { appData.bluetoothManager.managerState.helpView } label: { Label("Bluetooth Checklist", systemImage: "bolt.horizontal.circle.fill") } } if shouldShowDeviceChecklistEntry { NavigationLink(tag: SidebarItem.deviceChecklist, selection: $selectedSidebarItem) { DeviceHelpView() } label: { Label("Device Checklist", systemImage: "checklist") } } if shouldShowDiscoveryChecklistEntry { NavigationLink(tag: SidebarItem.discoveryChecklist, selection: $selectedSidebarItem) { DiscoveryChecklistView() } label: { Label("Discovery Checklist", systemImage: "magnifyingglass.circle") } } } } Section(header: Text("Debug")) { NavigationLink(tag: SidebarItem.debug, selection: $selectedSidebarItem) { debugView } label: { Label("Debug Info", systemImage: "wrench.and.screwdriver") } } } .listStyle(SidebarListStyle()) .navigationTitle("USB Meters") } @ViewBuilder private func detailContent(for item: SidebarItem) -> some View { switch item { case .overview: overviewDetail case .meter(let macAddress): if let meter = liveMeter(forMacAddress: macAddress) { MeterView().environmentObject(meter) } else if let meter = meterEntry(for: macAddress), let known = appData.knownMetersByMAC[macAddress] { offlineMeterDetail(for: meter, known: known) } else { unavailableMeterDetail } case .debug: debugView case .bluetoothHelp: appData.bluetoothManager.managerState.helpView case .deviceChecklist: DeviceHelpView() case .discoveryChecklist: DiscoveryChecklistView() } } private var overviewDetail: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 8) { Text("USB Meter") .font(.system(.title2, design: .rounded).weight(.bold)) Text("Discover nearby supported meters and open one to see live diagnostics, records, and controls.") .font(.footnote) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22) if shouldShowBluetoothHelpEntry { overviewHintCard( title: "Bluetooth needs attention", detail: "Open Bluetooth Checklist from the sidebar to resolve the current Bluetooth state.", tint: appData.bluetoothManager.managerState.color, symbol: "bolt.horizontal.circle.fill" ) } else { overviewHintCard( title: "Discovered devices", detail: visibleMeters.isEmpty ? devicesEmptyStateText : "\(visibleMeters.count) known device(s) available in the sidebar.", tint: visibleMeters.isEmpty ? .secondary : .green, symbol: visibleMeters.isEmpty ? "dot.radiowaves.left.and.right" : "sensor.tag.radiowaves.forward.fill" ) } overviewHintCard( title: "Quick start", detail: "1. Power on your USB meter.\n2. Keep it close to this device.\n3. Select it from Discovered Devices in the sidebar.", tint: .orange, symbol: "list.number" ) if shouldShowDeviceChecklistEntry || shouldShowDiscoveryChecklistEntry { overviewHintCard( title: "Need help finding devices?", detail: "Use the Assistance entries from the sidebar for guided troubleshooting checklists.", tint: .yellow, symbol: "questionmark.circle.fill" ) } } .padding() } .background( LinearGradient( colors: [.blue.opacity(0.12), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationBarTitle(Text("Overview"), displayMode: .inline) } private var unavailableMeterDetail: some View { VStack(spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 30, weight: .bold)) .foregroundColor(.orange) Text("Device no longer available") .font(.headline) Text("Select another device from the sidebar or return to Overview.") .font(.footnote) .foregroundColor(.secondary) } .padding(24) } private func offlineMeterDetail(for meter: MeterSidebarEntry, known: KnownMeterCatalogItem) -> some View { let isConnectedElsewhere = isConnectedElsewhere(known) return ScrollView { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Circle() .fill(meter.statusColor) .frame(width: 10, height: 10) Text(isConnectedElsewhere ? "Connected Elsewhere" : "Unavailable On This Device") .font(.headline) } Text(meter.displayName) .font(.title3.weight(.semibold)) Text(meter.modelSummary) .font(.subheadline) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: meter.meterColor, fillOpacity: 0.14, strokeOpacity: 0.22) Text("When this meter appears over BLE on this device, the live Meter View opens automatically from the BT layer and no CloudKit state will override it.") .font(.footnote) .foregroundColor(.secondary) .padding(.horizontal, 4) } .padding() } .background( LinearGradient( colors: [meter.meterColor.opacity(0.10), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) } private var visibleMeters: [MeterSidebarEntry] { var entriesByMAC: [String: MeterSidebarEntry] = [:] for known in appData.knownMetersByMAC.values { let isConnectedElsewhere = isConnectedElsewhere(known) entriesByMAC[known.macAddress] = MeterSidebarEntry( id: known.macAddress, macAddress: known.macAddress, displayName: known.displayName, modelSummary: known.modelType ?? "Unknown model", meterColor: meterColor(forModelType: known.modelType), statusText: isConnectedElsewhere ? "Elsewhere" : "Offline", statusColor: isConnectedElsewhere ? .indigo : .secondary, isLive: false, lastSeenAt: known.lastSeenAt ) } for meter in appData.meters.values { let mac = meter.btSerial.macAddress.description let known = appData.knownMetersByMAC[mac] let cloudElsewhere = known.map(isConnectedElsewhere) ?? false let liveConnected = meter.operationalState >= .peripheralConnected let effectiveElsewhere = cloudElsewhere && !liveConnected entriesByMAC[mac] = MeterSidebarEntry( id: mac, macAddress: mac, displayName: meter.name, modelSummary: meter.deviceModelSummary, meterColor: meter.color, statusText: effectiveElsewhere ? "Elsewhere" : statusText(for: meter.operationalState), statusColor: effectiveElsewhere ? .indigo : Meter.operationalColor(for: meter.operationalState), isLive: true, lastSeenAt: meter.lastSeen ) } return entriesByMAC.values.sorted { lhs, rhs in lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending } } private func isConnectedElsewhere(_ known: KnownMeterCatalogItem) -> Bool { guard let connectedBy = known.connectedByDeviceID, !connectedBy.isEmpty else { return false } guard connectedBy != AppData.myDeviceID else { return false } guard let expiry = known.connectedExpiryAt else { return false } return expiry > Date() } private func formatDate(_ value: Date?) -> String { guard let value else { return "(empty)" } return value.formatted(date: .abbreviated, time: .standard) } private var visibleMeterIDs: [String] { visibleMeters.map(\.id) } private var shouldShowBluetoothHelpEntry: Bool { switch appData.bluetoothManager.managerState { case .poweredOn: return false case .unknown: return false default: return true } } private var shouldShowDeviceChecklistEntry: Bool { hasWaitedLongEnoughForDevices } private var shouldShowDiscoveryChecklistEntry: Bool { hasWaitedLongEnoughForDevices } private var shouldShowAssistanceSection: Bool { shouldShowBluetoothHelpEntry || shouldShowDeviceChecklistEntry || shouldShowDiscoveryChecklistEntry } private func liveMeter(forMacAddress macAddress: String) -> Meter? { appData.meters.values.first { $0.btSerial.macAddress.description == macAddress } } private func meterEntry(for macAddress: String) -> MeterSidebarEntry? { visibleMeters.first { $0.macAddress == macAddress } } private func sanitizeSelection() { guard let selectedSidebarItem else { return } switch selectedSidebarItem { case .meter(let meterID): if meterEntry(for: meterID) == nil { self.selectedSidebarItem = .overview } case .bluetoothHelp: if !shouldShowBluetoothHelpEntry { self.selectedSidebarItem = .overview } case .deviceChecklist: if !shouldShowDeviceChecklistEntry { self.selectedSidebarItem = .overview } case .discoveryChecklist: if !shouldShowDiscoveryChecklistEntry { self.selectedSidebarItem = .overview } case .overview, .debug: break } } private var hasWaitedLongEnoughForDevices: Bool { guard appData.bluetoothManager.managerState == .poweredOn else { return false } guard appData.meters.isEmpty else { return false } guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else { return false } return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay } private var isWaitingForFirstDiscovery: Bool { guard appData.bluetoothManager.managerState == .poweredOn else { return false } guard appData.meters.isEmpty else { return false } guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else { return false } return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay } private var devicesEmptyStateText: String { if isWaitingForFirstDiscovery { return "Scanning for nearby supported meters..." } return "No supported meters are visible yet." } private func meterSidebarRow(for meter: MeterSidebarEntry) -> some View { HStack(spacing: 14) { Image(systemName: meter.isLive ? "sensor.tag.radiowaves.forward.fill" : "sensor.tag.radiowaves.forward") .font(.system(size: 18, weight: .semibold)) .foregroundColor(meter.meterColor) .frame(width: 42, height: 42) .background( Circle() .fill(meter.meterColor.opacity(0.18)) ) .overlay(alignment: .bottomTrailing) { Circle() .fill(meter.statusColor) .frame(width: 12, height: 12) .overlay( Circle() .stroke(Color(uiColor: .systemBackground), lineWidth: 2) ) } VStack(alignment: .leading, spacing: 4) { Text(meter.displayName) .font(.headline) Text(meter.modelSummary) .font(.caption) .foregroundColor(.secondary) } Spacer() VStack(alignment: .trailing, spacing: 4) { HStack(spacing: 6) { Circle() .fill(meter.statusColor) .frame(width: 8, height: 8) Text(meter.statusText) .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } .padding(.horizontal, 10) .padding(.vertical, 6) .background( Capsule(style: .continuous) .fill(meter.statusColor.opacity(0.12)) ) .overlay( Capsule(style: .continuous) .stroke(meter.statusColor.opacity(0.22), lineWidth: 1) ) Text(meter.macAddress) .font(.caption2.monospaced()) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) } } .padding(14) .meterCard( tint: meter.meterColor, fillOpacity: meter.isLive ? 0.16 : 0.10, strokeOpacity: meter.isLive ? 0.22 : 0.16, cornerRadius: 18 ) } private func meterColor(forModelType modelType: String?) -> Color { guard let modelType = modelType?.uppercased() else { return .secondary } if modelType.contains("UM25") { return Model.UM25C.color } if modelType.contains("UM34") { return Model.UM34C.color } if modelType.contains("TC66") || modelType.contains("PW0316") { return Model.TC66C.color } return .secondary } private func statusText(for state: Meter.OperationalState) -> String { switch state { case .offline: return "Offline" case .connectedElsewhere: return "Elsewhere" case .peripheralNotConnected: return "Available" case .peripheralConnectionPending: return "Connecting" case .peripheralConnected: return "Linked" case .peripheralReady: return "Ready" case .comunicating: return "Syncing" case .dataIsAvailable: return "Live" } } private func overviewHintCard(title: String, detail: String, tint: Color, symbol: String) -> some View { HStack(alignment: .top, spacing: 12) { Image(systemName: symbol) .font(.system(size: 16, weight: .semibold)) .foregroundColor(tint) .frame(width: 34, height: 34) .background(Circle().fill(tint.opacity(0.16))) VStack(alignment: .leading, spacing: 5) { Text(title) .font(.headline) Text(detail) .font(.footnote) .foregroundColor(.secondary) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(16) .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20) } private var debugView: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 8) { Text("Debug Information") .font(.system(.title2, design: .rounded).weight(.bold)) Text("System and CloudKit details for troubleshooting.") .font(.footnote) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .purple, fillOpacity: 0.16, strokeOpacity: 0.22) debugCard(title: "Device Info", content: [ "Device ID: \(AppData.myDeviceID)", "Device Name: \(AppData.myDeviceName)" ]) debugCard(title: "CloudKit Status", content: [ "Container: iCloud.ro.xdev.USB-Meter", "Total Meters: \(appData.knownMetersByMAC.count)", "Live Meters: \(appData.meters.count)" ]) VStack(alignment: .leading, spacing: 10) { Text("Observers (Keepalive: 15s)") .font(.headline) if appData.observers.isEmpty { Text("No observers registered yet") .font(.caption) .foregroundColor(.secondary) } else { ForEach(appData.observers) { observer in observerDebugCard(for: observer) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.22) if !appData.knownMetersByMAC.isEmpty { VStack(alignment: .leading, spacing: 10) { Text("Known Meters") .font(.headline) ForEach(Array(appData.knownMetersByMAC.values), id: \.macAddress) { known in meterDebugCard(for: known) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .cyan, fillOpacity: 0.14, strokeOpacity: 0.22) } } .padding() } .background( LinearGradient( colors: [.purple.opacity(0.08), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationBarTitle(Text("Debug Info"), displayMode: .inline) } private func debugCard(title: String, content: [String]) -> some View { VStack(alignment: .leading, spacing: 8) { Text(title) .font(.headline) VStack(alignment: .leading, spacing: 4) { ForEach(content, id: \.self) { line in Text(line) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(12) .meterCard(tint: .purple, fillOpacity: 0.08, strokeOpacity: 0.16) } private func observerDebugCard(for observer: ObserverRecord) -> some View { let statusColor: Color = observer.isAlive ? .green : .red let statusText = observer.isAlive ? "Alive" : "Dead" let timeSinceKeepalive = Date().timeIntervalSince(observer.lastKeepalive) return VStack(alignment: .leading, spacing: 6) { HStack { Circle() .fill(statusColor) .frame(width: 8, height: 8) Text(observer.deviceName) .font(.subheadline.weight(.semibold)) Spacer() Text(statusText) .font(.caption2) .foregroundColor(statusColor) } Text(observer.deviceID) .font(.system(.caption2, design: .monospaced)) .foregroundColor(.secondary) HStack { Text("Last keepalive: \(Int(timeSinceKeepalive))s ago") .font(.caption2) .foregroundColor(.secondary) Spacer() if observer.bluetoothEnabled { Image(systemName: "antenna.radiowaves.left.and.right") .font(.caption2) .foregroundColor(.blue) } else { Image(systemName: "antenna.radiowaves.left.and.right.slash") .font(.caption2) .foregroundColor(.secondary) } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(10) .meterCard(tint: statusColor, fillOpacity: 0.08, strokeOpacity: 0.14) } private func meterDebugCard(for known: KnownMeterCatalogItem) -> some View { let isLive = appData.meters.values.contains { $0.btSerial.macAddress.description == known.macAddress } let connectedElsewhere = isConnectedElsewhere(known) let statusText: String = { if isLive { return "Live on this device" } else if connectedElsewhere { return "Connected elsewhere" } else { return "Offline" } }() let statusColor: Color = { if isLive { return .green } else if connectedElsewhere { return .indigo } else { return .secondary } }() return VStack(alignment: .leading, spacing: 8) { HStack { Circle() .fill(statusColor) .frame(width: 8, height: 8) Text(known.displayName) .font(.subheadline.weight(.semibold)) Spacer() Text(statusText) .font(.caption2) .foregroundColor(statusColor) } Text(known.macAddress) .font(.system(.caption, design: .monospaced)) .foregroundColor(.secondary) if let modelType = known.modelType { Text("Model: \(modelType)") .font(.caption2) .foregroundColor(.secondary) } Divider() if let connectedBy = known.connectedByDeviceName, !connectedBy.isEmpty { VStack(alignment: .leading, spacing: 3) { Text("Connection:") .font(.caption.weight(.semibold)) Text("Device: \(connectedBy)") .font(.system(.caption2, design: .monospaced)) if let connectedAt = known.connectedAt { Text("Since: \(formatDate(connectedAt))") .font(.system(.caption2, design: .monospaced)) } if let expiry = known.connectedExpiryAt { let expired = expiry < Date() Text("Expires: \(formatDate(expiry)) \(expired ? "(expired)" : "")") .font(.system(.caption2, design: .monospaced)) .foregroundColor(expired ? .orange : .secondary) } } } else { Text("Not connected") .font(.caption2) .foregroundColor(.secondary) } if let lastSeenAt = known.lastSeenAt { Divider() VStack(alignment: .leading, spacing: 3) { Text("Discovery:") .font(.caption.weight(.semibold)) Text("Last seen: \(formatDate(lastSeenAt))") .font(.system(.caption2, design: .monospaced)) if let seenBy = known.lastSeenByDeviceName, !seenBy.isEmpty { Text("Seen by: \(seenBy)") .font(.system(.caption2, design: .monospaced)) } } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(12) .meterCard(tint: statusColor, fillOpacity: 0.08, strokeOpacity: 0.14) } } private struct DiscoveryChecklistView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text("Discovery Checklist") .font(.system(.title3, design: .rounded).weight(.bold)) .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .yellow, fillOpacity: 0.18, strokeOpacity: 0.24) checklistCard( title: "Keep the meter close", body: "For first pairing, keep the meter near your phone or Mac and away from strong interference." ) checklistCard( title: "Wake up Bluetooth advertising", body: "On some models, opening the Bluetooth menu on the meter restarts advertising for discovery." ) checklistCard( title: "Avoid competing connections", body: "Disconnect the meter from other phones/apps before trying discovery in this app." ) } .padding() } .background( LinearGradient( colors: [.yellow.opacity(0.14), Color.clear], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationTitle("Discovery Help") } private func checklistCard(title: String, body: String) -> some View { VStack(alignment: .leading, spacing: 6) { Text(title) .font(.headline) Text(body) .font(.footnote) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard(tint: .yellow, fillOpacity: 0.14, strokeOpacity: 0.20) } }