Newer Older
980 lines | 36.117kb
Bogdan Timofte authored 2 weeks ago
1
//
2
//  MeterView.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 04/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8
// MARK: Parent frame https://stackoverflow.com/questions/56832865/how-to-access-parents-frame-in-swiftui
9

            
10
import SwiftUI
11
import CoreBluetooth
12

            
13
struct MeterView: View {
Bogdan Timofte authored 2 weeks ago
14
    private enum MeterTab: Hashable {
15
        case connection
16
        case live
17
        case chart
Bogdan Timofte authored a week ago
18
        case settings
Bogdan Timofte authored 2 weeks ago
19

            
20
        var title: String {
21
            switch self {
22
            case .connection: return "Home"
23
            case .live: return "Live"
24
            case .chart: return "Chart"
Bogdan Timofte authored a week ago
25
            case .settings: return "Settings"
Bogdan Timofte authored 2 weeks ago
26
            }
27
        }
28

            
29
        var systemImage: String {
30
            switch self {
31
            case .connection: return "house.fill"
32
            case .live: return "waveform.path.ecg"
33
            case .chart: return "chart.xyaxis.line"
Bogdan Timofte authored a week ago
34
            case .settings: return "gearshape.fill"
Bogdan Timofte authored 2 weeks ago
35
            }
36
        }
37
    }
Bogdan Timofte authored 2 weeks ago
38

            
39
    @EnvironmentObject private var meter: Meter
Bogdan Timofte authored 2 weeks ago
40
    @Environment(\.dismiss) private var dismiss
41

            
42
    private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac
Bogdan Timofte authored 2 weeks ago
43

            
44
    @State var dataGroupsViewVisibility: Bool = false
Bogdan Timofte authored 2 weeks ago
45
    @State var recordingViewVisibility: Bool = false
Bogdan Timofte authored 2 weeks ago
46
    @State var measurementsViewVisibility: Bool = false
Bogdan Timofte authored 2 weeks ago
47
    @State private var selectedMeterTab: MeterTab = .connection
Bogdan Timofte authored 2 weeks ago
48
    @State private var navBarTitle: String = "Meter"
49
    @State private var navBarShowRSSI: Bool = false
50
    @State private var navBarRSSI: Int = 0
Bogdan Timofte authored a week ago
51
    @State private var editingName = false
52
    @State private var editingScreenTimeout = false
53
    @State private var editingScreenBrightness = false
Bogdan Timofte authored 2 weeks ago
54
    private var myBounds: CGRect { UIScreen.main.bounds }
Bogdan Timofte authored 2 weeks ago
55
    private let actionStripPadding: CGFloat = 10
56
    private let actionDividerWidth: CGFloat = 1
Bogdan Timofte authored 2 weeks ago
57
    private let actionButtonMaxWidth: CGFloat = 156
58
    private let actionButtonMinWidth: CGFloat = 88
59
    private let actionButtonHeight: CGFloat = 108
Bogdan Timofte authored 2 weeks ago
60
    private let pageHorizontalPadding: CGFloat = 12
61
    private let pageVerticalPadding: CGFloat = 12
62
    private let contentCardPadding: CGFloat = 16
Bogdan Timofte authored 2 weeks ago
63

            
Bogdan Timofte authored 2 weeks ago
64
    var body: some View {
Bogdan Timofte authored 2 weeks ago
65
        GeometryReader { proxy in
66
            let landscape = isLandscape(size: proxy.size)
67

            
Bogdan Timofte authored 2 weeks ago
68
            VStack(spacing: 0) {
69
                if Self.isMacIPadApp {
70
                    macNavigationHeader
71
                }
72
                Group {
73
                    if landscape {
74
                        landscapeDeck(size: proxy.size)
75
                    } else {
76
                        portraitContent(size: proxy.size)
77
                    }
Bogdan Timofte authored 2 weeks ago
78
                }
Bogdan Timofte authored 2 weeks ago
79
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 weeks ago
80
            }
Bogdan Timofte authored 2 weeks ago
81
            #if !targetEnvironment(macCatalyst)
Bogdan Timofte authored 2 weeks ago
82
            .navigationBarHidden(Self.isMacIPadApp || landscape)
Bogdan Timofte authored 2 weeks ago
83
            #endif
Bogdan Timofte authored 2 weeks ago
84
        }
85
        .background(meterBackground)
Bogdan Timofte authored 2 weeks ago
86
        .modifier(IOSOnlyNavBar(
87
            apply: !Self.isMacIPadApp,
88
            title: navBarTitle,
89
            showRSSI: navBarShowRSSI,
90
            rssi: navBarRSSI,
91
            meter: meter
92
        ))
93
        .onAppear {
94
            navBarTitle = meter.name.isEmpty ? "Meter" : meter.name
95
            navBarShowRSSI = meter.operationalState > .notPresent
96
            navBarRSSI = meter.btSerial.averageRSSI
97
        }
98
        .onChange(of: meter.name) { name in
99
            navBarTitle = name.isEmpty ? "Meter" : name
100
        }
101
        .onChange(of: meter.operationalState) { state in
102
            navBarShowRSSI = state > .notPresent
103
        }
104
        .onChange(of: meter.btSerial.averageRSSI) { newRSSI in
105
            if abs(newRSSI - navBarRSSI) >= 5 {
106
                navBarRSSI = newRSSI
107
            }
108
        }
109
    }
110

            
111
    // MARK: - Custom navigation header for Designed-for-iPad on Mac
112

            
113
    private var macNavigationHeader: some View {
114
        HStack(spacing: 12) {
115
            Button {
116
                dismiss()
117
            } label: {
118
                HStack(spacing: 4) {
119
                    Image(systemName: "chevron.left")
120
                        .font(.body.weight(.semibold))
121
                    Text("USB Meters")
122
                }
123
                .foregroundColor(.accentColor)
124
            }
125
            .buttonStyle(.plain)
126

            
127
            Text(meter.name.isEmpty ? "Meter" : meter.name)
128
                .font(.headline)
129
                .lineLimit(1)
130

            
131
            Spacer()
132

            
Bogdan Timofte authored 2 weeks ago
133
            if meter.operationalState > .notPresent {
Bogdan Timofte authored 2 weeks ago
134
                RSSIView(RSSI: meter.btSerial.averageRSSI)
Bogdan Timofte authored 2 weeks ago
135
                    .frame(width: 18, height: 18)
136
            }
Bogdan Timofte authored 2 weeks ago
137

            
138
        }
139
        .padding(.horizontal, 16)
140
        .padding(.vertical, 10)
141
        .background(
142
            Rectangle()
143
                .fill(.ultraThinMaterial)
144
                .ignoresSafeArea(edges: .top)
145
        )
146
        .overlay(alignment: .bottom) {
147
            Rectangle()
148
                .fill(Color.secondary.opacity(0.12))
149
                .frame(height: 1)
150
        }
Bogdan Timofte authored 2 weeks ago
151
    }
152

            
Bogdan Timofte authored 2 weeks ago
153
    private func portraitContent(size: CGSize) -> some View {
154
        portraitSegmentedDeck(size: size)
155
    }
156

            
157
    private func landscapeDeck(size: CGSize) -> some View {
158
        landscapeSegmentedDeck(size: size)
159
    }
160

            
161
    private func landscapeSegmentedDeck(size: CGSize) -> some View {
162
        VStack(spacing: 0) {
163
            segmentedTabBar(horizontalPadding: 12)
164

            
165
            landscapeSegmentedContent(size: size)
166
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
167
                .id(selectedMeterTab)
168
                .transition(.opacity.combined(with: .move(edge: .trailing)))
169
        }
170
        .animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
171
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
172
        .onAppear {
173
            normalizeSelectedTab()
174
        }
175
        .onChange(of: availableMeterTabs) { _ in
176
            normalizeSelectedTab()
177
        }
178
    }
179

            
180
    private func portraitSegmentedDeck(size: CGSize) -> some View {
181
        VStack(spacing: 0) {
182
            segmentedTabBar(horizontalPadding: 16)
183

            
184
            portraitSegmentedContent(size: size)
185
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
186
                .id(selectedMeterTab)
187
                .transition(.opacity.combined(with: .move(edge: .trailing)))
188
        }
189
        .animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
190
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
191
        .onAppear {
192
            normalizeSelectedTab()
193
        }
194
        .onChange(of: availableMeterTabs) { _ in
195
            normalizeSelectedTab()
196
        }
197
    }
198

            
199
    private func segmentedTabBar(horizontalPadding: CGFloat) -> some View {
200
        HStack {
201
            Spacer(minLength: 0)
202

            
203
            HStack(spacing: 8) {
204
                ForEach(availableMeterTabs, id: \.self) { tab in
205
                    let isSelected = selectedMeterTab == tab
206

            
207
                    Button {
208
                        withAnimation(.easeInOut(duration: 0.2)) {
209
                            selectedMeterTab = tab
210
                        }
211
                    } label: {
212
                        HStack(spacing: 6) {
213
                            Image(systemName: tab.systemImage)
214
                                .font(.subheadline.weight(.semibold))
215
                            Text(tab.title)
216
                                .font(.subheadline.weight(.semibold))
217
                                .lineLimit(1)
218
                        }
219
                        .foregroundColor(isSelected ? .white : .primary)
220
                        .padding(.horizontal, 10)
221
                        .padding(.vertical, 7)
222
                        .frame(maxWidth: .infinity)
223
                        .background(
224
                            Capsule()
225
                                .fill(isSelected ? meter.color : Color.secondary.opacity(0.12))
226
                        )
Bogdan Timofte authored 2 weeks ago
227
                    }
Bogdan Timofte authored 2 weeks ago
228
                    .buttonStyle(.plain)
229
                    .accessibilityLabel(tab.title)
Bogdan Timofte authored 2 weeks ago
230
                }
231
            }
Bogdan Timofte authored 2 weeks ago
232
            .frame(maxWidth: 420)
233
            .padding(6)
234
            .background(
235
                RoundedRectangle(cornerRadius: 14, style: .continuous)
236
                    .fill(Color.secondary.opacity(0.10))
237
            )
238

            
239
            Spacer(minLength: 0)
240
        }
241
        .padding(.horizontal, horizontalPadding)
242
        .padding(.top, 10)
243
        .padding(.bottom, 8)
244
        .background(
245
            Rectangle()
246
                .fill(.ultraThinMaterial)
247
                .opacity(0.78)
248
                .ignoresSafeArea(edges: .top)
249
        )
250
        .overlay(alignment: .bottom) {
251
            Rectangle()
252
                .fill(Color.secondary.opacity(0.12))
253
                .frame(height: 1)
Bogdan Timofte authored 2 weeks ago
254
        }
Bogdan Timofte authored 2 weeks ago
255
    }
256

            
Bogdan Timofte authored 2 weeks ago
257
    @ViewBuilder
258
    private func landscapeSegmentedContent(size: CGSize) -> some View {
259
        switch selectedMeterTab {
260
        case .connection:
261
            landscapeConnectionPage
262
        case .live:
263
            if meter.operationalState == .dataIsAvailable {
264
                landscapeLivePage(size: size)
265
            } else {
266
                landscapeConnectionPage
Bogdan Timofte authored 2 weeks ago
267
            }
Bogdan Timofte authored 2 weeks ago
268
        case .chart:
269
            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
270
                landscapeChartPage(size: size)
271
            } else {
272
                landscapeConnectionPage
273
            }
Bogdan Timofte authored a week ago
274
        case .settings:
275
            landscapeSettingsPage(size: size)
Bogdan Timofte authored 2 weeks ago
276
        }
277
    }
