1 contributor
//
// SidebarView.swift
// USB Meter
//
import SwiftUI
import Combine
private enum SidebarCreationSheet: Identifiable {
case meter
case device
case charger
var id: String {
switch self {
case .meter:
return "meter"
case .device:
return "device"
case .charger:
return "charger"
}
}
}
struct SidebarView: View {
@EnvironmentObject private var appData: AppData
@State private var isUSBMetersExpanded = true
@State private var isDevicesExpanded = true
@State private var isChargersExpanded = true
@State private var isHelpExpanded = false
@State private var dismissedAutoHelpReason: SidebarHelpReason?
@State private var now = Date()
@State private var creationSheet: SidebarCreationSheet?
private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private let noDevicesHelpDelay: TimeInterval = 12
var body: some View {
SidebarListView(backgroundTint: appData.bluetoothManager.managerState.color) {
usbMetersSection
} helpSection: {
helpSection
} debugSection: {
debugSection
}
.onAppear {
appData.bluetoothManager.start()
now = Date()
}
.onReceive(helpRefreshTimer) { currentDate in
now = currentDate
}
.onChange(of: activeHelpAutoReason) { newReason in
if newReason == nil {
dismissedAutoHelpReason = nil
}
}
.sheet(item: $creationSheet) { sheet in
switch sheet {
case .meter:
MeterEditorSheetView()
.environmentObject(appData)
case .device:
ChargedDeviceEditorSheetView()
.environmentObject(appData)
case .charger:
ChargerEditorSheetView()
.environmentObject(appData)
}
}
}
private var usbMetersSection: some View {
Group {
SidebarUSBMetersSectionView(
meters: appData.meterSummaries,
managerState: appData.bluetoothManager.managerState,
hasLiveMeters: appData.meters.isEmpty == false,
scanStartedAt: appData.bluetoothManager.scanStartedAt,
now: now,
noDevicesHelpDelay: noDevicesHelpDelay,
isExpanded: isUSBMetersExpanded,
onToggle: {
withAnimation(.easeInOut(duration: 0.22)) {
isUSBMetersExpanded.toggle()
}
},
onAddMeter: { creationSheet = .meter }
)
SidebarChargedDevicesSectionView(
title: "Devices",
mode: .device,
chargedDevices: appData.deviceSummaries,
emptyStateText: "No devices yet. Open Charge Record on a live meter or use the add button here to create one and start learning capacity.",
tint: .orange,
isExpanded: isDevicesExpanded,
onToggle: {
withAnimation(.easeInOut(duration: 0.22)) {
isDevicesExpanded.toggle()
}
},
onAdd: { creationSheet = .device }
)
SidebarChargedDevicesSectionView(
title: "Chargers",
mode: .charger,
chargedDevices: appData.chargerSummaries,
emptyStateText: "No chargers yet. Add one here so wireless sessions can track both the charged device and the charger being used.",
tint: .pink,
isExpanded: isChargersExpanded,
onToggle: {
withAnimation(.easeInOut(duration: 0.22)) {
isChargersExpanded.toggle()
}
},
onAdd: { creationSheet = .charger }
)
}
}
private var helpSection: some View {
SidebarHelpSectionView(
activeReason: activeHelpAutoReason,
isExpanded: helpIsExpanded,
bluetoothStatusTint: appData.bluetoothManager.managerState.color,
bluetoothStatusText: bluetoothStatusText,
cloudSyncHelpTitle: appData.cloudAvailability.helpTitle,
cloudSyncHelpMessage: appData.cloudAvailability.helpMessage,
onToggle: toggleHelpSection,
onOpenSettings: openSettings
) {
appData.bluetoothManager.managerState.helpView
} deviceHelpDestination: {
DeviceHelpView()
}
}
private var debugSection: some View {
SidebarDebugSectionView()
}
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: SidebarHelpReason? {
SidebarAutoHelpResolver.activeReason(
managerState: appData.bluetoothManager.managerState,
cloudAvailability: appData.cloudAvailability,
hasLiveMeters: appData.meters.isEmpty == false,
scanStartedAt: appData.bluetoothManager.scanStartedAt,
now: now,
noDevicesHelpDelay: noDevicesHelpDelay
)
}
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)
}
}
// MARK: - Meter Editor Sheet
struct MeterEditorSheetView: View {
@EnvironmentObject private var appData: AppData
@Environment(\.dismiss) private var dismiss
let existingMeterSummary: AppData.MeterSummary?
@State private var customName: String
@State private var macAddress: String
@State private var advertisedName: String
@State private var selectedModel: Model
init(existingMeterSummary: AppData.MeterSummary? = nil) {
self.existingMeterSummary = existingMeterSummary
_customName = State(initialValue: existingMeterSummary?.displayName ?? "")
_macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "")
_advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "")
_selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary))
}
var body: some View {
NavigationView {
Form {
Section(
header: ContextInfoHeader(
title: "Identity",
message: "Use the real BLE MAC address format `AA:BB:CC:DD:EE:FF`. Saved meters remain visible in the sidebar even when they are currently offline."
)
) {
TextField("Display name", text: $customName)
TextField("MAC Address", text: $macAddress)
.textInputAutocapitalization(.characters)
.disableAutocorrection(true)
.disabled(existingMeterSummary != nil)
Picker("Model", selection: $selectedModel) {
ForEach(Model.allCases, id: \.self) { model in
Text(model.canonicalName)
.tag(model)
}
}
TextField("Advertised name", text: $advertisedName)
}
}
.navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(existingMeterSummary == nil ? "Save" : "Update") {
let normalizedMAC = AppData.normalizedMACAddress(macAddress)
let didSave = appData.createKnownMeter(
macAddress: normalizedMAC,
customName: customName,
modelName: selectedModel.canonicalName,
advertisedName: advertisedName
)
if didSave {
dismiss()
}
}
.disabled(isSaveDisabled)
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
private var isSaveDisabled: Bool {
AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false
}
private static func model(for summary: String?) -> Model {
if summary?.contains("UM34C") == true {
return .UM34C
}
if summary?.contains("TC66C") == true {
return .TC66C
}
return .UM25C
}
}