USB-Meter / USB Meter / Views / ContentView.swift
Newer Older
1d65e6e 2 months ago History
683 lines | 26.275kb
Bogdan Timofte authored 2 months ago
1
//
2
//  ContentView.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 01/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8

            
9
//MARK: Bluetooth Icon: https://upload.wikimedia.org/wikipedia/commons/d/da/Bluetooth.svg
10

            
11
import SwiftUI
Bogdan Timofte authored 2 months ago
12
import Combine
Bogdan Timofte authored 2 months ago
13
import UIKit
Bogdan Timofte authored 2 months ago
14

            
15
struct ContentView: View {
Bogdan Timofte authored 2 months ago
16
    private enum SidebarItem: Hashable {
17
        case overview
18
        case meter(String)
Bogdan Timofte authored 2 months ago
19
        case debug
Bogdan Timofte authored 2 months ago
20
        case bluetoothHelp
21
        case deviceChecklist
22
        case discoveryChecklist
23
    }
Bogdan Timofte authored 2 months ago
24

            
Bogdan Timofte authored 2 months ago
25
    private struct MeterSidebarEntry: Identifiable, Hashable {
26
        let id: String
27
        let macAddress: String
28
        let displayName: String
29
        let modelSummary: String
30
        let meterColor: Color
31
        let statusText: String
32
        let statusColor: Color
33
        let isLive: Bool
34
        let lastSeenAt: Date?
Bogdan Timofte authored 2 months ago
35
    }
Bogdan Timofte authored 2 months ago
36

            
37
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored 2 months ago
38
    @State private var selectedSidebarItem: SidebarItem? = .overview
Bogdan Timofte authored 2 months ago
39
    @State private var now = Date()
40
    private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
41
    private let noDevicesHelpDelay: TimeInterval = 12
Bogdan Timofte authored 2 months ago
42

            
43
    var body: some View {
44
        NavigationView {
Bogdan Timofte authored 2 months ago
45
            sidebar
46
            detailContent(for: selectedSidebarItem ?? .overview)
Bogdan Timofte authored 2 months ago
47
        }
Bogdan Timofte authored 2 months ago
48
        .navigationViewStyle(DoubleColumnNavigationViewStyle())
Bogdan Timofte authored 2 months ago
49
        .onAppear {
50
            appData.bluetoothManager.start()
Bogdan Timofte authored 2 months ago
51
            now = Date()
52
        }
53
        .onReceive(helpRefreshTimer) { currentDate in
54
            now = currentDate
55
        }
Bogdan Timofte authored 2 months ago
56
        .onChange(of: visibleMeterIDs) { _ in
57
            sanitizeSelection()
58
        }
59
        .onChange(of: appData.bluetoothManager.managerState) { _ in
60
            sanitizeSelection()
Bogdan Timofte authored 2 months ago
61
        }
Bogdan Timofte authored 2 months ago
62
    }
Bogdan Timofte authored 2 months ago
63

            
Bogdan Timofte authored 2 months ago
64
    private var sidebar: some View {
65
        List(selection: $selectedSidebarItem) {
66
            Section(header: Text("Start")) {
67
                NavigationLink(tag: SidebarItem.overview, selection: $selectedSidebarItem) {
68
                    detailContent(for: .overview)
69
                } label: {
70
                    Label("Overview", systemImage: "house.fill")
71
                }
Bogdan Timofte authored 2 months ago
72
            }
73

            
Bogdan Timofte authored 2 months ago
74
            Section(header: Text("Meters")) {
75
                if visibleMeters.isEmpty {
76
                    HStack(spacing: 10) {
77
                        Image(systemName: isWaitingForFirstDiscovery ? "dot.radiowaves.left.and.right" : "questionmark.circle")
78
                            .foregroundColor(isWaitingForFirstDiscovery ? .blue : .secondary)
79
                        Text(devicesEmptyStateText)
80
                            .font(.footnote)
Bogdan Timofte authored 2 months ago
81
                            .foregroundColor(.secondary)
82
                    }
Bogdan Timofte authored 2 months ago
83
                } else {
84
                    ForEach(visibleMeters) { meter in
85
                        NavigationLink(tag: SidebarItem.meter(meter.id), selection: $selectedSidebarItem) {
86
                            detailContent(for: .meter(meter.id))
87
                        } label: {
88
                            meterSidebarRow(for: meter)
89
                        }
90
                        .buttonStyle(.plain)
91
                        .listRowInsets(EdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10))
92
                        .listRowBackground(Color.clear)
93
                    }
94
                }
95
            }
96

            
97
            if shouldShowAssistanceSection {
98
                Section(header: Text("Assistance")) {
99
                    if shouldShowBluetoothHelpEntry {
100
                        NavigationLink(tag: SidebarItem.bluetoothHelp, selection: $selectedSidebarItem) {
101
                            appData.bluetoothManager.managerState.helpView
102
                        } label: {
103
                            Label("Bluetooth Checklist", systemImage: "bolt.horizontal.circle.fill")
104
                        }
105
                    }
Bogdan Timofte authored 2 months ago
106

            
Bogdan Timofte authored 2 months ago
107
                    if shouldShowDeviceChecklistEntry {
108
                        NavigationLink(tag: SidebarItem.deviceChecklist, selection: $selectedSidebarItem) {
109
                            DeviceHelpView()
110
                        } label: {
111
                            Label("Device Checklist", systemImage: "checklist")
112
                        }
Bogdan Timofte authored 2 months ago
113
                    }
114

            
Bogdan Timofte authored 2 months ago
115
                    if shouldShowDiscoveryChecklistEntry {
116
                        NavigationLink(tag: SidebarItem.discoveryChecklist, selection: $selectedSidebarItem) {
117
                            DiscoveryChecklistView()
118
                        } label: {
119
                            Label("Discovery Checklist", systemImage: "magnifyingglass.circle")
120
                        }
121
                    }
Bogdan Timofte authored 2 months ago
122
                }
Bogdan Timofte authored 2 months ago
123
            }
Bogdan Timofte authored 2 months ago
124

            
125
            Section(header: Text("Debug")) {
126
                NavigationLink(tag: SidebarItem.debug, selection: $selectedSidebarItem) {
127
                    debugView
128
                } label: {
129
                    Label("Debug Info", systemImage: "wrench.and.screwdriver")
130
                }
131
            }
Bogdan Timofte authored 2 months ago
132
        }
133
        .listStyle(SidebarListStyle())
134
        .navigationTitle("USB Meters")
135
    }
