Newer Older
841 lines | 31.791kb
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
Bogdan Timofte authored 2 weeks ago
12
import UIKit
Bogdan Timofte authored 2 weeks ago
13

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

            
20
        var title: String {
21
            switch self {
22
            case .connection: return "Home"
23
            case .live: return "Live"
24
            case .chart: return "Chart"
25
            }
26
        }
27

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

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

            
41
    private static let isMacIPadApp: Bool = ProcessInfo.processInfo.isiOSAppOnMac
Bogdan Timofte authored 2 weeks ago
42
    private static let isCatalyst: Bool = {
43
        #if targetEnvironment(macCatalyst)
44
        return true
45
        #else
46
        return false
47
        #endif
48
    }()
Bogdan Timofte authored 2 weeks ago
49

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

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

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

            
114
    // MARK: - Custom navigation header for Designed-for-iPad on Mac
115

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

            
130
            Text(meter.name.isEmpty ? "Meter" : meter.name)
131
                .font(.headline)
132
                .lineLimit(1)
133

            
134
            Spacer()
135

            
Bogdan Timofte authored 2 weeks ago
136
            if meter.operationalState >= .peripheralNotConnected {
Bogdan Timofte authored 2 weeks ago
137
                RSSIView(RSSI: meter.btSerial.averageRSSI)
Bogdan Timofte authored 2 weeks ago
138
                    .frame(width: 18, height: 18)
139
            }
Bogdan Timofte authored 2 weeks ago
140

            
Bogdan Timofte authored 2 weeks ago
141
            NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
142
                Image(systemName: "gearshape.fill")
Bogdan Timofte authored 2 weeks ago
143
                    .foregroundColor(.accentColor)
Bogdan Timofte authored 2 weeks ago
144
            }
Bogdan Timofte authored 2 weeks ago
145
            .buttonStyle(.plain)
146
        }
147
        .padding(.horizontal, 16)
148
        .padding(.vertical, 10)
149
        .background(
150
            Rectangle()
151
                .fill(.ultraThinMaterial)
152
                .ignoresSafeArea(edges: .top)
153
        )
154
        .overlay(alignment: .bottom) {
155
            Rectangle()
156
                .fill(Color.secondary.opacity(0.12))
157
                .frame(height: 1)
158
        }
Bogdan Timofte authored 2 weeks ago
159
    }
160

            
Bogdan Timofte authored 2 weeks ago
161
    private func portraitContent(size: CGSize) -> some View {
162
        portraitSegmentedDeck(size: size)
163
    }
164

            
165
    private func landscapeDeck(size: CGSize) -> some View {
166
        landscapeSegmentedDeck(size: size)
167
    }
168

            
169
    private func landscapeSegmentedDeck(size: CGSize) -> some View {
170
        VStack(spacing: 0) {
171
            segmentedTabBar(horizontalPadding: 12)
172

            
173
            landscapeSegmentedContent(size: size)
174
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
175
                .id(selectedMeterTab)
176
                .transition(.opacity.combined(with: .move(edge: .trailing)))
177
        }
178
        .animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
179
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
180
        .onAppear {
181
            normalizeSelectedTab()
182
        }
183
        .onChange(of: availableMeterTabs) { _ in
184
            normalizeSelectedTab()
185
        }
186
    }
187

            
188
    private func portraitSegmentedDeck(size: CGSize) -> some View {
189
        VStack(spacing: 0) {
190
            segmentedTabBar(horizontalPadding: 16)
191

            
192
            portraitSegmentedContent(size: size)
193
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
194
                .id(selectedMeterTab)
195
                .transition(.opacity.combined(with: .move(edge: .trailing)))
196
        }
197
        .animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
198
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
199
        .onAppear {
200
            normalizeSelectedTab()
201
        }
202
        .onChange(of: availableMeterTabs) { _ in
203
            normalizeSelectedTab()
204
        }
205
    }
