USB-Meter / USB Meter / Views / ContentView.swift
Newer Older
541 lines | 19.274kb
Bogdan Timofte authored 2 weeks 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 weeks ago
12
import Combine
Bogdan Timofte authored 2 weeks ago
13

            
14
struct ContentView: View {
Bogdan Timofte authored 2 weeks ago
15
    private enum HelpAutoReason: String {
16
        case bluetoothPermission
Bogdan Timofte authored a week ago
17
        case cloudSyncUnavailable
Bogdan Timofte authored 2 weeks ago
18
        case noDevicesDetected
19

            
20
        var tint: Color {
21
            switch self {
22
            case .bluetoothPermission:
23
                return .orange
Bogdan Timofte authored a week ago
24
            case .cloudSyncUnavailable:
25
                return .indigo
Bogdan Timofte authored 2 weeks ago
26
            case .noDevicesDetected:
27
                return .yellow
28
            }
29
        }
30

            
31
        var symbol: String {
32
            switch self {
33
            case .bluetoothPermission:
34
                return "bolt.horizontal.circle.fill"
Bogdan Timofte authored a week ago
35
            case .cloudSyncUnavailable:
36
                return "icloud.slash.fill"
Bogdan Timofte authored 2 weeks ago
37
            case .noDevicesDetected:
38
                return "magnifyingglass.circle.fill"
39
            }
40
        }
41

            
42
        var badgeTitle: String {
43
            switch self {
44
            case .bluetoothPermission:
45
                return "Required"
Bogdan Timofte authored a week ago
46
            case .cloudSyncUnavailable:
47
                return "Sync Off"
Bogdan Timofte authored 2 weeks ago
48
            case .noDevicesDetected:
49
                return "Suggested"
50
            }
51
        }
52
    }
Bogdan Timofte authored a week ago
53

            
Bogdan Timofte authored 2 weeks ago
54
    @EnvironmentObject private var appData: AppData
Bogdan Timofte authored 2 weeks ago
55
    @State private var isHelpExpanded = false
56
    @State private var dismissedAutoHelpReason: HelpAutoReason?
57
    @State private var now = Date()
58
    private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
59
    private let noDevicesHelpDelay: TimeInterval = 12
Bogdan Timofte authored a week ago
60

            
Bogdan Timofte authored 2 weeks ago
61
    var body: some View {
62
        NavigationView {
Bogdan Timofte authored 2 weeks ago
63
            ScrollView {
64
                VStack(alignment: .leading, spacing: 18) {
65
                    headerCard
66
                    helpSection
67
                    devicesSection
Bogdan Timofte authored a week ago
68
                    debugSection
Bogdan Timofte authored 2 weeks ago
69
                }
Bogdan Timofte authored 2 weeks ago
70
                .padding()
Bogdan Timofte authored 2 weeks ago
71
            }
Bogdan Timofte authored 2 weeks ago
72
            .background(
73
                LinearGradient(
74
                    colors: [
75
                        appData.bluetoothManager.managerState.color.opacity(0.18),
76
                        Color.clear
77
                    ],
78
                    startPoint: .topLeading,
79
                    endPoint: .bottomTrailing
80
                )
81
                .ignoresSafeArea()
82
            )
Bogdan Timofte authored 2 weeks ago
83
            .navigationBarTitle(Text("USB Meters"), displayMode: .inline)
84
        }
Bogdan Timofte authored 2 weeks ago
85
        .onAppear {
86
            appData.bluetoothManager.start()
Bogdan Timofte authored 2 weeks ago
87
            now = Date()
88
        }
89
        .onReceive(helpRefreshTimer) { currentDate in
90
            now = currentDate
91
        }
92
        .onChange(of: activeHelpAutoReason) { newReason in
93
            if newReason == nil {
94
                dismissedAutoHelpReason = nil
95
            }
Bogdan Timofte authored 2 weeks ago
96
        }
Bogdan Timofte authored 2 weeks ago
97
    }
Bogdan Timofte authored 2 weeks ago
98

            
99
    private var headerCard: some View {
100
        VStack(alignment: .leading, spacing: 10) {
101
            Text("USB Meters")
102
                .font(.system(.title2, design: .rounded).weight(.bold))
103
            Text("Browse nearby supported meters and jump into live diagnostics, charge records, and device controls.")
104
                .font(.footnote)
105
                .foregroundColor(.secondary)
106
            HStack {
107
                Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill")
108
                    .font(.footnote.weight(.semibold))
109
                    .foregroundColor(appData.bluetoothManager.managerState.color)
110
                Spacer()
111
                Text(bluetoothStatusText)
112
                    .font(.caption.weight(.semibold))
113
                    .foregroundColor(.secondary)
114
            }
115
        }
116
        .padding(18)
117
        .meterCard(tint: appData.bluetoothManager.managerState.color, fillOpacity: 0.22, strokeOpacity: 0.26)
118
    }
119

            
120
    private var helpSection: some View {
121
        VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 weeks ago
122
            Button(action: toggleHelpSection) {
123
                HStack(spacing: 14) {
124
                    Image(systemName: helpSectionSymbol)
125
                        .font(.system(size: 18, weight: .semibold))
126
                        .foregroundColor(helpSectionTint)
127
                        .frame(width: 42, height: 42)
128
                        .background(Circle().fill(helpSectionTint.opacity(0.18)))
129

            
130
                    VStack(alignment: .leading, spacing: 4) {
131
                        Text("Help")
132
                            .font(.headline)
133
                        Text(helpSectionSummary)
134
                            .font(.caption)
135
                            .foregroundColor(.secondary)
136
                    }
137

            
138
                    Spacer()
139

            
140
                    if let activeHelpAutoReason {
141
                        Text(activeHelpAutoReason.badgeTitle)
142
                            .font(.caption2.weight(.bold))
143
                            .foregroundColor(activeHelpAutoReason.tint)
144
                            .padding(.horizontal, 10)
145
                            .padding(.vertical, 6)
146
                            .background(
147
                                Capsule(style: .continuous)
148
                                    .fill(activeHelpAutoReason.tint.opacity(0.12))
149
                            )
150
                            .overlay(
151
                                Capsule(style: .continuous)
152
                                    .stroke(activeHelpAutoReason.tint.opacity(0.22), lineWidth: 1)
153
                            )
154
                    }
155

            
156
                    Image(systemName: helpIsExpanded ? "chevron.up" : "chevron.down")
157
                        .font(.footnote.weight(.bold))
158
                        .foregroundColor(.secondary)
159
                }
160
                .padding(14)
161
                .meterCard(tint: helpSectionTint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
Bogdan Timofte authored 2 weeks ago
162
            }
163
            .buttonStyle(.plain)
164

            
Bogdan Timofte authored 2 weeks ago
165
            if helpIsExpanded {
166
                if let activeHelpAutoReason {
167
                    helpNoticeCard(for: activeHelpAutoReason)
168
                }
169

            
Bogdan Timofte authored a week ago
170
                if activeHelpAutoReason == .cloudSyncUnavailable {
171
                    Button(action: openSettings) {
172
                        sidebarLinkCard(
173
                            title: "Open Settings",
174
                            subtitle: "Check Apple ID, iCloud Drive, and any restrictions affecting Cloud sync.",
175
                            symbol: "gearshape.fill",
176
                            tint: .indigo
177
                        )
178
                    }
179
                    .buttonStyle(.plain)
180
                }
181

            
Bogdan Timofte authored 2 weeks ago
182
                NavigationLink(destination: appData.bluetoothManager.managerState.helpView) {
183
                    sidebarLinkCard(
184
                        title: "Bluetooth",
185
                        subtitle: "Permissions, adapter state, and connection tips.",
186
                        symbol: "bolt.horizontal.circle.fill",
187
                        tint: appData.bluetoothManager.managerState.color
188
                    )
189
                }
190
                .buttonStyle(.plain)
191

            
192
                NavigationLink(destination: DeviceHelpView()) {
193
                    sidebarLinkCard(
194
                        title: "Device",
195
                        subtitle: "Quick checks when a meter is not responding as expected.",
196
                        symbol: "questionmark.circle.fill",
197
                        tint: .orange
198
                    )
199
                }
200
                .buttonStyle(.plain)
Bogdan Timofte authored 2 weeks ago
201
            }
202
        }
Bogdan Timofte authored 2 weeks ago
203
        .animation(.easeInOut(duration: 0.22), value: helpIsExpanded)
Bogdan Timofte authored 2 weeks ago
204
    }
205

            
206
    private var devicesSection: some View {
207
        VStack(alignment: .leading, spacing: 12) {
208
            HStack {
Bogdan Timofte authored a week ago
209
                Text("Known Meters")
Bogdan Timofte authored 2 weeks ago
210
                    .font(.headline)
211
                Spacer()
Bogdan Timofte authored a week ago
212
                Text("\(appData.knownMeters.count)")
Bogdan Timofte authored 2 weeks ago
213
                    .font(.caption.weight(.bold))
214
                    .padding(.horizontal, 10)
215
                    .padding(.vertical, 6)
216
                    .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
217
            }
218

            
Bogdan Timofte authored a week ago
219
            if appData.knownMeters.isEmpty {
Bogdan Timofte authored 2 weeks ago
220
                Text(devicesEmptyStateText)
Bogdan Timofte authored 2 weeks ago
221
                    .font(.footnote)
222
                    .foregroundColor(.secondary)
223
                    .frame(maxWidth: .infinity, alignment: .leading)
224
                    .padding(18)
Bogdan Timofte authored 2 weeks ago
225
                    .meterCard(
226
                        tint: isWaitingForFirstDiscovery ? .blue : .secondary,
227
                        fillOpacity: 0.14,
228
                        strokeOpacity: 0.20
229
                    )
Bogdan Timofte authored 2 weeks ago
230
            } else {
Bogdan Timofte authored a week ago
231
                ForEach(appData.knownMeters) { knownMeter in
232
                    if let meter = knownMeter.meter {
233
                        NavigationLink(destination: MeterView().environmentObject(meter)) {
234
                            MeterRowView()
235
                                .environmentObject(meter)
236
                        }
237
                        .buttonStyle(.plain)
238
                    } else {
239
                        knownMeterCard(for: knownMeter)
Bogdan Timofte authored 2 weeks ago
240
                    }
241
                }
242
            }
243
        }
244
    }
245

            
Bogdan Timofte authored a week ago
246
    private var debugSection: some View {
247
        VStack(alignment: .leading, spacing: 12) {
248
            Text("Debug")
249
                .font(.headline)
250
            debugLink
251
        }
252
    }
253

            
Bogdan Timofte authored a week ago
254
    private var debugLink: some View {
255
        NavigationLink(destination: MeterMappingDebugView()) {
256
            sidebarLinkCard(
Bogdan Timofte authored a week ago
257
                title: "Meter Sync Debug",
258
                subtitle: "Inspect meter name sync data and iCloud KVS visibility as seen by this device.",
Bogdan Timofte authored a week ago
259
                symbol: "list.bullet.rectangle",
260
                tint: .purple
261
            )
262
        }
263
        .buttonStyle(.plain)
264
    }
265

            
Bogdan Timofte authored 2 weeks ago
266
    private var bluetoothStatusText: String {
267
        switch appData.bluetoothManager.managerState {
268
        case .poweredOff:
269
            return "Off"
270
        case .poweredOn:
271
            return "On"
272
        case .resetting:
273
            return "Resetting"
274
        case .unauthorized:
275
            return "Unauthorized"
276
        case .unknown:
277
            return "Unknown"
278
        case .unsupported:
279
            return "Unsupported"
280
        @unknown default:
281
            return "Other"
282
        }
283
    }
284

            
Bogdan Timofte authored 2 weeks ago
285
    private var helpIsExpanded: Bool {
286
        isHelpExpanded || shouldAutoExpandHelp
287
    }
288

            
289
    private var shouldAutoExpandHelp: Bool {
290
        guard let activeHelpAutoReason else {
291
            return false
292
        }
293
        return dismissedAutoHelpReason != activeHelpAutoReason
294
    }
295

            
296
    private var activeHelpAutoReason: HelpAutoReason? {
297
        if appData.bluetoothManager.managerState == .unauthorized {
298
            return .bluetoothPermission
299
        }
Bogdan Timofte authored a week ago
300
        if shouldPromptForCloudSync {
301
            return .cloudSyncUnavailable
302
        }
Bogdan Timofte authored 2 weeks ago
303
        if hasWaitedLongEnoughForDevices {
304
            return .noDevicesDetected
305
        }
306
        return nil
307
    }
308

            
Bogdan Timofte authored a week ago
309
    private var shouldPromptForCloudSync: Bool {
310
        switch appData.cloudAvailability {
311
        case .noAccount, .error:
312
            return true
313
        case .unknown, .available:
314
            return false
315
        }
316
    }
317

            
Bogdan Timofte authored 2 weeks ago
318
    private var hasWaitedLongEnoughForDevices: Bool {
319
        guard appData.bluetoothManager.managerState == .poweredOn else {
320
            return false
321
        }
322
        guard appData.meters.isEmpty else {
323
            return false
324
        }
325
        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
326
            return false
327
        }
328
        return now.timeIntervalSince(scanStartedAt) >= noDevicesHelpDelay
329
    }