Bogdan Timofte authored 2 months ago
136

            
Bogdan Timofte authored 2 months ago
137
    @ViewBuilder
138
    private func detailContent(for item: SidebarItem) -> some View {
139
        switch item {
140
        case .overview:
141
            overviewDetail
142
        case .meter(let macAddress):
143
            if let meter = liveMeter(forMacAddress: macAddress) {
144
                MeterView().environmentObject(meter)
145
            } else if let meter = meterEntry(for: macAddress),
146
                      let known = appData.knownMetersByMAC[macAddress] {
147
                offlineMeterDetail(for: meter, known: known)
148
            } else {
149
                unavailableMeterDetail
150
            }
Bogdan Timofte authored 2 months ago
151
        case .debug:
152
            debugView
Bogdan Timofte authored 2 months ago
153
        case .bluetoothHelp:
154
            appData.bluetoothManager.managerState.helpView
155
        case .deviceChecklist:
156
            DeviceHelpView()
157
        case .discoveryChecklist:
158
            DiscoveryChecklistView()
159
        }
160
    }
Bogdan Timofte authored 2 months ago
161

            
Bogdan Timofte authored 2 months ago
162
    private var overviewDetail: some View {
163
        ScrollView {
164
            VStack(alignment: .leading, spacing: 16) {
165
                VStack(alignment: .leading, spacing: 8) {
166
                    Text("USB Meter")
167
                        .font(.system(.title2, design: .rounded).weight(.bold))
168
                    Text("Discover nearby supported meters and open one to see live diagnostics, records, and controls.")
169
                        .font(.footnote)
170
                        .foregroundColor(.secondary)
171
                }
172
                .frame(maxWidth: .infinity, alignment: .leading)
173
                .padding(18)
174
                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22)
175

            
176
                if shouldShowBluetoothHelpEntry {
177
                    overviewHintCard(
178
                        title: "Bluetooth needs attention",
179
                        detail: "Open Bluetooth Checklist from the sidebar to resolve the current Bluetooth state.",
180
                        tint: appData.bluetoothManager.managerState.color,
181
                        symbol: "bolt.horizontal.circle.fill"
182
                    )
183
                } else {
184
                    overviewHintCard(
185
                        title: "Discovered devices",
186
                        detail: visibleMeters.isEmpty ? devicesEmptyStateText : "\(visibleMeters.count) known device(s) available in the sidebar.",
187
                        tint: visibleMeters.isEmpty ? .secondary : .green,
188
                        symbol: visibleMeters.isEmpty ? "dot.radiowaves.left.and.right" : "sensor.tag.radiowaves.forward.fill"
Bogdan Timofte authored 2 months ago
189
                    )
190
                }
Bogdan Timofte authored 2 months ago
191

            
192
                overviewHintCard(
193
                    title: "Quick start",
194
                    detail: "1. Power on your USB meter.\n2. Keep it close to this device.\n3. Select it from Discovered Devices in the sidebar.",
195
                    tint: .orange,
196
                    symbol: "list.number"
197
                )
198

            
199
                if shouldShowDeviceChecklistEntry || shouldShowDiscoveryChecklistEntry {
200
                    overviewHintCard(
201
                        title: "Need help finding devices?",
202
                        detail: "Use the Assistance entries from the sidebar for guided troubleshooting checklists.",
203
                        tint: .yellow,
204
                        symbol: "questionmark.circle.fill"
Bogdan Timofte authored 2 months ago
205
                    )
206
                }
Bogdan Timofte authored 2 months ago
207
            }
Bogdan Timofte authored 2 months ago
208
            .padding()
Bogdan Timofte authored 2 months ago
209
        }
