Newer Older
971 lines | 35.727kb
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 2 weeks ago
351
            }
Bogdan Timofte authored 2 weeks ago
352

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

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

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

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

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

            
Bogdan Timofte authored a week ago
418
    @ViewBuilder
419
    private func portraitSettingsPage(size: CGSize) -> some View {
420
        settingsTabContent
421
    }
422

            
423
    @ViewBuilder
424
    private func landscapeSettingsPage(size: CGSize) -> some View {
425
        settingsTabContent
426
    }
427

            
428
    private var settingsTabContent: some View {
429
        VStack(spacing: 0) {
430
            if Self.isMacIPadApp {
431
                settingsMacHeader
432
            }
433
            ScrollView {
434
                VStack(spacing: 14) {
435
                    settingsCard(title: "Name", tint: meter.color) {
436
                        HStack {
437
                            Spacer()
438
                            if !editingName {
439
                                Text(meter.name)
440
                                    .foregroundColor(.secondary)
441
                            }
442
                            ChevronView(rotate: $editingName)
443
                        }
444
                        if editingName {
445
                            EditNameView(editingName: $editingName, newName: meter.name)
446
                        }
447
                    }
448

            
449
                    if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
450
                        settingsCard(title: "Meter Temperature Unit", tint: .orange) {
451
                            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.")
452
                                .font(.footnote)
453
                                .foregroundColor(.secondary)
454
                            Picker("", selection: $meter.tc66TemperatureUnitPreference) {
455
                                ForEach(TemperatureUnitPreference.allCases) { unit in
456
                                    Text(unit.title).tag(unit)
457
                                }
458
                            }
459
                            .pickerStyle(SegmentedPickerStyle())
460
                        }
461
                    }
462

            
463
                    if meter.operationalState == .dataIsAvailable {
464
                        settingsCard(
465
                            title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls",
466
                            tint: .indigo
467
                        ) {
468
                            if meter.reportsCurrentScreenIndex {
469
                                Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
470
                                    .font(.footnote)
471
                                    .foregroundColor(.secondary)
472
                            } else {
473
                                Text("Use these controls when you want to switch device pages without crowding the main meter view.")
474
                                    .font(.footnote)
475
                                    .foregroundColor(.secondary)
476
                            }
477

            
478
                            ControlView(showsHeader: false)
479
                        }
480
                    }
481

            
482
                    if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
483
                        settingsCard(title: "Screen Timeout", tint: .purple) {
484
                            HStack {
485
                                Spacer()
486
                                if !editingScreenTimeout {
487
                                    Text(meter.screenTimeout > 0 ? "\(meter.screenTimeout) Minutes" : "Off")
488
                                        .foregroundColor(.secondary)
489
                                }
490
                                ChevronView(rotate: $editingScreenTimeout)
491
                            }
492
                            if editingScreenTimeout {
493
                                EditScreenTimeoutView()
494
                            }
495
                        }
496

            
497
                        settingsCard(title: "Screen Brightness", tint: .yellow) {
498
                            HStack {
499
                                Spacer()
500
                                if !editingScreenBrightness {
501
                                    Text("\(meter.screenBrightness)")
502
                                        .foregroundColor(.secondary)
503
                                }
504
                                ChevronView(rotate: $editingScreenBrightness)
505
                            }
506
                            if editingScreenBrightness {
507
                                EditScreenBrightnessView()
508
                            }
509
                        }
510
                    }
511
                }
512
                .padding()
513
            }
514
            .background(
515
                LinearGradient(
516
                    colors: [meter.color.opacity(0.14), Color.clear],
517
                    startPoint: .topLeading,
518
                    endPoint: .bottomTrailing
519
                )
520
                .ignoresSafeArea()
521
            )
522
        }
523
    }
524

            
525
    private var settingsMacHeader: some View {
526
        HStack(spacing: 12) {
527
            Button {
528
                selectedMeterTab = .connection
529
            } label: {
530
                HStack(spacing: 4) {
531
                    Image(systemName: "chevron.left")
532
                        .font(.body.weight(.semibold))
533
                    Text("Back")
534
                }
535
                .foregroundColor(.accentColor)
536
            }
537
            .buttonStyle(.plain)
538

            
539
            Text("Meter Settings")
540
                .font(.headline)
541
                .lineLimit(1)
542

            
543
            Spacer()
544

            
545
            if meter.operationalState > .notPresent {
546
                RSSIView(RSSI: meter.btSerial.averageRSSI)
547
                    .frame(width: 18, height: 18)
548
            }
549
        }
550
        .padding(.horizontal, 16)
551
        .padding(.vertical, 10)
552
        .background(
553
            Rectangle()
554
                .fill(.ultraThinMaterial)
555
                .ignoresSafeArea(edges: .top)
556
        )
557
        .overlay(alignment: .bottom) {
558
            Rectangle()
559
                .fill(Color.secondary.opacity(0.12))
560
                .frame(height: 1)
561
        }
562
    }