206

            
207
    private func segmentedTabBar(horizontalPadding: CGFloat) -> some View {
208
        HStack {
209
            Spacer(minLength: 0)
210

            
211
            HStack(spacing: 8) {
212
                ForEach(availableMeterTabs, id: \.self) { tab in
213
                    let isSelected = selectedMeterTab == tab
214

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

            
247
            Spacer(minLength: 0)
248
        }
249
        .padding(.horizontal, horizontalPadding)
250
        .padding(.top, 10)
251
        .padding(.bottom, 8)
252
        .background(
253
            Rectangle()
254
                .fill(.ultraThinMaterial)
255
                .opacity(0.78)
256
                .ignoresSafeArea(edges: .top)
257
        )
258
        .overlay(alignment: .bottom) {
259
            Rectangle()
260
                .fill(Color.secondary.opacity(0.12))
261
                .frame(height: 1)
Bogdan Timofte authored 2 weeks ago
262
        }
Bogdan Timofte authored 2 weeks ago
263
    }
264

            
Bogdan Timofte authored 2 weeks ago
265
    @ViewBuilder
266
    private func landscapeSegmentedContent(size: CGSize) -> some View {
267
        switch selectedMeterTab {
268
        case .connection:
269
            landscapeConnectionPage
270
        case .live:
271
            if meter.operationalState == .dataIsAvailable {
272
                landscapeLivePage(size: size)
273
            } else {
274
                landscapeConnectionPage
Bogdan Timofte authored 2 weeks ago
275
            }
Bogdan Timofte authored 2 weeks ago
276
        case .chart:
277
            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
278
                landscapeChartPage(size: size)
279
            } else {
280
                landscapeConnectionPage
281
            }
282
        }
283
    }
Bogdan Timofte authored 2 weeks ago
284

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

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

            
313
                homeInfoPreview
314
            }
315
        }
316
    }
317

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

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

            
336
    private var landscapeConnectionPage: some View {
337
        landscapeFace {
338
            VStack(alignment: .leading, spacing: 12) {
339
                connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
340

            
341
                homeInfoPreview
342
            }
343
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
344
        }
345
    }
346

            
347
    private var homeInfoPreview: some View {
Bogdan Timofte authored 2 weeks ago
348
        VStack(spacing: 14) {
Bogdan Timofte authored 2 weeks ago
349
            MeterInfoCard(title: "Overview", tint: meter.color) {
350
                MeterInfoRow(label: "Name", value: meter.name)
Bogdan Timofte authored 2 weeks ago
351
                MeterInfoRow(label: "Device Model", value: meter.deviceModelName)
352
                MeterInfoRow(label: "Advertised Model", value: meter.modelString)
353
                MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
354
                MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
Bogdan Timofte authored 2 weeks ago
355
            }
Bogdan Timofte authored 2 weeks ago
356

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

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

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

            
399
            MeterInfoCard(title: "iCloud Debug", tint: .indigo) {
400
                HStack {
401
                    Spacer()
402
                    Button {
403
                        UIPasteboard.general.string = icloudDebugText
404
                    } label: {
405
                        Label("Copy", systemImage: "doc.on.doc")
406
                            .font(.caption.weight(.semibold))
407
                    }
408
                    .buttonStyle(.plain)
409
                }
410
                Text(icloudDebugText)
411
                    .font(.system(.footnote, design: .monospaced))
412
                    .textSelection(.enabled)
413
                    .frame(maxWidth: .infinity, alignment: .leading)
414
            }
Bogdan Timofte authored 2 weeks ago
415
        }
416
        .padding(.horizontal, pageHorizontalPadding)
417
    }
418

            
Bogdan Timofte authored 2 weeks ago
419
    private var cloudKnownMeter: KnownMeterCatalogItem? {
420
        appData.knownMetersByMAC[meter.btSerial.macAddress.description]
421
    }
422

            
423
    private var cloudConnectedElsewhere: Bool {
424
        guard meter.operationalState < .peripheralConnected else { return false }
425
        guard let known = cloudKnownMeter else { return false }
426
        guard let connectedBy = known.connectedByDeviceID, !connectedBy.isEmpty else { return false }
427
        guard connectedBy != AppData.myDeviceID else { return false }
428
        guard let expiry = known.connectedExpiryAt else { return false }
429
        return expiry > Date()
430
    }
431

            
432
    private func formatCloudDate(_ value: Date?) -> String {
433
        guard let value else { return "(empty)" }
434
        return value.formatted(date: .abbreviated, time: .standard)
435
    }