330

            
331
    private var isWaitingForFirstDiscovery: Bool {
332
        guard appData.bluetoothManager.managerState == .poweredOn else {
333
            return false
334
        }
335
        guard appData.meters.isEmpty else {
336
            return false
337
        }
338
        guard let scanStartedAt = appData.bluetoothManager.scanStartedAt else {
339
            return false
340
        }
341
        return now.timeIntervalSince(scanStartedAt) < noDevicesHelpDelay
342
    }
343

            
344
    private var devicesEmptyStateText: String {
345
        if isWaitingForFirstDiscovery {
346
            return "Scanning for nearby supported meters..."
347
        }
Bogdan Timofte authored a week ago
348
        return "No known meters yet. Nearby supported meters will appear here and remain available after they disappear."
Bogdan Timofte authored 2 weeks ago
349
    }
350

            
351
    private var helpSectionTint: Color {
352
        activeHelpAutoReason?.tint ?? .secondary
353
    }
354

            
355
    private var helpSectionSymbol: String {
356
        activeHelpAutoReason?.symbol ?? "questionmark.circle.fill"
357
    }
358

            
359
    private var helpSectionSummary: String {
360
        switch activeHelpAutoReason {
361
        case .bluetoothPermission:
362
            return "Bluetooth permission is needed before scanning can begin."
Bogdan Timofte authored a week ago
363
        case .cloudSyncUnavailable:
364
            return appData.cloudAvailability.helpMessage
Bogdan Timofte authored 2 weeks ago
365
        case .noDevicesDetected:
366
            return "No supported devices were found after \(Int(noDevicesHelpDelay)) seconds."
367
        case nil:
368
            return "Connection tips and quick checks when discovery needs help."
369
        }
370
    }