563

            
564
    private func settingsCard<Content: View>(
565
        title: String,
566
        tint: Color,
567
        @ViewBuilder content: () -> Content
568
    ) -> some View {
569
        VStack(alignment: .leading, spacing: 12) {
570
            Text(title)
571
                .font(.headline)
572
            content()
573
        }
574
        .padding(18)
575
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
576
    }
577

            
Bogdan Timofte authored 2 weeks ago
578
    private var availableMeterTabs: [MeterTab] {
579
        var tabs: [MeterTab] = [.connection]
580

            
581
        if meter.operationalState == .dataIsAvailable {
582
            tabs.append(.live)
583

            
584
            if meter.measurements.power.context.isValid {
585
                tabs.append(.chart)
586
            }
587
        }
588

            
Bogdan Timofte authored a week ago
589
        tabs.append(.settings)
590

            
Bogdan Timofte authored 2 weeks ago
591
        return tabs
592
    }
593

            
594
    private func normalizeSelectedTab() {
595
        guard availableMeterTabs.contains(selectedMeterTab) else {
596
            withAnimation(.easeInOut(duration: 0.22)) {
597
                selectedMeterTab = .connection
598
            }
599
            return
600
        }
601
    }
602

            
603
    private func prefersCompactPortraitConnection(for size: CGSize) -> Bool {
604
        size.height < 760 || size.width < 380
605
    }
606

            
607
    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
608
        ScrollView {
609
            content()
610
                .frame(maxWidth: .infinity, alignment: .topLeading)
611
                .padding(.horizontal, pageHorizontalPadding)
612
                .padding(.vertical, pageVerticalPadding)
Bogdan Timofte authored 2 weeks ago
613
        }
614
    }
615

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

            
Bogdan Timofte authored 2 weeks ago
624
    private var meterBackground: some View {
625
        LinearGradient(
626
            colors: [
627
                meter.color.opacity(0.22),
628
                Color.secondary.opacity(0.08),
629
                Color.clear
630
            ],
631
            startPoint: .topLeading,
632
            endPoint: .bottomTrailing
633
        )
634
        .ignoresSafeArea()
635
    }
636

            
637
    private func isLandscape(size: CGSize) -> Bool {
638
        size.width > size.height
639
    }
640

            
Bogdan Timofte authored 2 weeks ago
641
    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
642
        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
Bogdan Timofte authored 2 weeks ago
643
            HStack(alignment: .top) {
Bogdan Timofte authored 2 weeks ago
644
                meterIdentity(compact: compact)
Bogdan Timofte authored 2 weeks ago
645
                Spacer()
Bogdan Timofte authored 2 weeks ago
646
                statusBadge
Bogdan Timofte authored 2 weeks ago
647
            }
Bogdan Timofte authored 2 weeks ago
648

            
Bogdan Timofte authored 2 weeks ago
649
            if compact {
650
                Spacer(minLength: 0)
651
            }
652

            
653
            connectionActionArea(compact: compact)
Bogdan Timofte authored 2 weeks ago
654

            
655
            if showsActions {
656
                VStack(spacing: compact ? 10 : 12) {
657
                    Rectangle()
658
                        .fill(Color.secondary.opacity(0.12))
659
                        .frame(height: 1)
660

            
661
                    actionGrid(compact: compact, embedded: true)
662
                }
663
            }
Bogdan Timofte authored 2 weeks ago
664
        }
Bogdan Timofte authored 2 weeks ago
665
        .padding(compact ? 16 : 20)
666
        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
667
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
668
    }
669

            
Bogdan Timofte authored 2 weeks ago
670
    private func meterIdentity(compact: Bool) -> some View {
671
        HStack(alignment: .firstTextBaseline, spacing: 8) {
672
            Text(meter.name)
673
                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
674
                .lineLimit(1)
675
                .minimumScaleFactor(0.8)
676

            
677
            Text(meter.deviceModelName)
678
                .font((compact ? Font.caption : .subheadline).weight(.semibold))
679
                .foregroundColor(.secondary)
680
                .lineLimit(1)
681
                .minimumScaleFactor(0.8)
682
        }
683
    }