Bogdan Timofte authored 2 months ago
210
        .background(
211
            LinearGradient(
212
                colors: [.blue.opacity(0.12), Color.clear],
213
                startPoint: .topLeading,
214
                endPoint: .bottomTrailing
215
            )
216
            .ignoresSafeArea()
217
        )
218
        .navigationBarTitle(Text("Overview"), displayMode: .inline)
Bogdan Timofte authored 2 months ago
219
    }
220

            
Bogdan Timofte authored 2 months ago
221
    private var unavailableMeterDetail: some View {
222
        VStack(spacing: 10) {
223
            Image(systemName: "exclamationmark.triangle.fill")
224
                .font(.system(size: 30, weight: .bold))
225
                .foregroundColor(.orange)
226
            Text("Device no longer available")
227
                .font(.headline)
228
            Text("Select another device from the sidebar or return to Overview.")
229
                .font(.footnote)
230
                .foregroundColor(.secondary)
231
        }
232
        .padding(24)
233
    }
Bogdan Timofte authored 2 months ago
234

            
Bogdan Timofte authored 2 months ago
235
    private func offlineMeterDetail(for meter: MeterSidebarEntry, known: KnownMeterCatalogItem) -> some View {
236
        let isConnectedElsewhere = isConnectedElsewhere(known)
237

            
238
        return ScrollView {
239
            VStack(alignment: .leading, spacing: 14) {
240
                VStack(alignment: .leading, spacing: 8) {
241
                    HStack(spacing: 8) {
242
                        Circle()
243
                            .fill(meter.statusColor)
244
                            .frame(width: 10, height: 10)
245
                        Text(isConnectedElsewhere ? "Connected Elsewhere" : "Unavailable On This Device")
246
                            .font(.headline)
Bogdan Timofte authored 2 months ago
247
                    }
Bogdan Timofte authored 2 months ago
248

            
249
                    Text(meter.displayName)
250
                        .font(.title3.weight(.semibold))
251

            
252
                    Text(meter.modelSummary)
253
                        .font(.subheadline)
254
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 months ago
255
                }
Bogdan Timofte authored 2 months ago
256
                .frame(maxWidth: .infinity, alignment: .leading)
257
                .padding(18)
258
                .meterCard(tint: meter.meterColor, fillOpacity: 0.14, strokeOpacity: 0.22)
259

            
260
                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.")
261
                    .font(.footnote)
262
                    .foregroundColor(.secondary)
263
                    .padding(.horizontal, 4)
Bogdan Timofte authored 2 months ago
264
            }
Bogdan Timofte authored 2 months ago
265
            .padding()
Bogdan Timofte authored 2 months ago
266
        }
Bogdan Timofte authored 2 months ago
267
        .background(
268
            LinearGradient(
269
                colors: [meter.meterColor.opacity(0.10), Color.clear],
270
                startPoint: .topLeading,
271
                endPoint: .bottomTrailing
272
            )
273
            .ignoresSafeArea()
274
        )
Bogdan Timofte authored 2 months ago
275
    }