Bogdan Timofte authored 2 weeks ago
278

            
Bogdan Timofte authored 2 weeks ago
279
    @ViewBuilder
280
    private func portraitSegmentedContent(size: CGSize) -> some View {
281
        switch selectedMeterTab {
282
        case .connection:
283
            portraitConnectionPage(size: size)
284
        case .live:
Bogdan Timofte authored 2 weeks ago
285
            if meter.operationalState == .dataIsAvailable {
Bogdan Timofte authored 2 weeks ago
286
                portraitLivePage(size: size)
287
            } else {
288
                portraitConnectionPage(size: size)
289
            }
290
        case .chart:
291
            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
292
                portraitChartPage
293
            } else {
294
                portraitConnectionPage(size: size)
295
            }
Bogdan Timofte authored a week ago
296
        case .settings:
297
            portraitSettingsPage(size: size)
Bogdan Timofte authored 2 weeks ago
298
        }
299
    }
Bogdan Timofte authored 2 weeks ago
300

            
Bogdan Timofte authored 2 weeks ago
301
    private func portraitConnectionPage(size: CGSize) -> some View {
302
        portraitFace {
303
            VStack(alignment: .leading, spacing: 12) {
304
                connectionCard(
305
                    compact: prefersCompactPortraitConnection(for: size),
306
                    showsActions: meter.operationalState == .dataIsAvailable
307
                )
308

            
309
                homeInfoPreview
310
            }
311
        }
312
    }