436

            
437
    private var icloudDebugText: String {
438
        [
439
            "Local Device ID: \(AppData.myDeviceID)",
440
            "Local Device Name: \(AppData.myDeviceName)",
441
            "Now: \(formatCloudDate(Date()))",
442
            "MAC: \(meter.btSerial.macAddress.description)",
443
            "Display Name: \(meter.name)",
444
            "BT State: \(statusText)",
445
            "Connected By Device ID: \(cloudKnownMeter?.connectedByDeviceID ?? "(empty)")",
446
            "Connected By Device Name: \(cloudKnownMeter?.connectedByDeviceName ?? "(empty)")",
447
            "Connected At: \(formatCloudDate(cloudKnownMeter?.connectedAt))",
448
            "Connected Expiry: \(formatCloudDate(cloudKnownMeter?.connectedExpiryAt))",
449
            "Last Seen At: \(formatCloudDate(cloudKnownMeter?.lastSeenAt))",
450
            "Last Seen By Device ID: \(cloudKnownMeter?.lastSeenByDeviceID ?? "(empty)")",
451
            "Last Seen By Device Name: \(cloudKnownMeter?.lastSeenByDeviceName ?? "(empty)")",
452
            "Last Seen Peripheral: \(cloudKnownMeter?.lastSeenPeripheralName ?? "(empty)")",
453
            "Connected Elsewhere Decision: \(cloudConnectedElsewhere ? "true (foreign device + valid expiry)" : "false (missing foreign owner or expired claim)")"
454
        ].joined(separator: "\n")
455
    }
456

            
Bogdan Timofte authored 2 weeks ago
457
    private func landscapeLivePage(size: CGSize) -> some View {
458
        landscapeFace {
459
            LiveView(compactLayout: true, availableSize: size)
460
                .padding(contentCardPadding)
461
                .frame(maxWidth: .infinity, alignment: .topLeading)
462
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
463
        }
464
    }
465

            
466
    private func landscapeChartPage(size: CGSize) -> some View {
467
        landscapeFace {
468
            MeasurementChartView()
469
                .environmentObject(meter.measurements)
470
                .frame(height: max(250, size.height - 44))
471
                .padding(contentCardPadding)
472
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
473
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
474
        }
475
    }
476

            
477
    private var availableMeterTabs: [MeterTab] {
478
        var tabs: [MeterTab] = [.connection]
479

            
480
        if meter.operationalState == .dataIsAvailable {
481
            tabs.append(.live)
482

            
483
            if meter.measurements.power.context.isValid {
484
                tabs.append(.chart)
485
            }
486
        }
487

            
488
        return tabs
489
    }
490

            
491
    private func normalizeSelectedTab() {
492
        guard availableMeterTabs.contains(selectedMeterTab) else {
493
            withAnimation(.easeInOut(duration: 0.22)) {
494
                selectedMeterTab = .connection
495
            }
496
            return
497
        }
498
    }
499

            
500
    private func prefersCompactPortraitConnection(for size: CGSize) -> Bool {
501
        size.height < 760 || size.width < 380
502
    }
503

            
504
    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
505
        ScrollView {
506
            content()
507
                .frame(maxWidth: .infinity, alignment: .topLeading)
508
                .padding(.horizontal, pageHorizontalPadding)
509
                .padding(.vertical, pageVerticalPadding)
Bogdan Timofte authored 2 weeks ago
510
        }
511
    }
512

            
513
    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
514
        content()
515
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
516
        .padding(.horizontal, pageHorizontalPadding)
517
        .padding(.vertical, pageVerticalPadding)
Bogdan Timofte authored 2 weeks ago
518
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
519
    }
Bogdan Timofte authored 2 weeks ago
520

            
Bogdan Timofte authored 2 weeks ago
521
    private var meterBackground: some View {
522
        LinearGradient(
523
            colors: [
524
                meter.color.opacity(0.22),
525
                Color.secondary.opacity(0.08),
526
                Color.clear
527
            ],
528
            startPoint: .topLeading,
529
            endPoint: .bottomTrailing
530
        )
531
        .ignoresSafeArea()
532
    }
533

            
534
    private func isLandscape(size: CGSize) -> Bool {
535
        size.width > size.height
536
    }
537

            
Bogdan Timofte authored 2 weeks ago
538
    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
539
        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
Bogdan Timofte authored 2 weeks ago
540
            HStack(alignment: .top) {
Bogdan Timofte authored 2 weeks ago
541
                meterIdentity(compact: compact)
Bogdan Timofte authored 2 weeks ago
542
                Spacer()
Bogdan Timofte authored 2 weeks ago
543
                statusBadge
Bogdan Timofte authored 2 weeks ago
544
            }