276

            
Bogdan Timofte authored 2 months ago
277
    private var visibleMeters: [MeterSidebarEntry] {
278
        var entriesByMAC: [String: MeterSidebarEntry] = [:]
279

            
280
        for known in appData.knownMetersByMAC.values {
281
            let isConnectedElsewhere = isConnectedElsewhere(known)
282
            entriesByMAC[known.macAddress] = MeterSidebarEntry(
283
                id: known.macAddress,
284
                macAddress: known.macAddress,
285
                displayName: known.displayName,
286
                modelSummary: known.modelType ?? "Unknown model",
287
                meterColor: meterColor(forModelType: known.modelType),
288
                statusText: isConnectedElsewhere ? "Elsewhere" : "Offline",
289
                statusColor: isConnectedElsewhere ? .indigo : .secondary,
290
                isLive: false,
291
                lastSeenAt: known.lastSeenAt
292
            )
293
        }
294

            
295
        for meter in appData.meters.values {
296
            let mac = meter.btSerial.macAddress.description
297
            let known = appData.knownMetersByMAC[mac]
298
            let cloudElsewhere = known.map(isConnectedElsewhere) ?? false
299
            let liveConnected = meter.operationalState >= .peripheralConnected
300
            let effectiveElsewhere = cloudElsewhere && !liveConnected
301
            entriesByMAC[mac] = MeterSidebarEntry(
302
                id: mac,
303
                macAddress: mac,
304
                displayName: meter.name,
305
                modelSummary: meter.deviceModelSummary,
306
                meterColor: meter.color,
307
                statusText: effectiveElsewhere ? "Elsewhere" : statusText(for: meter.operationalState),
308
                statusColor: effectiveElsewhere ? .indigo : Meter.operationalColor(for: meter.operationalState),
309
                isLive: true,
310
                lastSeenAt: meter.lastSeen
311
            )
312
        }
313

            
314
        return entriesByMAC.values.sorted { lhs, rhs in
315
            lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
Bogdan Timofte authored 2 months ago
316
        }
317
    }
318

            
Bogdan Timofte authored 2 months ago
319
    private func isConnectedElsewhere(_ known: KnownMeterCatalogItem) -> Bool {
320
        guard let connectedBy = known.connectedByDeviceID, !connectedBy.isEmpty else {
321
            return false
322
        }
323
        guard connectedBy != AppData.myDeviceID else {
324
            return false
325
        }
326
        guard let expiry = known.connectedExpiryAt else {
327
            return false
328
        }
329
        return expiry > Date()
330
    }