313

            
314
    private func portraitLivePage(size: CGSize) -> some View {
315
        portraitFace {
316
            LiveView(compactLayout: prefersCompactPortraitConnection(for: size), availableSize: size)
317
                .padding(contentCardPadding)
318
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
319
        }
320
    }
321

            
322
    private var portraitChartPage: some View {
323
        portraitFace {
324
            MeasurementChartView()
325
                .environmentObject(meter.measurements)
326
                .frame(minHeight: myBounds.height / 3.4)
327
                .padding(contentCardPadding)
328
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
329
        }
330
    }
331

            
332
    private var landscapeConnectionPage: some View {
333
        landscapeFace {
334
            VStack(alignment: .leading, spacing: 12) {
335
                connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
336

            
337
                homeInfoPreview
338
            }
339
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
340
        }
341
    }
342

            
343
    private var homeInfoPreview: some View {
Bogdan Timofte authored 2 weeks ago
344
        VStack(spacing: 14) {
Bogdan Timofte authored 2 weeks ago
345
            MeterInfoCard(title: "Overview", tint: meter.color) {
346
                MeterInfoRow(label: "Name", value: meter.name)
Bogdan Timofte authored 2 weeks ago
347
                MeterInfoRow(label: "Device Model", value: meter.deviceModelName)
348
                MeterInfoRow(label: "Advertised Model", value: meter.modelString)
349
                MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
350
                MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
Bogdan Timofte authored a week ago
351
                MeterInfoRow(label: "Last Seen", value: meterHistoryText(for: meter.lastSeen))
352
                MeterInfoRow(label: "Last Connected", value: meterHistoryText(for: meter.lastConnectedAt))
Bogdan Timofte authored 2 weeks ago
353
            }
Bogdan Timofte authored 2 weeks ago
354

            
355
            MeterInfoCard(title: "Identifiers", tint: .blue) {
356
                MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
Bogdan Timofte authored 2 weeks ago
357
                if meter.modelNumber != 0 {
358
                    MeterInfoRow(label: "Model Identifier", value: "\(meter.modelNumber)")
359
                }
360
            }
361

            
362
            MeterInfoCard(title: "Screen Reporting", tint: .orange) {
363
                if meter.reportsCurrentScreenIndex {
364
                    MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription)
365
                    Text("The active screen index is reported by the meter and mapped by the app to a known label.")
366
                        .font(.footnote)
367
                        .foregroundColor(.secondary)
368
                } else {
369
                    MeterInfoRow(label: "Current Screen", value: "Not Reported")
370
                    Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
371
                        .font(.footnote)
372
                        .foregroundColor(.secondary)
373
                }
374
            }
375

            
376
            MeterInfoCard(title: "Live Device Details", tint: .indigo) {
377
                if meter.operationalState == .dataIsAvailable {
378
                    if !meter.firmwareVersion.isEmpty {
379
                        MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
380
                    }
381
                    if meter.supportsChargerDetection {
382
                        MeterInfoRow(label: "Detected Charger", value: meter.chargerTypeDescription)
383
                    }
384
                    if meter.serialNumber != 0 {
385
                        MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
386
                    }
387
                    if meter.bootCount != 0 {
388
                        MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
389
                    }
390
                } else {
391
                    Text("Connect to the meter to load firmware, serial, and boot details.")
392
                        .font(.footnote)
393
                        .foregroundColor(.secondary)
394
                }
Bogdan Timofte authored 2 weeks ago
395
            }
396
        }