Bogdan Timofte authored 2 weeks ago
545

            
Bogdan Timofte authored 2 weeks ago
546
            if compact {
547
                Spacer(minLength: 0)
548
            }
549

            
550
            connectionActionArea(compact: compact)
Bogdan Timofte authored 2 weeks ago
551

            
552
            if showsActions {
553
                VStack(spacing: compact ? 10 : 12) {
554
                    Rectangle()
555
                        .fill(Color.secondary.opacity(0.12))
556
                        .frame(height: 1)
557

            
558
                    actionGrid(compact: compact, embedded: true)
559
                }
560
            }
Bogdan Timofte authored 2 weeks ago
561
        }
Bogdan Timofte authored 2 weeks ago
562
        .padding(compact ? 16 : 20)
563
        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
564
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
565
    }
566

            
Bogdan Timofte authored 2 weeks ago
567
    private func meterIdentity(compact: Bool) -> some View {
568
        HStack(alignment: .firstTextBaseline, spacing: 8) {
569
            Text(meter.name)
570
                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
571
                .lineLimit(1)
572
                .minimumScaleFactor(0.8)
573

            
574
            Text(meter.deviceModelName)
575
                .font((compact ? Font.caption : .subheadline).weight(.semibold))
576
                .foregroundColor(.secondary)
577
                .lineLimit(1)
578
                .minimumScaleFactor(0.8)
579
        }
580
    }
581

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

            
585
        return GeometryReader { proxy in
Bogdan Timofte authored 2 weeks ago
586
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
Bogdan Timofte authored 2 weeks ago
587
            let stripWidth = actionStripWidth(for: buttonWidth)
Bogdan Timofte authored 2 weeks ago
588
            let stripContent = HStack(spacing: 0) {
589
                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
590
                    dataGroupsViewVisibility.toggle()
591
                }
592
                .sheet(isPresented: $dataGroupsViewVisibility) {
593
                    DataGroupsView(visibility: $dataGroupsViewVisibility)
594
                        .environmentObject(meter)
595
                }
Bogdan Timofte authored 2 weeks ago
596

            
Bogdan Timofte authored 2 weeks ago
597
                if meter.supportsRecordingView {
598
                    actionStripDivider(height: currentActionHeight)
599
                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
600
                        recordingViewVisibility.toggle()
Bogdan Timofte authored 2 weeks ago
601
                    }
Bogdan Timofte authored 2 weeks ago
602
                    .sheet(isPresented: $recordingViewVisibility) {
603
                        RecordingView(visibility: $recordingViewVisibility)
Bogdan Timofte authored 2 weeks ago
604
                            .environmentObject(meter)
605
                    }
Bogdan Timofte authored 2 weeks ago
606
                }
Bogdan Timofte authored 2 weeks ago
607

            
Bogdan Timofte authored 2 weeks ago
608
                actionStripDivider(height: currentActionHeight)
609
                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
610
                    measurementsViewVisibility.toggle()
611
                }
612
                .sheet(isPresented: $measurementsViewVisibility) {
613
                    MeasurementsView(visibility: $measurementsViewVisibility)
614
                        .environmentObject(meter.measurements)
615
                }
616
            }
617
            .padding(actionStripPadding)
618
            .frame(width: stripWidth)
Bogdan Timofte authored 2 weeks ago
619

            
Bogdan Timofte authored 2 weeks ago
620
            HStack {
621
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
622
                stripContent
623
                    .meterCard(
624
                        tint: embedded ? meter.color : Color.secondary,
625
                        fillOpacity: embedded ? 0.08 : 0.10,
626
                        strokeOpacity: embedded ? 0.14 : 0.16,
627
                        cornerRadius: embedded ? 24 : 22
628
                    )
Bogdan Timofte authored 2 weeks ago
629
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
630
            }
631
        }
Bogdan Timofte authored 2 weeks ago
632
        .frame(height: currentActionHeight + (actionStripPadding * 2))
633
    }