684

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

            
688
        return GeometryReader { proxy in
Bogdan Timofte authored 2 weeks ago
689
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
Bogdan Timofte authored 2 weeks ago
690
            let stripWidth = actionStripWidth(for: buttonWidth)
Bogdan Timofte authored 2 weeks ago
691
            let stripContent = HStack(spacing: 0) {
692
                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
693
                    dataGroupsViewVisibility.toggle()
694
                }
695
                .sheet(isPresented: $dataGroupsViewVisibility) {
696
                    DataGroupsView(visibility: $dataGroupsViewVisibility)
697
                        .environmentObject(meter)
698
                }
Bogdan Timofte authored 2 weeks ago
699

            
Bogdan Timofte authored 2 weeks ago
700
                if meter.supportsRecordingView {
701
                    actionStripDivider(height: currentActionHeight)
702
                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
703
                        recordingViewVisibility.toggle()
Bogdan Timofte authored 2 weeks ago
704
                    }
Bogdan Timofte authored 2 weeks ago
705
                    .sheet(isPresented: $recordingViewVisibility) {
706
                        RecordingView(visibility: $recordingViewVisibility)
Bogdan Timofte authored 2 weeks ago
707
                            .environmentObject(meter)
708
                    }
Bogdan Timofte authored 2 weeks ago
709
                }
Bogdan Timofte authored 2 weeks ago
710

            
Bogdan Timofte authored 2 weeks ago
711
                actionStripDivider(height: currentActionHeight)
712
                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
713
                    measurementsViewVisibility.toggle()
714
                }
715
                .sheet(isPresented: $measurementsViewVisibility) {
716
                    MeasurementsView(visibility: $measurementsViewVisibility)
717
                        .environmentObject(meter.measurements)
718
                }
719
            }
720
            .padding(actionStripPadding)
721
            .frame(width: stripWidth)
Bogdan Timofte authored 2 weeks ago
722

            
Bogdan Timofte authored 2 weeks ago
723
            HStack {
724
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
725
                stripContent
726
                    .meterCard(
727
                        tint: embedded ? meter.color : Color.secondary,
728
                        fillOpacity: embedded ? 0.08 : 0.10,
729
                        strokeOpacity: embedded ? 0.14 : 0.16,
730
                        cornerRadius: embedded ? 24 : 22
731
                    )
Bogdan Timofte authored 2 weeks ago
732
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
733
            }
734
        }
Bogdan Timofte authored 2 weeks ago
735
        .frame(height: currentActionHeight + (actionStripPadding * 2))
736
    }
737

            
738
    private func connectionActionArea(compact: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
739
        let connected = meter.operationalState >= .peripheralConnectionPending
Bogdan Timofte authored 2 weeks ago
740
        let tint = connected ? disconnectActionTint : connectActionTint
Bogdan Timofte authored 2 weeks ago
741

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

            
Bogdan Timofte authored 2 weeks ago
781
    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
782
        Button(action: action) {
Bogdan Timofte authored 2 weeks ago
783
            VStack(spacing: compact ? 8 : 10) {
Bogdan Timofte authored 2 weeks ago
784
                Image(systemName: icon)
Bogdan Timofte authored 2 weeks ago
785
                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
786
                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
Bogdan Timofte authored 2 weeks ago
787
                    .background(Circle().fill(tint.opacity(0.14)))
Bogdan Timofte authored 2 weeks ago
788
                Text(title)
Bogdan Timofte authored 2 weeks ago
789
                    .font((compact ? Font.caption : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
790
                    .multilineTextAlignment(.center)
791
                    .lineLimit(2)
792
                    .minimumScaleFactor(0.9)
Bogdan Timofte authored 2 weeks ago
793
            }
Bogdan Timofte authored 2 weeks ago
794
            .foregroundColor(tint)
Bogdan Timofte authored 2 weeks ago
795
            .frame(width: width, height: height)
Bogdan Timofte authored 2 weeks ago
796
            .contentShape(Rectangle())
Bogdan Timofte authored 2 weeks ago
797
        }
798
        .buttonStyle(.plain)
799
    }
800

            
Bogdan Timofte authored 2 weeks ago
801
    private var visibleActionButtonCount: CGFloat {
802
        meter.supportsRecordingView ? 3 : 2
803
    }
804

            
805
    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
Bogdan Timofte authored 2 weeks ago
806
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
807
        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
808
        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
Bogdan Timofte authored 2 weeks ago
809
        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
810
    }
811

            
Bogdan Timofte authored 2 weeks ago
812
    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
813
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
814
        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
815
    }
816

            
Bogdan Timofte authored 2 weeks ago
817
    private func actionStripDivider(height: CGFloat) -> some View {
Bogdan Timofte authored 2 weeks ago
818
        Rectangle()
819
            .fill(Color.secondary.opacity(0.16))
Bogdan Timofte authored 2 weeks ago
820
            .frame(width: actionDividerWidth, height: max(44, height - 22))
821
    }