397
        .padding(.horizontal, pageHorizontalPadding)
398
    }
399

            
400
    private func landscapeLivePage(size: CGSize) -> some View {
401
        landscapeFace {
402
            LiveView(compactLayout: true, availableSize: size)
403
                .padding(contentCardPadding)
404
                .frame(maxWidth: .infinity, alignment: .topLeading)
405
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
406
        }
407
    }
408

            
409
    private func landscapeChartPage(size: CGSize) -> some View {
410
        landscapeFace {
411
            MeasurementChartView()
412
                .environmentObject(meter.measurements)
413
                .frame(height: max(250, size.height - 44))
414
                .padding(contentCardPadding)
415
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
416
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
417
        }
418
    }
419

            
Bogdan Timofte authored a week ago
420
    private func meterHistoryText(for date: Date?) -> String {
421
        guard let date else {
422
            return "Never"
423
        }
424
        return date.format(as: "yyyy-MM-dd HH:mm")
425
    }
426

            
Bogdan Timofte authored a week ago
427
    @ViewBuilder
428
    private func portraitSettingsPage(size: CGSize) -> some View {
429
        settingsTabContent
430
    }
431

            
432
    @ViewBuilder
433
    private func landscapeSettingsPage(size: CGSize) -> some View {
434
        settingsTabContent
435
    }
436

            
437
    private var settingsTabContent: some View {
438
        VStack(spacing: 0) {
439
            if Self.isMacIPadApp {
440
                settingsMacHeader
441
            }
442
            ScrollView {
443
                VStack(spacing: 14) {
444
                    settingsCard(title: "Name", tint: meter.color) {
445
                        HStack {
446
                            Spacer()
447
                            if !editingName {
448
                                Text(meter.name)
449
                                    .foregroundColor(.secondary)
450
                            }
451
                            ChevronView(rotate: $editingName)
452
                        }
453
                        if editingName {
454
                            EditNameView(editingName: $editingName, newName: meter.name)
455
                        }
456
                    }
457

            
458
                    if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
459
                        settingsCard(title: "Meter Temperature Unit", tint: .orange) {
460
                            Text("TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.")
461
                                .font(.footnote)
462
                                .foregroundColor(.secondary)
463
                            Picker("", selection: $meter.tc66TemperatureUnitPreference) {
464
                                ForEach(TemperatureUnitPreference.allCases) { unit in
465
                                    Text(unit.title).tag(unit)
466
                                }
467
                            }
468
                            .pickerStyle(SegmentedPickerStyle())
469
                        }
470
                    }
471

            
472
                    if meter.operationalState == .dataIsAvailable {
473
                        settingsCard(
474
                            title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls",
475
                            tint: .indigo
476
                        ) {
477
                            if meter.reportsCurrentScreenIndex {
478
                                Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
479
                                    .font(.footnote)
480
                                    .foregroundColor(.secondary)
481
                            } else {
482
                                Text("Use these controls when you want to switch device pages without crowding the main meter view.")
483
                                    .font(.footnote)
484
                                    .foregroundColor(.secondary)
485
                            }
486

            
487
                            ControlView(showsHeader: false)
488
                        }
489
                    }
490

            
491
                    if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
492
                        settingsCard(title: "Screen Timeout", tint: .purple) {
493
                            HStack {
494
                                Spacer()
495
                                if !editingScreenTimeout {
496
                                    Text(meter.screenTimeout > 0 ? "\(meter.screenTimeout) Minutes" : "Off")
497
                                        .foregroundColor(.secondary)
498
                                }
499
                                ChevronView(rotate: $editingScreenTimeout)
500
                            }
501
                            if editingScreenTimeout {
502
                                EditScreenTimeoutView()
503
                            }
504
                        }
505

            
506
                        settingsCard(title: "Screen Brightness", tint: .yellow) {
507
                            HStack {
508
                                Spacer()
509
                                if !editingScreenBrightness {
510
                                    Text("\(meter.screenBrightness)")
511
                                        .foregroundColor(.secondary)
512
                                }
513
                                ChevronView(rotate: $editingScreenBrightness)
514
                            }
515
                            if editingScreenBrightness {
516
                                EditScreenBrightnessView()
517
                            }
518
                        }
519
                    }
520
                }
521
                .padding()
522
            }