331

            
332
    private func formatDate(_ value: Date?) -> String {
333
        guard let value else { return "(empty)" }
334
        return value.formatted(date: .abbreviated, time: .standard)
335
    }
336

            
337
    private var visibleMeterIDs: [String] {
338
        visibleMeters.map(\.id)
339
    }
340

            
341
    private var shouldShowBluetoothHelpEntry: Bool {
Bogdan Timofte authored 2 months ago
342
        switch appData.bluetoothManager.managerState {
343
        case .poweredOn:
Bogdan Timofte authored 2 months ago
344
            return false
Bogdan Timofte authored 2 months ago
345
        case .unknown:
Bogdan Timofte authored 2 months ago
346
            return false
347
        default:
348
            return true
Bogdan Timofte authored 2 months ago
349
        }
350
    }
351

            
Bogdan Timofte authored 2 months ago
352
    private var shouldShowDeviceChecklistEntry: Bool {
353
        hasWaitedLongEnoughForDevices
Bogdan Timofte authored 2 months ago
354
    }
355

            
Bogdan Timofte authored 2 months ago
356
    private var shouldShowDiscoveryChecklistEntry: Bool {
357
        hasWaitedLongEnoughForDevices
358
    }
359

            
360
    private var shouldShowAssistanceSection: Bool {
361
        shouldShowBluetoothHelpEntry || shouldShowDeviceChecklistEntry || shouldShowDiscoveryChecklistEntry
Bogdan Timofte authored 2 months ago
362
    }
363

            
Bogdan Timofte authored 2 months ago
364
    private func liveMeter(forMacAddress macAddress: String) -> Meter? {
365
        appData.meters.values.first { $0.btSerial.macAddress.description == macAddress }
366
    }
367

            
368
    private func meterEntry(for macAddress: String) -> MeterSidebarEntry? {
369
        visibleMeters.first { $0.macAddress == macAddress }
370
    }
371

            
372
    private func sanitizeSelection() {
373
        guard let selectedSidebarItem else {
374
            return
Bogdan Timofte authored 2 months ago
375
        }
Bogdan Timofte authored 2 months ago
376

            
377
        switch selectedSidebarItem {
378
        case .meter(let meterID):
379
            if meterEntry(for: meterID) == nil {
380
                self.selectedSidebarItem = .overview
381
            }
382
        case .bluetoothHelp:
383
            if !shouldShowBluetoothHelpEntry {
384
                self.selectedSidebarItem = .overview
385
            }
386
        case .deviceChecklist:
387
            if !shouldShowDeviceChecklistEntry {
388
                self.selectedSidebarItem = .overview
389
            }
390
        case .discoveryChecklist:
391
            if !shouldShowDiscoveryChecklistEntry {
392
                self.selectedSidebarItem = .overview
393
            }
Bogdan Timofte authored 2 months ago
394
        case .overview, .debug:
Bogdan Timofte authored 2 months ago
395
            break
Bogdan Timofte authored 2 months ago
396
        }
397
    }
398

            
399
    private var hasWaitedLongEnoughForDevices: Bool {
400
        guard appData.bluetoothManager.managerState == .poweredOn else {
401
            return false
402
        }
403
        guard appData.meters.isEmpty else {
404
            return false
405
        }
406
        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
407
            return false
408
        }
409
        return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay
410
    }
411

            
412
    private var isWaitingForFirstDiscovery: Bool {
413
        guard appData.bluetoothManager.managerState == .poweredOn else {
414
            return false
415
        }
416
        guard appData.meters.isEmpty else {
417
            return false
418
        }
419
        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
420
            return false
421
        }
422
        return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay
423
    }