371

            
372
    private func toggleHelpSection() {
373
        withAnimation(.easeInOut(duration: 0.22)) {
374
            if shouldAutoExpandHelp {
375
                dismissedAutoHelpReason = activeHelpAutoReason
376
                isHelpExpanded = false
377
            } else {
378
                isHelpExpanded.toggle()
379
            }
380
        }
381
    }
382

            
Bogdan Timofte authored a week ago
383
    private func openSettings() {
384
        guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
385
            return
386
        }
387
        UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil)
388
    }
389

            
Bogdan Timofte authored 2 weeks ago
390
    private func helpNoticeCard(for reason: HelpAutoReason) -> some View {
391
        VStack(alignment: .leading, spacing: 8) {
392
            Text(helpNoticeTitle(for: reason))
393
                .font(.subheadline.weight(.semibold))
394
            Text(helpNoticeDetail(for: reason))
395
                .font(.caption)
396
                .foregroundColor(.secondary)
397
        }
398
        .frame(maxWidth: .infinity, alignment: .leading)
399
        .padding(14)
400
        .meterCard(tint: reason.tint, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
401
    }
402

            
403
    private func helpNoticeTitle(for reason: HelpAutoReason) -> String {
404
        switch reason {
405
        case .bluetoothPermission:
406
            return "Bluetooth access needs attention"
Bogdan Timofte authored a week ago
407
        case .cloudSyncUnavailable:
408
            return appData.cloudAvailability.helpTitle
Bogdan Timofte authored 2 weeks ago
409
        case .noDevicesDetected:
410
            return "No supported meters found yet"
411
        }
412
    }
413

            
414
    private func helpNoticeDetail(for reason: HelpAutoReason) -> String {
415
        switch reason {
416
        case .bluetoothPermission:
417
            return "Open Bluetooth help to review the permission state and jump into Settings if access is blocked."
Bogdan Timofte authored a week ago
418
        case .cloudSyncUnavailable:
419
            return appData.cloudAvailability.helpMessage
Bogdan Timofte authored 2 weeks ago
420
        case .noDevicesDetected:
421
            return "Open Device help for quick checks like meter power, Bluetooth availability on the meter, or an existing connection to another phone."
422
        }
423
    }
424

            
Bogdan Timofte authored 2 weeks ago
425
    private func sidebarLinkCard(
426
        title: String,
427
        subtitle: String,
428
        symbol: String,
429
        tint: Color
430
    ) -> some View {
431
        HStack(spacing: 14) {
432
            Image(systemName: symbol)
433
                .font(.system(size: 18, weight: .semibold))
434
                .foregroundColor(tint)
435
                .frame(width: 42, height: 42)
436
                .background(Circle().fill(tint.opacity(0.18)))
437

            
438
            VStack(alignment: .leading, spacing: 4) {
439
                Text(title)
440
                    .font(.headline)
441
                Text(subtitle)
442
                    .font(.caption)
443
                    .foregroundColor(.secondary)
444
            }
445

            
446
            Spacer()
447

            
448
            Image(systemName: "chevron.right")
449
                .font(.footnote.weight(.bold))
450
                .foregroundColor(.secondary)
451
        }
452
        .padding(14)
453
        .meterCard(tint: tint, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 18)
454
    }
Bogdan Timofte authored a week ago
455

            
456
    private func knownMeterCard(for knownMeter: AppData.KnownMeterSummary) -> some View {
457
        HStack(spacing: 14) {
458
            Image(systemName: "sensor.tag.radiowaves.forward.fill")
459
                .font(.system(size: 18, weight: .semibold))
460
                .foregroundColor(knownMeterTint(for: knownMeter))
461
                .frame(width: 42, height: 42)
462
                .background(
463
                    Circle()
464
                        .fill(knownMeterTint(for: knownMeter).opacity(0.18))
465
                )
466
                .overlay(alignment: .bottomTrailing) {
467
                    Circle()
468
                        .fill(Color.red)
469
                        .frame(width: 12, height: 12)
470
                        .overlay(
471
                            Circle()
472
                                .stroke(Color(uiColor: .systemBackground), lineWidth: 2)
473
                        )
474
                }
475

            
476
            VStack(alignment: .leading, spacing: 4) {
477
                Text(knownMeter.displayName)
478
                    .font(.headline)
479
                Text(knownMeter.modelSummary)
480
                    .font(.caption)
481
                    .foregroundColor(.secondary)
482
                if let advertisedName = knownMeter.advertisedName, advertisedName != knownMeter.modelSummary {
483
                    Text("Advertised as \(advertisedName)")
484
                        .font(.caption2)
485
                        .foregroundColor(.secondary)
486
                }
487
            }
488

            
489
            Spacer()
490

            
491
            VStack(alignment: .trailing, spacing: 4) {
492
                HStack(spacing: 6) {
493
                    Circle()
494
                        .fill(Color.red)
495
                        .frame(width: 8, height: 8)
496
                    Text("Missing")
497
                        .font(.caption.weight(.semibold))
498
                        .foregroundColor(.secondary)
499
                }
500
                .padding(.horizontal, 10)
501
                .padding(.vertical, 6)
502
                .background(
503
                    Capsule(style: .continuous)
504
                        .fill(Color.red.opacity(0.12))
505
                )
506
                .overlay(
507
                    Capsule(style: .continuous)
508
                        .stroke(Color.red.opacity(0.22), lineWidth: 1)
509
                )
510
                Text(knownMeter.macAddress)
511
                    .font(.caption2)
512
                    .foregroundColor(.secondary)
513
                if let lastSeen = knownMeter.lastSeen {
514
                    Text("Seen \(lastSeen.format(as: "yyyy-MM-dd HH:mm"))")
515
                        .font(.caption2)
516
                        .foregroundColor(.secondary)
517
                }
518
            }
519
        }
520
        .padding(14)
521
        .meterCard(
522
            tint: knownMeterTint(for: knownMeter),
523
            fillOpacity: 0.16,
524
            strokeOpacity: 0.22,
525
            cornerRadius: 18
526
        )
527
    }
528

            
529
    private func knownMeterTint(for knownMeter: AppData.KnownMeterSummary) -> Color {
530
        switch knownMeter.modelSummary {
531
        case "UM25C":
532
            return .blue
533
        case "UM34C":
534
            return .yellow
535
        case "TC66C":
536
            return Model.TC66C.color
537
        default:
538
            return .secondary
539
        }
540
    }
Bogdan Timofte authored 2 weeks ago
541
}