523
            .background(
524
                LinearGradient(
525
                    colors: [meter.color.opacity(0.14), Color.clear],
526
                    startPoint: .topLeading,
527
                    endPoint: .bottomTrailing
528
                )
529
                .ignoresSafeArea()
530
            )
531
        }
532
    }
533

            
534
    private var settingsMacHeader: some View {
535
        HStack(spacing: 12) {
536
            Button {
537
                selectedMeterTab = .connection
538
            } label: {
539
                HStack(spacing: 4) {
540
                    Image(systemName: "chevron.left")
541
                        .font(.body.weight(.semibold))
542
                    Text("Back")
543
                }
544
                .foregroundColor(.accentColor)
545
            }
546
            .buttonStyle(.plain)
547

            
548
            Text("Meter Settings")
549
                .font(.headline)
550
                .lineLimit(1)
551

            
552
            Spacer()
553

            
554
            if meter.operationalState > .notPresent {
555
                RSSIView(RSSI: meter.btSerial.averageRSSI)
556
                    .frame(width: 18, height: 18)
557
            }
558
        }
559
        .padding(.horizontal, 16)
560
        .padding(.vertical, 10)
561
        .background(
562
            Rectangle()
563
                .fill(.ultraThinMaterial)
564
                .ignoresSafeArea(edges: .top)
565
        )
566
        .overlay(alignment: .bottom) {
567
            Rectangle()
568
                .fill(Color.secondary.opacity(0.12))
569
                .frame(height: 1)
570
        }
571
    }
572

            
573
    private func settingsCard<Content: View>(
574
        title: String,
575
        tint: Color,
576
        @ViewBuilder content: () -> Content
577
    ) -> some View {
578
        VStack(alignment: .leading, spacing: 12) {
579
            Text(title)
580
                .font(.headline)
581
            content()
582
        }
583
        .padding(18)
584
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
585
    }
586

            
Bogdan Timofte authored 2 weeks ago
587
    private var availableMeterTabs: [MeterTab] {
588
        var tabs: [MeterTab] = [.connection]
589

            
590
        if meter.operationalState == .dataIsAvailable {
591
            tabs.append(.live)
592

            
593
            if meter.measurements.power.context.isValid {
594
                tabs.append(.chart)
595
            }
596
        }
597

            
Bogdan Timofte authored a week ago
598
        tabs.append(.settings)
599

            
Bogdan Timofte authored 2 weeks ago
600
        return tabs
601
    }
602

            
603
    private func normalizeSelectedTab() {
604
        guard availableMeterTabs.contains(selectedMeterTab) else {
605
            withAnimation(.easeInOut(duration: 0.22)) {
606
                selectedMeterTab = .connection
607
            }
608
            return
609
        }
610
    }
611

            
612
    private func prefersCompactPortraitConnection(for size: CGSize) -> Bool {
613
        size.height < 760 || size.width < 380
614
    }
615

            
616
    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
617
        ScrollView {
618
            content()
619
                .frame(maxWidth: .infinity, alignment: .topLeading)
620
                .padding(.horizontal, pageHorizontalPadding)
621
                .padding(.vertical, pageVerticalPadding)
Bogdan Timofte authored 2 weeks ago
622
        }
623
    }
624

            
625
    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
626
        content()
627
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
628
        .padding(.horizontal, pageHorizontalPadding)
629
        .padding(.vertical, pageVerticalPadding)
Bogdan Timofte authored 2 weeks ago
630
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
631
    }
