// // 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 struct ContentView: View { private enum HelpAutoReason: String { case bluetoothPermission case cloudSyncUnavailable case noDevicesDetected var tint: Color { switch self { case .bluetoothPermission: return .orange case .cloudSyncUnavailable: return .indigo case .noDevicesDetected: return .yellow } } var symbol: String { switch self { case .bluetoothPermission: return "bolt.horizontal.circle.fill" case .cloudSyncUnavailable: return "icloud.slash.fill" case .noDevicesDetected: return "magnifyingglass.circle.fill" } } var badgeTitle: String { switch self { case .bluetoothPermission: return "Required" case .cloudSyncUnavailable: return "Sync Off" case .noDevicesDetected: return "Suggested" } } } @EnvironmentObject private var appData: AppData @State private var isHelpExpanded = false @State private var dismissedAutoHelpReason: HelpAutoReason? @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 { ScrollView { VStack(alignment: .leading, spacing: 18) { headerCard helpSection devicesSection debugSection } .padding() } .background( LinearGradient( colors: [ appData.bluetoothManager.managerState.color.opacity(0.18), Color.clear ], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() ) .navigationBarTitle(Text("USB Meters"), displayMode: .inline) } .onAppear { appData.bluetoothManager.start() now = Date() } .onReceive(helpRefreshTimer) { currentDate in now = currentDate } .onChange(of: activeHelpAutoReason) { newReason in if newReason == nil { dismissedAutoHelpReason = nil } } } private var headerCard: some View { VStack(alignment: .leading, spacing: 10) { Text("USB Meters") .font(.system(.title2, design: .rounded).weight(.bold)) Text("Browse nearby supported meters and jump into live diagnostics, charge records, and device controls.") .font(.footnote) .foregroundColor(.secondary) HStack { Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill") .font(.footnote.weight(.semibold)) .foregroundColor(appData.bluetoothManager.managerState.color) Spacer() Text(bluetoothStatusText) .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } } .padding(18) .meterCard(tint: appData.bluetoothManager.managerState.color, fillOpacity: 0.22, strokeOpacity: 0.26) } private var helpSection: some View { VStack(alignment: .leading, spacing: 12) { Button(action: toggleHelpSection) { HStack(spacing: 14) { Image(systemName: helpSectionSymbol) .font(.system(size: 18, weight: .semibold)) .foregroundColor(helpSectionTint) .frame(width: 42, height: 42) .background(Circle().fill(helpSectionTint.opacity(0.18))) VStack(alignment: .leading, spacing: 4) { Text("Help") .font(.headline) Text(helpSectionSummary) .font(.caption) .foregroundColor(.secondary) } Spacer() if let activeHelpAutoReason { Text(activeHelpAutoReason.badgeTitle) .font(.caption2.weight(.bold)) .foregroundColor(activeHelpAutoReason.tint) .padding(.horizontal, 10) .padding(.vertical, 6) .background( Capsule(style: .continuous) .fill(activeHelpAutoReason.tint.opacity(0.12)) ) .overlay( Capsule(style: .continuous) .stroke(activeHelpAutoReason.tint.opacity(0.22), lineWidth: 1) ) } Image(systemName: helpIsExpanded ? "chevron.up" : "chevron.down") .font(.footnote.weight(.bold)) .foregroundColor(.secondary) } .padding(14) .meterCard(tint: helpSectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) } .buttonStyle(.plain) if helpIsExpanded { if let activeHelpAutoReason { helpNoticeCard(for: activeHelpAutoReason) } if activeHelpAutoReason == .cloudSyncUnavailable { Button(action: openSettings) { sidebarLinkCard( title: "Open Settings", subtitle: "Check Apple ID, iCloud Drive, and any restrictions affecting Cloud sync.", symbol: "gearshape.fill", tint: .indigo ) } .buttonStyle(.plain) } NavigationLink(destination: appData.bluetoothManager.managerState.helpView) { sidebarLinkCard( title: "Bluetooth", subtitle: "Permissions, adapter state, and connection tips.", symbol: "bolt.horizontal.circle.fill", tint: appData.bluetoothManager.managerState.color ) } .buttonStyle(.plain) NavigationLink(destination: DeviceHelpView()) { sidebarLinkCard( title: "Device", subtitle: "Quick checks when a meter is not responding as expected.", symbol: "questionmark.circle.fill", tint: .orange ) } .buttonStyle(.plain) } } .animation(.easeInOut(duration: 0.22), value: helpIsExpanded) } private var devicesSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { Text("Known Meters") .font(.headline) Spacer() Text("\(appData.knownMeters.count)") .font(.caption.weight(.bold)) .padding(.horizontal, 10) .padding(.vertical, 6) .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) } if appData.knownMeters.isEmpty { Text(devicesEmptyStateText) .font(.footnote) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(18) .meterCard( tint: isWaitingForFirstDiscovery ? .blue : .secondary, fillOpacity: 0.14, strokeOpacity: 0.20 ) } else { ForEach(appData.knownMeters) { knownMeter in if let meter = knownMeter.meter { NavigationLink(destination: MeterView().environmentObject(meter)) { MeterRowView() .environmentObject(meter) } .buttonStyle(.plain) } else { knownMeterCard(for: knownMeter) } } } } } private var debugSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Debug") .font(.headline) debugLink } } private var debugLink: some View { NavigationLink(destination: MeterMappingDebugView()) { sidebarLinkCard( title: "Meter Sync Debug", subtitle: "Inspect meter name sync data and iCloud KVS visibility as seen by this device.", symbol: "list.bullet.rectangle", tint: .purple ) } .buttonStyle(.plain) } private var bluetoothStatusText: String { switch appData.bluetoothManager.managerState { case .poweredOff: return "Off" case .poweredOn: return "On" case .resetting: return "Resetting" case .unauthorized: return "Unauthorized" case .unknown: return "Unknown" case .unsupported: return "Unsupported" @unknown default: return "Other" } } private var helpIsExpanded: Bool { isHelpExpanded || shouldAutoExpandHelp } private var shouldAutoExpandHelp: Bool { guard let activeHelpAutoReason else { return false } return dismissedAutoHelpReason != activeHelpAutoReason } private var activeHelpAutoReason: HelpAutoReason? { if appData.bluetoothManager.managerState == .unauthorized { return .bluetoothPermission } if shouldPromptForCloudSync { return .cloudSyncUnavailable } if hasWaitedLongEnoughForDevices { return .noDevicesDetected } return nil } private var shouldPromptForCloudSync: Bool { switch appData.cloudAvailability { case .noAccount, .error: return true case .unknown, .available: return false } } 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 known meters yet. Nearby supported meters will appear here and remain available after they disappear." } private var helpSectionTint: Color { activeHelpAutoReason?.tint ?? .secondary } private var helpSectionSymbol: String { activeHelpAutoReason?.symbol ?? "questionmark.circle.fill" } private var helpSectionSummary: String { switch activeHelpAutoReason { case .bluetoothPermission: return "Bluetooth permission is needed before scanning can begin." case .cloudSyncUnavailable: return appData.cloudAvailability.helpMessage case .noDevicesDetected: return "No supported devices were found after \(Int(noDevicesHelpDelay)) seconds." case nil: return "Connection tips and quick checks when discovery needs help." } } private func toggleHelpSection() { withAnimation(.easeInOut(duration: 0.22)) { if shouldAutoExpandHelp { dismissedAutoHelpReason = activeHelpAutoReason isHelpExpanded = false } else { isHelpExpanded.toggle() } } } private func openSettings() { guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil) } private func helpNoticeCard(for reason: HelpAutoReason) -> some View { VStack(alignment: .leading, spacing: 8) { Text(helpNoticeTitle(for: reason)) .font(.subheadline.weight(.semibold)) Text(helpNoticeDetail(for: reason)) .font(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(14) .meterCard(tint: reason.tint, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18) } private func helpNoticeTitle(for reason: HelpAutoReason) -> String { switch reason { case .bluetoothPermission: return "Bluetooth access needs attention" case .cloudSyncUnavailable: return appData.cloudAvailability.helpTitle case .noDevicesDetected: return "No supported meters found yet" } } private func helpNoticeDetail(for reason: HelpAutoReason) -> String { switch reason { case .bluetoothPermission: return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked." case .cloudSyncUnavailable: return appData.cloudAvailability.helpMessage case .noDevicesDetected: return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone." } } private func sidebarLinkCard( title: String, subtitle: String, symbol: String, tint: Color ) -> some View { HStack(spacing: 14) { Image(systemName: symbol) .font(.system(size: 18, weight: .semibold)) .foregroundColor(tint) .frame(width: 42, height: 42) .background(Circle().fill(tint.opacity(0.18))) VStack(alignment: .leading, spacing: 4) { Text(title) .font(.headline) Text(subtitle) .font(.caption) .foregroundColor(.secondary) } Spacer() Image(systemName: "chevron.right") .font(.footnote.weight(.bold)) .foregroundColor(.secondary) } .padding(14) .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18) } private func knownMeterCard(for knownMeter: AppData.KnownMeterSummary) -> some View { HStack(spacing: 14) { Image(systemName: "sensor.tag.radiowaves.forward.fill") .font(.system(size: 18, weight: .semibold)) .foregroundColor(knownMeterTint(for: knownMeter)) .frame(width: 42, height: 42) .background( Circle() .fill(knownMeterTint(for: knownMeter).opacity(0.18)) ) .overlay(alignment: .bottomTrailing) { Circle() .fill(Color.red) .frame(width: 12, height: 12) .overlay( Circle() .stroke(Color(uiColor: .systemBackground), lineWidth: 2) ) } VStack(alignment: .leading, spacing: 4) { Text(knownMeter.displayName) .font(.headline) Text(knownMeter.modelSummary) .font(.caption) .foregroundColor(.secondary) if let advertisedName = knownMeter.advertisedName, advertisedName != knownMeter.modelSummary { Text("Advertised as \(advertisedName)") .font(.caption2) .foregroundColor(.secondary) } } Spacer() VStack(alignment: .trailing, spacing: 4) { HStack(spacing: 6) { Circle() .fill(Color.red) .frame(width: 8, height: 8) Text("Missing") .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } .padding(.horizontal, 10) .padding(.vertical, 6) .background( Capsule(style: .continuous) .fill(Color.red.opacity(0.12)) ) .overlay( Capsule(style: .continuous) .stroke(Color.red.opacity(0.22), lineWidth: 1) ) Text(knownMeter.macAddress) .font(.caption2) .foregroundColor(.secondary) if let lastSeen = knownMeter.lastSeen { Text("Seen \(lastSeen.format(as: "yyyy-MM-dd HH:mm"))") .font(.caption2) .foregroundColor(.secondary) } } } .padding(14) .meterCard( tint: knownMeterTint(for: knownMeter), fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18 ) } private func knownMeterTint(for knownMeter: AppData.KnownMeterSummary) -> Color { switch knownMeter.modelSummary { case "UM25C": return .blue case "UM34C": return .yellow case "TC66C": return Model.TC66C.color default: return .secondary } } }