// // 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 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") } } } } } .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 .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) VStack(alignment: .leading, spacing: 10) { HStack { Text("iCloud Debug") .font(.headline) Spacer() Button { UIPasteboard.general.string = offlineDebugText(for: meter, known: known, isConnectedElsewhere: isConnectedElsewhere) } label: { Label("Copy", systemImage: "doc.on.doc") .font(.caption.weight(.semibold)) } .buttonStyle(.plain) } Text(offlineDebugText(for: meter, known: known, isConnectedElsewhere: isConnectedElsewhere)) .font(.system(.footnote, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .padding(18) .meterCard(tint: .indigo, 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 func offlineDebugText(for meter: MeterSidebarEntry, known: KnownMeterCatalogItem, isConnectedElsewhere: Bool) -> String { [ "Local Device ID: \(AppData.myDeviceID)", "Local Device Name: \(AppData.myDeviceName)", "Now: \(formatDate(Date()))", "MAC: \(meter.macAddress)", "Display Name: \(meter.displayName)", "Model: \(meter.modelSummary)", "Connected By Device ID: \(known.connectedByDeviceID ?? "(empty)")", "Connected By Device Name: \(known.connectedByDeviceName ?? "(empty)")", "Connected At: \(formatDate(known.connectedAt))", "Connected Expiry: \(formatDate(known.connectedExpiryAt))", "Last Seen At: \(formatDate(known.lastSeenAt))", "Last Seen By Device ID: \(known.lastSeenByDeviceID ?? "(empty)")", "Last Seen By Device Name: \(known.lastSeenByDeviceName ?? "(empty)")", "Last Seen Peripheral Name: \(known.lastSeenPeripheralName ?? "(empty)")", "Connected Elsewhere Decision: \(isConnectedElsewhere ? "true (foreign device + valid expiry)" : "false (missing foreign owner or expired claim)")" ].joined(separator: "\n") } 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: 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 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) } }