Bogdan Timofte authored 2 weeks ago
632

            
Bogdan Timofte authored 2 weeks ago
633
    private var meterBackground: some View {
634
        LinearGradient(
635
            colors: [
636
                meter.color.opacity(0.22),
637
                Color.secondary.opacity(0.08),
638
                Color.clear
639
            ],
640
            startPoint: .topLeading,
641
            endPoint: .bottomTrailing
642
        )
643
        .ignoresSafeArea()
644
    }
645

            
646
    private func isLandscape(size: CGSize) -> Bool {
647
        size.width > size.height
648
    }
649

            
Bogdan Timofte authored 2 weeks ago
650
    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
651
        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
Bogdan Timofte authored 2 weeks ago
652
            HStack(alignment: .top) {
Bogdan Timofte authored 2 weeks ago
653
                meterIdentity(compact: compact)
Bogdan Timofte authored 2 weeks ago
654
                Spacer()
Bogdan Timofte authored 2 weeks ago
655
                statusBadge
Bogdan Timofte authored 2 weeks ago
656
            }
Bogdan Timofte authored 2 weeks ago
657

            
Bogdan Timofte authored 2 weeks ago
658
            if compact {
659
                Spacer(minLength: 0)
660
            }
661

            
662
            connectionActionArea(compact: compact)
Bogdan Timofte authored 2 weeks ago
663

            
664
            if showsActions {
665
                VStack(spacing: compact ? 10 : 12) {
666
                    Rectangle()
667
                        .fill(Color.secondary.opacity(0.12))
668
                        .frame(height: 1)
669

            
670
                    actionGrid(compact: compact, embedded: true)
671
                }
672
            }
Bogdan Timofte authored 2 weeks ago
673
        }
Bogdan Timofte authored 2 weeks ago
674
        .padding(compact ? 16 : 20)
675
        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
676
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
677
    }
678

            
Bogdan Timofte authored 2 weeks ago
679
    private func meterIdentity(compact: Bool) -> some View {
680
        HStack(alignment: .firstTextBaseline, spacing: 8) {
681
            Text(meter.name)
682
                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
683
                .lineLimit(1)
684
                .minimumScaleFactor(0.8)
685

            
686
            Text(meter.deviceModelName)
687
                .font((compact ? Font.caption : .subheadline).weight(.semibold))
688
                .foregroundColor(.secondary)
689
                .lineLimit(1)
690
                .minimumScaleFactor(0.8)
691
        }
692
    }
693

            
Bogdan Timofte authored 2 weeks ago
694
    private func actionGrid(compact: Bool = false, embedded: Bool = false) -> some View {
695
        let currentActionHeight = compact ? CGFloat(86) : actionButtonHeight
696

            
697
        return GeometryReader { proxy in
Bogdan Timofte authored 2 weeks ago
698
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
Bogdan Timofte authored 2 weeks ago
699
            let stripWidth = actionStripWidth(for: buttonWidth)
Bogdan Timofte authored 2 weeks ago
700
            let stripContent = HStack(spacing: 0) {
701
                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
702
                    dataGroupsViewVisibility.toggle()
703
                }
704
                .sheet(isPresented: $dataGroupsViewVisibility) {
705
                    DataGroupsView(visibility: $dataGroupsViewVisibility)
706
                        .environmentObject(meter)
707
                }
Bogdan Timofte authored 2 weeks ago
708

            
Bogdan Timofte authored 2 weeks ago
709
                if meter.supportsRecordingView {
710
                    actionStripDivider(height: currentActionHeight)
711
                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
712
                        recordingViewVisibility.toggle()
Bogdan Timofte authored 2 weeks ago
713
                    }
Bogdan Timofte authored 2 weeks ago
714
                    .sheet(isPresented: $recordingViewVisibility) {
715
                        RecordingView(visibility: $recordingViewVisibility)
Bogdan Timofte authored 2 weeks ago
716
                            .environmentObject(meter)
717
                    }
Bogdan Timofte authored 2 weeks ago
718
                }
Bogdan Timofte authored 2 weeks ago
719

            
Bogdan Timofte authored 2 weeks ago
720
                actionStripDivider(height: currentActionHeight)
721
                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
722
                    measurementsViewVisibility.toggle()
723
                }
724
                .sheet(isPresented: $measurementsViewVisibility) {
725
                    MeasurementsView(visibility: $measurementsViewVisibility)
726
                        .environmentObject(meter.measurements)
727
                }
728
            }
729
            .padding(actionStripPadding)
730
            .frame(width: stripWidth)
Bogdan Timofte authored 2 weeks ago
731

            
Bogdan Timofte authored 2 weeks ago
732
            HStack {
733
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
734
                stripContent
735
                    .meterCard(
736
                        tint: embedded ? meter.color : Color.secondary,
737
                        fillOpacity: embedded ? 0.08 : 0.10,
738
                        strokeOpacity: embedded ? 0.14 : 0.16,
739
                        cornerRadius: embedded ? 24 : 22
740
                    )
Bogdan Timofte authored 2 weeks ago
741
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
742
            }
743
        }