822

            
823
    private var statusBadge: some View {
824
        Text(statusText)
825
            .font(.caption.weight(.bold))
826
            .padding(.horizontal, 12)
827
            .padding(.vertical, 6)
828
            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
Bogdan Timofte authored 2 weeks ago
829
    }
830

            
831
    private var connectActionTint: Color {
832
        Color(red: 0.20, green: 0.46, blue: 0.43)
833
    }
834

            
835
    private var disconnectActionTint: Color {
836
        Color(red: 0.66, green: 0.39, blue: 0.35)
837
    }
838

            
Bogdan Timofte authored 2 weeks ago
839
    private var statusText: String {
840
        switch meter.operationalState {
841
        case .notPresent:
842
            return "Missing"
843
        case .peripheralNotConnected:
844
            return "Ready"
845
        case .peripheralConnectionPending:
846
            return "Connecting"
847
        case .peripheralConnected:
848
            return "Linked"
849
        case .peripheralReady:
850
            return "Preparing"
851
        case .comunicating:
852
            return "Syncing"
853
        case .dataIsAvailable:
854
            return "Live"
855
        }
856
    }
857

            
858
    private var statusColor: Color {
859
        Meter.operationalColor(for: meter.operationalState)
Bogdan Timofte authored 2 weeks ago
860
    }
Bogdan Timofte authored 2 weeks ago
861
}
Bogdan Timofte authored 2 weeks ago
862

            
863

            
864
private struct MeterInfoCard<Content: View>: View {
865
    let title: String
866
    let tint: Color
867
    @ViewBuilder var content: Content
868

            
869
    var body: some View {
870
        VStack(alignment: .leading, spacing: 12) {
871
            Text(title)
872
                .font(.headline)
873
            content
874
        }
875
        .frame(maxWidth: .infinity, alignment: .leading)
876
        .padding(18)
877
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
878
    }
879
}
880

            
881
private struct MeterInfoRow: View {
882
    let label: String
883
    let value: String
884

            
885
    var body: some View {
886
        HStack {
887
            Text(label)
888
            Spacer()
889
            Text(value)
890
                .foregroundColor(.secondary)
891
                .multilineTextAlignment(.trailing)
892
        }
893
        .font(.footnote)
894
    }
895
}
Bogdan Timofte authored 2 weeks ago
896

            
Bogdan Timofte authored a week ago
897
private struct EditNameView: View {
898

            
899
    @EnvironmentObject private var meter: Meter
900

            
901
    @Binding var editingName: Bool
902
    @State var newName: String
903

            
904
    var body: some View {
905
        TextField("Name", text: self.$newName, onCommit: {
906
            self.meter.name = self.newName
907
            self.editingName = false
908
        })
909
            .textFieldStyle(RoundedBorderTextFieldStyle())
910
            .lineLimit(1)
911
            .disableAutocorrection(true)
912
            .multilineTextAlignment(.center)
913
    }
914
}
915

            
916
private struct EditScreenTimeoutView: View {
917

            
918
    @EnvironmentObject private var meter: Meter
919

            
920
    var body: some View {
921
        Picker("", selection: self.$meter.screenTimeout ) {
922
            ForEach(1...9, id: \.self) { value in
923
                Text("\(value)").tag(value)
924
            }
925
            Text("Off").tag(0)
926
        }
927
        .pickerStyle(SegmentedPickerStyle())
928
    }
929
}
930

            
931
private struct EditScreenBrightnessView: View {
932

            
933
    @EnvironmentObject private var meter: Meter
934

            
935
    var body: some View {
936
        Picker("", selection: self.$meter.screenBrightness ) {
937
            ForEach(0...5, id: \.self) { value in
938
                Text("\(value)").tag(value)
939
            }
940
        }
941
        .pickerStyle(SegmentedPickerStyle())
942
    }
943
}
944

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

            
947
private struct IOSOnlyNavBar: ViewModifier {
948
    let apply: Bool
949
    let title: String
950
    let showRSSI: Bool
951
    let rssi: Int
952
    let meter: Meter
953

            
954
    @ViewBuilder
955
    func body(content: Content) -> some View {
956
        if apply {
957
            content
958
                .navigationBarTitle(title)
959
                .toolbar {
960
                    ToolbarItemGroup(placement: .navigationBarTrailing) {
961
                        if showRSSI {
962
                            RSSIView(RSSI: rssi)
963
                                .frame(width: 18, height: 18)
964
                        }
965
                    }
966
                }
967
        } else {
968
            content
969
        }
970
    }
971
}