634

            
635
    private func connectionActionArea(compact: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
636
        let connected = meter.operationalState >= .peripheralConnectionPending
Bogdan Timofte authored 2 weeks ago
637
        let tint = connected ? disconnectActionTint : connectActionTint
Bogdan Timofte authored 2 weeks ago
638

            
Bogdan Timofte authored 2 weeks ago
639
        return Group {
Bogdan Timofte authored 2 weeks ago
640
            if meter.operationalState == .offline {
641
                HStack(spacing: 10) {
642
                    Image(systemName: "wifi.slash")
643
                        .foregroundColor(.secondary)
644
                    Text("Meter is offline.")
645
                        .fontWeight(.semibold)
646
                    Spacer()
647
                }
648
                .padding(compact ? 12 : 16)
649
                .meterCard(tint: .secondary, fillOpacity: 0.10, strokeOpacity: 0.16)
650
            } else if meter.operationalState == .connectedElsewhere || cloudConnectedElsewhere {
Bogdan Timofte authored 2 weeks ago
651
                HStack(spacing: 10) {
Bogdan Timofte authored 2 weeks ago
652
                    Image(systemName: "person.2.fill")
653
                        .foregroundColor(.indigo)
654
                    Text("Connected on another device.")
Bogdan Timofte authored 2 weeks ago
655
                        .fontWeight(.semibold)
656
                    Spacer()
657
                }
Bogdan Timofte authored 2 weeks ago
658
                .padding(compact ? 12 : 16)
Bogdan Timofte authored 2 weeks ago
659
                .meterCard(tint: .indigo, fillOpacity: 0.14, strokeOpacity: 0.18)
Bogdan Timofte authored 2 weeks ago
660
            } else {
Bogdan Timofte authored 2 weeks ago
661
                Button(action: {
Bogdan Timofte authored 2 weeks ago
662
                    if meter.operationalState < .peripheralConnectionPending {
Bogdan Timofte authored 2 weeks ago
663
                        meter.connect()
Bogdan Timofte authored 2 weeks ago
664
                    } else {
Bogdan Timofte authored 2 weeks ago
665
                        meter.disconnect()
666
                    }
667
                }) {
Bogdan Timofte authored 2 weeks ago
668
                    HStack(spacing: 12) {
Bogdan Timofte authored 2 weeks ago
669
                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
Bogdan Timofte authored 2 weeks ago
670
                            .foregroundColor(tint)
671
                            .frame(width: 30, height: 30)
672
                            .background(Circle().fill(tint.opacity(0.12)))
Bogdan Timofte authored 2 weeks ago
673
                        Text(connected ? "Disconnect" : "Connect")
674
                            .fontWeight(.semibold)
Bogdan Timofte authored 2 weeks ago
675
                            .foregroundColor(.primary)
Bogdan Timofte authored 2 weeks ago
676
                        Spacer()
Bogdan Timofte authored 2 weeks ago
677
                    }
Bogdan Timofte authored 2 weeks ago
678
                    .padding(.horizontal, 18)
Bogdan Timofte authored 2 weeks ago
679
                    .padding(.vertical, compact ? 10 : 14)
Bogdan Timofte authored 2 weeks ago
680
                    .frame(maxWidth: .infinity)
Bogdan Timofte authored 2 weeks ago
681
                    .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored 2 weeks ago
682
                }
Bogdan Timofte authored 2 weeks ago
683
                .buttonStyle(.plain)
Bogdan Timofte authored 2 weeks ago
684
            }
685
        }
686
    }
Bogdan Timofte authored 2 weeks ago
687

            
Bogdan Timofte authored 2 weeks ago
688
    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
689
        Button(action: action) {
Bogdan Timofte authored 2 weeks ago
690
            VStack(spacing: compact ? 8 : 10) {
Bogdan Timofte authored 2 weeks ago
691
                Image(systemName: icon)
Bogdan Timofte authored 2 weeks ago
692
                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
693
                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
Bogdan Timofte authored 2 weeks ago
694
                    .background(Circle().fill(tint.opacity(0.14)))
Bogdan Timofte authored 2 weeks ago
695
                Text(title)
Bogdan Timofte authored 2 weeks ago
696
                    .font((compact ? Font.caption : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
697
                    .multilineTextAlignment(.center)
698
                    .lineLimit(2)
699
                    .minimumScaleFactor(0.9)
Bogdan Timofte authored 2 weeks ago
700
            }
Bogdan Timofte authored 2 weeks ago
701
            .foregroundColor(tint)
Bogdan Timofte authored 2 weeks ago
702
            .frame(width: width, height: height)
Bogdan Timofte authored 2 weeks ago
703
            .contentShape(Rectangle())
Bogdan Timofte authored 2 weeks ago
704
        }
705
        .buttonStyle(.plain)
706
    }
707

            
Bogdan Timofte authored 2 weeks ago
708
    private var visibleActionButtonCount: CGFloat {
709
        meter.supportsRecordingView ? 3 : 2
710
    }
711

            
712
    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
Bogdan Timofte authored 2 weeks ago
713
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
714
        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
715
        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
Bogdan Timofte authored 2 weeks ago
716
        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
717
    }