Bogdan Timofte authored 2 weeks ago
744
        .frame(height: currentActionHeight + (actionStripPadding * 2))
745
    }
746

            
747
    private func connectionActionArea(compact: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
748
        let connected = meter.operationalState >= .peripheralConnectionPending
Bogdan Timofte authored 2 weeks ago
749
        let tint = connected ? disconnectActionTint : connectActionTint
Bogdan Timofte authored 2 weeks ago
750

            
Bogdan Timofte authored 2 weeks ago
751
        return Group {
752
            if meter.operationalState == .notPresent {
Bogdan Timofte authored 2 weeks ago
753
                HStack(spacing: 10) {
754
                    Image(systemName: "exclamationmark.triangle.fill")
755
                        .foregroundColor(.orange)
756
                    Text("Not found at this time.")
757
                        .fontWeight(.semibold)
758
                    Spacer()
759
                }
Bogdan Timofte authored 2 weeks ago
760
                .padding(compact ? 12 : 16)
Bogdan Timofte authored 2 weeks ago
761
                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
Bogdan Timofte authored 2 weeks ago
762
            } else {
Bogdan Timofte authored 2 weeks ago
763
                Button(action: {
Bogdan Timofte authored 2 weeks ago
764
                    if meter.operationalState < .peripheralConnectionPending {
Bogdan Timofte authored 2 weeks ago
765
                        meter.connect()
Bogdan Timofte authored 2 weeks ago
766
                    } else {
Bogdan Timofte authored 2 weeks ago
767
                        meter.disconnect()
768
                    }
769
                }) {
Bogdan Timofte authored 2 weeks ago
770
                    HStack(spacing: 12) {
Bogdan Timofte authored 2 weeks ago
771
                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
Bogdan Timofte authored 2 weeks ago
772
                            .foregroundColor(tint)
773
                            .frame(width: 30, height: 30)
774
                            .background(Circle().fill(tint.opacity(0.12)))
Bogdan Timofte authored 2 weeks ago
775
                        Text(connected ? "Disconnect" : "Connect")
776
                            .fontWeight(.semibold)
Bogdan Timofte authored 2 weeks ago
777
                            .foregroundColor(.primary)
Bogdan Timofte authored 2 weeks ago
778
                        Spacer()
Bogdan Timofte authored 2 weeks ago
779
                    }
Bogdan Timofte authored 2 weeks ago
780
                    .padding(.horizontal, 18)
Bogdan Timofte authored 2 weeks ago
781
                    .padding(.vertical, compact ? 10 : 14)
Bogdan Timofte authored 2 weeks ago
782
                    .frame(maxWidth: .infinity)
Bogdan Timofte authored 2 weeks ago
783
                    .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored 2 weeks ago
784
                }
Bogdan Timofte authored 2 weeks ago
785
                .buttonStyle(.plain)
Bogdan Timofte authored 2 weeks ago
786
            }
787
        }
788
    }
Bogdan Timofte authored 2 weeks ago
789

            
Bogdan Timofte authored 2 weeks ago
790
    fileprivate func meterSheetButton(icon: String, title: String, tint: Color, width: CGFloat, height: CGFloat, compact: Bool = false, action: @escaping () -> Void) -> some View {
Bogdan Timofte authored 2 weeks ago
791
        Button(action: action) {
Bogdan Timofte authored 2 weeks ago
792
            VStack(spacing: compact ? 8 : 10) {
Bogdan Timofte authored 2 weeks ago
793
                Image(systemName: icon)
Bogdan Timofte authored 2 weeks ago
794
                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
795
                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
Bogdan Timofte authored 2 weeks ago
796
                    .background(Circle().fill(tint.opacity(0.14)))
Bogdan Timofte authored 2 weeks ago
797
                Text(title)
Bogdan Timofte authored 2 weeks ago
798
                    .font((compact ? Font.caption : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
799
                    .multilineTextAlignment(.center)
800
                    .lineLimit(2)
801
                    .minimumScaleFactor(0.9)
Bogdan Timofte authored 2 weeks ago
802
            }
Bogdan Timofte authored 2 weeks ago
803
            .foregroundColor(tint)
Bogdan Timofte authored 2 weeks ago
804
            .frame(width: width, height: height)
Bogdan Timofte authored 2 weeks ago
805
            .contentShape(Rectangle())
Bogdan Timofte authored 2 weeks ago
806
        }
807
        .buttonStyle(.plain)
808
    }
809

            
Bogdan Timofte authored 2 weeks ago
810
    private var visibleActionButtonCount: CGFloat {
811
        meter.supportsRecordingView ? 3 : 2
812
    }
813

            
814
    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
Bogdan Timofte authored 2 weeks ago
815
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
816
        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
817
        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
Bogdan Timofte authored 2 weeks ago
818
        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
819
    }
820

            
Bogdan Timofte authored 2 weeks ago
821
    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
822
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
823
        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
824
    }