424

            
425
    private var devicesEmptyStateText: String {
426
        if isWaitingForFirstDiscovery {
427
            return "Scanning for nearby supported meters..."
428
        }
Bogdan Timofte authored 2 months ago
429
        return "No supported meters are visible yet."
Bogdan Timofte authored 2 months ago
430
    }
431

            
Bogdan Timofte authored 2 months ago
432
    private func meterSidebarRow(for meter: MeterSidebarEntry) -> some View {
433
        HStack(spacing: 14) {
434
            Image(systemName: meter.isLive ? "sensor.tag.radiowaves.forward.fill" : "sensor.tag.radiowaves.forward")
435
                .font(.system(size: 18, weight: .semibold))
436
                .foregroundColor(meter.meterColor)
437
                .frame(width: 42, height: 42)
438
                .background(
439
                    Circle()
440
                        .fill(meter.meterColor.opacity(0.18))
441
                )
442
                .overlay(alignment: .bottomTrailing) {
443
                    Circle()
444
                        .fill(meter.statusColor)
445
                        .frame(width: 12, height: 12)
446
                        .overlay(
447
                            Circle()
448
                                .stroke(Color(uiColor: .systemBackground), lineWidth: 2)
449
                        )
450
                }
Bogdan Timofte authored 2 months ago
451

            
Bogdan Timofte authored 2 months ago
452
            VStack(alignment: .leading, spacing: 4) {
453
                Text(meter.displayName)
454
                    .font(.headline)
455
                Text(meter.modelSummary)
456
                    .font(.caption)
457
                    .foregroundColor(.secondary)
458
            }
Bogdan Timofte authored 2 months ago
459

            
Bogdan Timofte authored 2 months ago
460
            Spacer()
Bogdan Timofte authored 2 months ago
461

            
Bogdan Timofte authored 2 months ago
462
            VStack(alignment: .trailing, spacing: 4) {
463
                HStack(spacing: 6) {
464
                    Circle()
465
                        .fill(meter.statusColor)
466
                        .frame(width: 8, height: 8)
467
                    Text(meter.statusText)
468
                        .font(.caption.weight(.semibold))
469
                        .foregroundColor(.secondary)
470
                }
471
                .padding(.horizontal, 10)
472
                .padding(.vertical, 6)
473
                .background(
474
                    Capsule(style: .continuous)
475
                        .fill(meter.statusColor.opacity(0.12))
476
                )
477
                .overlay(
478
                    Capsule(style: .continuous)
479
                        .stroke(meter.statusColor.opacity(0.22), lineWidth: 1)
480
                )
481
                Text(meter.macAddress)
482
                    .font(.caption2.monospaced())
483
                    .foregroundColor(.secondary)
484
                    .lineLimit(1)
485
                    .truncationMode(.middle)
Bogdan Timofte authored 2 months ago
486
            }
487
        }
488
        .padding(14)
Bogdan Timofte authored 2 months ago
489
        .meterCard(
490
            tint: meter.meterColor,
491
            fillOpacity: meter.isLive ? 0.16 : 0.10,
492
            strokeOpacity: meter.isLive ? 0.22 : 0.16,
493
            cornerRadius: 18
494
        )
Bogdan Timofte authored 2 months ago
495
    }
496

            
Bogdan Timofte authored 2 months ago
497
    private func meterColor(forModelType modelType: String?) -> Color {
498
        guard let modelType = modelType?.uppercased() else { return .secondary }
499
        if modelType.contains("UM25") { return Model.UM25C.color }
500
        if modelType.contains("UM34") { return Model.UM34C.color }
501
        if modelType.contains("TC66") || modelType.contains("PW0316") { return Model.TC66C.color }
502
        return .secondary
Bogdan Timofte authored 2 months ago
503
    }
