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
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
debugLink
devicesSection
}
.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("Discovered Devices")
.font(.headline)
Spacer()
Text("\(appData.meters.count)")
.font(.caption.weight(.bold))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
}
if appData.meters.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(discoveredMeters, id: \.self) { meter in
NavigationLink(destination: MeterView().environmentObject(meter)) {
MeterRowView()
.environmentObject(meter)
}
.buttonStyle(.plain)
}
}
}
}
private var debugLink: some View {
NavigationLink(destination: MeterMappingDebugView()) {
sidebarLinkCard(
title: "Meter Mapping Debug",
subtitle: "Inspect the MAC ↔ name/TC66 table as seen by this device.",
symbol: "list.bullet.rectangle",
tint: .purple
)
}
.buttonStyle(.plain)
}
private var discoveredMeters: [Meter] {
Array(appData.meters.values).sorted { lhs, rhs in
lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
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 supported meters are visible right now."
}
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)
}
}