825

            
Bogdan Timofte authored 2 weeks ago
826
    private func actionStripDivider(height: CGFloat) -> some View {
Bogdan Timofte authored 2 weeks ago
827
        Rectangle()
828
            .fill(Color.secondary.opacity(0.16))
Bogdan Timofte authored 2 weeks ago
829
            .frame(width: actionDividerWidth, height: max(44, height - 22))
830
    }
831

            
832
    private var statusBadge: some View {
833
        Text(statusText)
834
            .font(.caption.weight(.bold))
835
            .padding(.horizontal, 12)
836
            .padding(.vertical, 6)
837
            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
Bogdan Timofte authored 2 weeks ago
838
    }
839

            
840
    private var connectActionTint: Color {
841
        Color(red: 0.20, green: 0.46, blue: 0.43)
842
    }
843

            
844
    private var disconnectActionTint: Color {
845
        Color(red: 0.66, green: 0.39, blue: 0.35)
846
    }
847

            
Bogdan Timofte authored 2 weeks ago
848
    private var statusText: String {
849
        switch meter.operationalState {
850
        case .notPresent:
851
            return "Missing"
852
        case .peripheralNotConnected:
853
            return "Ready"
854
        case .peripheralConnectionPending:
855
            return "Connecting"
856
        case .peripheralConnected:
857
            return "Linked"
858
        case .peripheralReady:
859
            return "Preparing"
860
        case .comunicating:
861
            return "Syncing"
862
        case .dataIsAvailable:
863
            return "Live"
864
        }
865
    }
866

            
867
    private var statusColor: Color {
868
        Meter.operationalColor(for: meter.operationalState)
Bogdan Timofte authored 2 weeks ago
869
    }
Bogdan Timofte authored 2 weeks ago
870
}
Bogdan Timofte authored 2 weeks ago
871

            
872

            
873
private struct MeterInfoCard<Content: View>: View {
874
    let title: String
875
    let tint: Color
876
    @ViewBuilder var content: Content
877

            
878
    var body: some View {
879
        VStack(alignment: .leading, spacing: 12) {
880
            Text(title)
881
                .font(.headline)
882
            content
883
        }
884
        .frame(maxWidth: .infinity, alignment: .leading)
885
        .padding(18)
886
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
887
    }
888
}
889

            
890
private struct MeterInfoRow: View {
891
    let label: String
892
    let value: String
893

            
894
    var body: some View {
895
        HStack {
896
            Text(label)
897
            Spacer()
898
            Text(value)
899
                .foregroundColor(.secondary)
900
                .multilineTextAlignment(.trailing)
901
        }
902
        .font(.footnote)
903
    }
904
}
Bogdan Timofte authored 2 weeks ago
905

            
Bogdan Timofte authored a week ago
906
private struct EditNameView: View {
907

            
908
    @EnvironmentObject private var meter: Meter
909

            
910
    @Binding var editingName: Bool
911
    @State var newName: String
912

            
913
    var body: some View {
914
        TextField("Name", text: self.$newName, onCommit: {
915
            self.meter.name = self.newName
916
            self.editingName = false
917
        })
918
            .textFieldStyle(RoundedBorderTextFieldStyle())
919
            .lineLimit(1)
920
            .disableAutocorrection(true)
921
            .multilineTextAlignment(.center)
922
    }
923
}
924

            
925
private struct EditScreenTimeoutView: View {
926

            
927
    @EnvironmentObject private var meter: Meter
928

            
929
    var body: some View {
930
        Picker("", selection: self.$meter.screenTimeout ) {
931
            ForEach(1...9, id: \.self) { value in
932
                Text("\(value)").tag(value)
933
            }
934
            Text("Off").tag(0)
935
        }
936
        .pickerStyle(SegmentedPickerStyle())
937
    }
938
}
939

            
940
private struct EditScreenBrightnessView: View {
941

            
942
    @EnvironmentObject private var meter: Meter
943

            
944
    var body: some View {
945
        Picker("", selection: self.$meter.screenBrightness ) {
946
            ForEach(0...5, id: \.self) { value in
947
                Text("\(value)").tag(value)
948
            }
949
        }
950
        .pickerStyle(SegmentedPickerStyle())
951
    }
952
}
953

            
Bogdan Timofte authored 2 weeks ago
954
// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac)
955

            
956
private struct IOSOnlyNavBar: ViewModifier {
957
    let apply: Bool
958
    let title: String
959
    let showRSSI: Bool
960
    let rssi: Int
961
    let meter: Meter
962

            
963
    @ViewBuilder
964
    func body(content: Content) -> some View {
965
        if apply {
966
            content
967
                .navigationBarTitle(title)
968
                .toolbar {
969
                    ToolbarItemGroup(placement: .navigationBarTrailing) {
970
                        if showRSSI {
971
                            RSSIView(RSSI: rssi)
972
                                .frame(width: 18, height: 18)
973
                        }
974
                    }
975
                }
976
        } else {
977
            content
978
        }
979
    }
980
}