504

            
Bogdan Timofte authored 2 months ago
505
    private func statusText(for state: Meter.OperationalState) -> String {
506
        switch state {
507
        case .offline:
508
            return "Offline"
509
        case .connectedElsewhere:
510
            return "Elsewhere"
511
        case .peripheralNotConnected:
512
            return "Available"
513
        case .peripheralConnectionPending:
514
            return "Connecting"
515
        case .peripheralConnected:
516
            return "Linked"
517
        case .peripheralReady:
518
            return "Ready"
519
        case .comunicating:
520
            return "Syncing"
521
        case .dataIsAvailable:
522
            return "Live"
Bogdan Timofte authored 2 months ago
523
        }
524
    }
525

            
Bogdan Timofte authored 2 months ago
526
    private func overviewHintCard(title: String, detail: String, tint: Color, symbol: String) -> some View {
527
        HStack(alignment: .top, spacing: 12) {
Bogdan Timofte authored 2 months ago
528
            Image(systemName: symbol)
Bogdan Timofte authored 2 months ago
529
                .font(.system(size: 16, weight: .semibold))
Bogdan Timofte authored 2 months ago
530
                .foregroundColor(tint)
Bogdan Timofte authored 2 months ago
531
                .frame(width: 34, height: 34)
532
                .background(Circle().fill(tint.opacity(0.16)))
Bogdan Timofte authored 2 months ago
533

            
Bogdan Timofte authored 2 months ago
534
            VStack(alignment: .leading, spacing: 5) {
Bogdan Timofte authored 2 months ago
535
                Text(title)
536
                    .font(.headline)
Bogdan Timofte authored 2 months ago
537
                Text(detail)
538
                    .font(.footnote)
Bogdan Timofte authored 2 months ago
539
                    .foregroundColor(.secondary)
540
            }
Bogdan Timofte authored 2 months ago
541
        }
542
        .frame(maxWidth: .infinity, alignment: .leading)
543
        .padding(16)
544
        .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
545
    }
Bogdan Timofte authored 2 months ago
546

            
547
    private var debugView: some View {
548
        ScrollView {
549
            VStack(alignment: .leading, spacing: 14) {
550
                VStack(alignment: .leading, spacing: 8) {
551
                    Text("Debug Information")
552
                        .font(.system(.title2, design: .rounded).weight(.bold))
553
                    Text("System and CloudKit details for troubleshooting.")
554
                        .font(.footnote)
555
                        .foregroundColor(.secondary)
556
                }
557
                .frame(maxWidth: .infinity, alignment: .leading)
558
                .padding(18)
559
                .meterCard(tint: .purple, fillOpacity: 0.16, strokeOpacity: 0.22)
560

            
561
                debugCard(title: "Device Info", content: [
562
                    "Device ID: \(AppData.myDeviceID)",
563
                    "Device Name: \(AppData.myDeviceName)"
564
                ])
565

            
566
                debugCard(title: "CloudKit Status", content: [
567
                    "Container: iCloud.ro.xdev.USB-Meter",
568
                    "Total Meters: \(appData.knownMetersByMAC.count)",
569
                    "Live Meters: \(appData.meters.count)"
570
                ])
571

            
572
                if !appData.knownMetersByMAC.isEmpty {
573
                    VStack(alignment: .leading, spacing: 10) {
574
                        Text("Connected Meters")
575
                            .font(.headline)
576
                        ForEach(Array(appData.knownMetersByMAC.values), id: \.macAddress) { known in
577
                            meterDebugCard(for: known)
578
                        }
579
                    }
580
                    .frame(maxWidth: .infinity, alignment: .leading)
581
                    .padding(18)
582
                    .meterCard(tint: .cyan, fillOpacity: 0.14, strokeOpacity: 0.22)
583
                }
584
            }
585
            .padding()
586
        }
587
        .background(
588
            LinearGradient(
589
                colors: [.purple.opacity(0.08), Color.clear],
590
                startPoint: .topLeading,
591
                endPoint: .bottomTrailing
592
            )
593
            .ignoresSafeArea()
594
        )
595
        .navigationBarTitle(Text("Debug Info"), displayMode: .inline)
596
    }