718

            
Bogdan Timofte authored 2 weeks ago
719
    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
720
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
721
        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
722
    }
723

            
Bogdan Timofte authored 2 weeks ago
724
    private func actionStripDivider(height: CGFloat) -> some View {
Bogdan Timofte authored 2 weeks ago
725
        Rectangle()
726
            .fill(Color.secondary.opacity(0.16))
Bogdan Timofte authored 2 weeks ago
727
            .frame(width: actionDividerWidth, height: max(44, height - 22))
728
    }
729

            
730
    private var statusBadge: some View {
731
        Text(statusText)
732
            .font(.caption.weight(.bold))
733
            .padding(.horizontal, 12)
734
            .padding(.vertical, 6)
735
            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
Bogdan Timofte authored 2 weeks ago
736
    }
737

            
738
    private var connectActionTint: Color {
739
        Color(red: 0.20, green: 0.46, blue: 0.43)
740
    }
741

            
742
    private var disconnectActionTint: Color {
743
        Color(red: 0.66, green: 0.39, blue: 0.35)
744
    }
745

            
Bogdan Timofte authored 2 weeks ago
746
    private var statusText: String {
Bogdan Timofte authored 2 weeks ago
747
        if cloudConnectedElsewhere {
748
            return "Elsewhere"
749
        }
Bogdan Timofte authored 2 weeks ago
750
        switch meter.operationalState {
Bogdan Timofte authored 2 weeks ago
751
        case .offline:
752
            return "Offline"
753
        case .connectedElsewhere:
754
            return "Elsewhere"
Bogdan Timofte authored 2 weeks ago
755
        case .peripheralNotConnected:
756
            return "Ready"
757
        case .peripheralConnectionPending:
758
            return "Connecting"
759
        case .peripheralConnected:
760
            return "Linked"
761
        case .peripheralReady:
762
            return "Preparing"
763
        case .comunicating:
764
            return "Syncing"
765
        case .dataIsAvailable:
766
            return "Live"
767
        }
768
    }
769

            
770
    private var statusColor: Color {
Bogdan Timofte authored 2 weeks ago
771
        if cloudConnectedElsewhere {
772
            return .indigo
773
        }
774
        return Meter.operationalColor(for: meter.operationalState)
Bogdan Timofte authored 2 weeks ago
775
    }
Bogdan Timofte authored 2 weeks ago
776
}
Bogdan Timofte authored 2 weeks ago
777

            
778

            
779
private struct MeterInfoCard<Content: View>: View {
780
    let title: String
781
    let tint: Color
782
    @ViewBuilder var content: Content
783

            
784
    var body: some View {
785
        VStack(alignment: .leading, spacing: 12) {
786
            Text(title)
787
                .font(.headline)
788
            content
789
        }
790
        .frame(maxWidth: .infinity, alignment: .leading)
791
        .padding(18)
792
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
793
    }
794
}
795

            
796
private struct MeterInfoRow: View {
797
    let label: String
798
    let value: String
799

            
800
    var body: some View {
801
        HStack {
802
            Text(label)
803
            Spacer()
804
            Text(value)
805
                .foregroundColor(.secondary)
806
                .multilineTextAlignment(.trailing)
807
        }
808
        .font(.footnote)
809
    }
810
}
Bogdan Timofte authored 2 weeks ago
811

            
812
// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac)
813

            
814
private struct IOSOnlyNavBar: ViewModifier {
815
    let apply: Bool
816
    let title: String
817
    let showRSSI: Bool
818
    let rssi: Int
819
    let meter: Meter
820

            
821
    @ViewBuilder
822
    func body(content: Content) -> some View {
823
        if apply {
824
            content
825
                .navigationBarTitle(title)
826
                .toolbar {
827
                    ToolbarItemGroup(placement: .navigationBarTrailing) {
828
                        if showRSSI {
829
                            RSSIView(RSSI: rssi)
830
                                .frame(width: 18, height: 18)
831
                        }
832
                        NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
833
                            Image(systemName: "gearshape.fill")
834
                        }
835
                    }
836
                }
837
        } else {
838
            content
839
        }
840
    }
841
}