1 contributor
//
// 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)"
])
if !appData.knownMetersByMAC.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Connected 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 meterDebugCard(for known: KnownMeterCatalogItem) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(known.displayName)
.font(.subheadline.weight(.semibold))
VStack(alignment: .leading, spacing: 2) {
Text(known.macAddress)
.font(.system(.caption, design: .monospaced))
if let connectedBy = known.connectedByDeviceName, !connectedBy.isEmpty {
Text("Connected: \(connectedBy)")
.font(.system(.caption2, design: .default))
.foregroundColor(.secondary)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.meterCard(tint: .cyan, fillOpacity: 0.08, strokeOpacity: 0.12)
}
}
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)
}
}