597

            
598
    private func debugCard(title: String, content: [String]) -> some View {
599
        VStack(alignment: .leading, spacing: 8) {
600
            Text(title)
601
                .font(.headline)
602
            VStack(alignment: .leading, spacing: 4) {
603
                ForEach(content, id: \.self) { line in
604
                    Text(line)
605
                        .font(.system(.footnote, design: .monospaced))
606
                        .textSelection(.enabled)
607
                }
608
            }
609
        }
610
        .frame(maxWidth: .infinity, alignment: .leading)
611
        .padding(12)
612
        .meterCard(tint: .purple, fillOpacity: 0.08, strokeOpacity: 0.16)
613
    }
614

            
615
    private func meterDebugCard(for known: KnownMeterCatalogItem) -> some View {
616
        VStack(alignment: .leading, spacing: 6) {
617
            Text(known.displayName)
618
                .font(.subheadline.weight(.semibold))
619
            VStack(alignment: .leading, spacing: 2) {
620
                Text(known.macAddress)
621
                    .font(.system(.caption, design: .monospaced))
622
                if let connectedBy = known.connectedByDeviceName, !connectedBy.isEmpty {
623
                    Text("Connected: \(connectedBy)")
624
                        .font(.system(.caption2, design: .default))
625
                        .foregroundColor(.secondary)
626
                }
627
            }
628
        }
629
        .frame(maxWidth: .infinity, alignment: .leading)
630
        .padding(10)
631
        .meterCard(tint: .cyan, fillOpacity: 0.08, strokeOpacity: 0.12)
632
    }
Bogdan Timofte authored 2 months ago
633
}
Bogdan Timofte authored 2 months ago
634

            
Bogdan Timofte authored 2 months ago
635
private struct DiscoveryChecklistView: View {
636
    var body: some View {
637
        ScrollView {
638
            VStack(alignment: .leading, spacing: 14) {
639
                Text("Discovery Checklist")
640
                    .font(.system(.title3, design: .rounded).weight(.bold))
641
                    .frame(maxWidth: .infinity, alignment: .leading)
642
                    .padding(18)
643
                    .meterCard(tint: .yellow, fillOpacity: 0.18, strokeOpacity: 0.24)
Bogdan Timofte authored 2 months ago
644

            
Bogdan Timofte authored 2 months ago
645
                checklistCard(
646
                    title: "Keep the meter close",
647
                    body: "For first pairing, keep the meter near your phone or Mac and away from strong interference."
648
                )
649
                checklistCard(
650
                    title: "Wake up Bluetooth advertising",
651
                    body: "On some models, opening the Bluetooth menu on the meter restarts advertising for discovery."
652
                )
653
                checklistCard(
654
                    title: "Avoid competing connections",
655
                    body: "Disconnect the meter from other phones/apps before trying discovery in this app."
656
                )
657
            }
658
            .padding()
659
        }
660
        .background(
661
            LinearGradient(
662
                colors: [.yellow.opacity(0.14), Color.clear],
663
                startPoint: .topLeading,
664
                endPoint: .bottomTrailing
665
            )
666
            .ignoresSafeArea()
667
        )
668
        .navigationTitle("Discovery Help")
669
    }
670

            
671
    private func checklistCard(title: String, body: String) -> some View {
672
        VStack(alignment: .leading, spacing: 6) {
673
            Text(title)
674
                .font(.headline)
675
            Text(body)
676
                .font(.footnote)
Bogdan Timofte authored 2 months ago
677
                .foregroundColor(.secondary)
678
        }
Bogdan Timofte authored 2 months ago
679
        .frame(maxWidth: .infinity, alignment: .leading)
680
        .padding(18)
681
        .meterCard(tint: .yellow, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored 2 months ago
682
    }
Bogdan Timofte authored 2 months ago
683
}