Newer Older
759 lines | 28.026kb
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
18

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

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

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

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

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

            
Bogdan Timofte authored 2 weeks ago
58
    var body: some View {
Bogdan Timofte authored 2 weeks ago
59
        GeometryReader { proxy in
60
            let landscape = isLandscape(size: proxy.size)
61

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

            
105
    // MARK: - Custom navigation header for Designed-for-iPad on Mac
106

            
107
    private var macNavigationHeader: some View {
108
        HStack(spacing: 12) {
109
            Button {
110
                dismiss()
111
            } label: {
112
                HStack(spacing: 4) {
113
                    Image(systemName: "chevron.left")
114
                        .font(.body.weight(.semibold))
115
                    Text("USB Meters")
116
                }
117
                .foregroundColor(.accentColor)
118
            }
119
            .buttonStyle(.plain)
120

            
121
            Text(meter.name.isEmpty ? "Meter" : meter.name)
122
                .font(.headline)
123
                .lineLimit(1)
124

            
125
            Spacer()
126

            
Bogdan Timofte authored 2 weeks ago
127
            if meter.operationalState > .notPresent {
Bogdan Timofte authored 2 weeks ago
128
                RSSIView(RSSI: meter.btSerial.averageRSSI)
Bogdan Timofte authored 2 weeks ago
129
                    .frame(width: 18, height: 18)
130
            }
Bogdan Timofte authored 2 weeks ago
131

            
Bogdan Timofte authored 2 weeks ago
132
            NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
133
                Image(systemName: "gearshape.fill")
Bogdan Timofte authored 2 weeks ago
134
                    .foregroundColor(.accentColor)
Bogdan Timofte authored 2 weeks ago
135
            }
Bogdan Timofte authored 2 weeks ago
136
            .buttonStyle(.plain)
137
        }
138
        .padding(.horizontal, 16)
139
        .padding(.vertical, 10)
140
        .background(
141
            Rectangle()
142
                .fill(.ultraThinMaterial)
143
                .ignoresSafeArea(edges: .top)
144
        )
145
        .overlay(alignment: .bottom) {
146
            Rectangle()
147
                .fill(Color.secondary.opacity(0.12))
148
                .frame(height: 1)
149
        }
Bogdan Timofte authored 2 weeks ago
150
    }
151

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 2 weeks ago
296
    private func portraitConnectionPage(size: CGSize) -> some View {
297
        portraitFace {
298
            VStack(alignment: .leading, spacing: 12) {
299
                connectionCard(
300
                    compact: prefersCompactPortraitConnection(for: size),
301
                    showsActions: meter.operationalState == .dataIsAvailable
302
                )
303

            
304
                homeInfoPreview
305
            }
306
        }
307
    }
308

            
309
    private func portraitLivePage(size: CGSize) -> some View {
310
        portraitFace {
311
            LiveView(compactLayout: prefersCompactPortraitConnection(for: size), availableSize: size)
312
                .padding(contentCardPadding)
313
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
314
        }
315
    }
316

            
317
    private var portraitChartPage: some View {
318
        portraitFace {
319
            MeasurementChartView()
320
                .environmentObject(meter.measurements)
321
                .frame(minHeight: myBounds.height / 3.4)
322
                .padding(contentCardPadding)
323
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
324
        }
325
    }
326

            
327
    private var landscapeConnectionPage: some View {
328
        landscapeFace {
329
            VStack(alignment: .leading, spacing: 12) {
330
                connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
331

            
332
                homeInfoPreview
333
            }
334
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
335
        }
336
    }
337

            
338
    private var homeInfoPreview: some View {
Bogdan Timofte authored 2 weeks ago
339
        VStack(spacing: 14) {
Bogdan Timofte authored 2 weeks ago
340
            MeterInfoCard(title: "Overview", tint: meter.color) {
341
                MeterInfoRow(label: "Name", value: meter.name)
Bogdan Timofte authored 2 weeks ago
342
                MeterInfoRow(label: "Device Model", value: meter.deviceModelName)
343
                MeterInfoRow(label: "Advertised Model", value: meter.modelString)
344
                MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
345
                MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
Bogdan Timofte authored 2 weeks ago
346
            }
Bogdan Timofte authored 2 weeks ago
347

            
348
            MeterInfoCard(title: "Identifiers", tint: .blue) {
349
                MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
Bogdan Timofte authored 2 weeks ago
350
                if meter.modelNumber != 0 {
351
                    MeterInfoRow(label: "Model Identifier", value: "\(meter.modelNumber)")
352
                }
353
            }
354

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

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

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

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

            
413
    private var availableMeterTabs: [MeterTab] {
414
        var tabs: [MeterTab] = [.connection]
415

            
416
        if meter.operationalState == .dataIsAvailable {
417
            tabs.append(.live)
418

            
419
            if meter.measurements.power.context.isValid {
420
                tabs.append(.chart)
421
            }
422
        }
423

            
424
        return tabs
425
    }
426

            
427
    private func normalizeSelectedTab() {
428
        guard availableMeterTabs.contains(selectedMeterTab) else {
429
            withAnimation(.easeInOut(duration: 0.22)) {
430
                selectedMeterTab = .connection
431
            }
432
            return
433
        }
434
    }
435

            
436
    private func prefersCompactPortraitConnection(for size: CGSize) -> Bool {
437
        size.height < 760 || size.width < 380
438
    }
439

            
440
    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
441
        ScrollView {
442
            content()
443
                .frame(maxWidth: .infinity, alignment: .topLeading)
444
                .padding(.horizontal, pageHorizontalPadding)
445
                .padding(.vertical, pageVerticalPadding)
Bogdan Timofte authored 2 weeks ago
446
        }
447
    }
448

            
449
    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
450
        content()
451
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
452
        .padding(.horizontal, pageHorizontalPadding)
453
        .padding(.vertical, pageVerticalPadding)
Bogdan Timofte authored 2 weeks ago
454
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
455
    }
Bogdan Timofte authored 2 weeks ago
456

            
Bogdan Timofte authored 2 weeks ago
457
    private var meterBackground: some View {
458
        LinearGradient(
459
            colors: [
460
                meter.color.opacity(0.22),
461
                Color.secondary.opacity(0.08),
462
                Color.clear
463
            ],
464
            startPoint: .topLeading,
465
            endPoint: .bottomTrailing
466
        )
467
        .ignoresSafeArea()
468
    }
469

            
470
    private func isLandscape(size: CGSize) -> Bool {
471
        size.width > size.height
472
    }
473

            
Bogdan Timofte authored 2 weeks ago
474
    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
475
        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
Bogdan Timofte authored 2 weeks ago
476
            HStack(alignment: .top) {
Bogdan Timofte authored 2 weeks ago
477
                meterIdentity(compact: compact)
Bogdan Timofte authored 2 weeks ago
478
                Spacer()
Bogdan Timofte authored 2 weeks ago
479
                statusBadge
Bogdan Timofte authored 2 weeks ago
480
            }
Bogdan Timofte authored 2 weeks ago
481

            
Bogdan Timofte authored 2 weeks ago
482
            if compact {
483
                Spacer(minLength: 0)
484
            }
485

            
486
            connectionActionArea(compact: compact)
Bogdan Timofte authored 2 weeks ago
487

            
488
            if showsActions {
489
                VStack(spacing: compact ? 10 : 12) {
490
                    Rectangle()
491
                        .fill(Color.secondary.opacity(0.12))
492
                        .frame(height: 1)
493

            
494
                    actionGrid(compact: compact, embedded: true)
495
                }
496
            }
Bogdan Timofte authored 2 weeks ago
497
        }
Bogdan Timofte authored 2 weeks ago
498
        .padding(compact ? 16 : 20)
499
        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
500
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
501
    }
502

            
Bogdan Timofte authored 2 weeks ago
503
    private func meterIdentity(compact: Bool) -> some View {
504
        HStack(alignment: .firstTextBaseline, spacing: 8) {
505
            Text(meter.name)
506
                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
507
                .lineLimit(1)
508
                .minimumScaleFactor(0.8)
509

            
510
            Text(meter.deviceModelName)
511
                .font((compact ? Font.caption : .subheadline).weight(.semibold))
512
                .foregroundColor(.secondary)
513
                .lineLimit(1)
514
                .minimumScaleFactor(0.8)
515
        }
516
    }
517

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

            
521
        return GeometryReader { proxy in
Bogdan Timofte authored 2 weeks ago
522
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
Bogdan Timofte authored 2 weeks ago
523
            let stripWidth = actionStripWidth(for: buttonWidth)
Bogdan Timofte authored 2 weeks ago
524
            let stripContent = HStack(spacing: 0) {
525
                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
526
                    dataGroupsViewVisibility.toggle()
527
                }
528
                .sheet(isPresented: $dataGroupsViewVisibility) {
529
                    DataGroupsView(visibility: $dataGroupsViewVisibility)
530
                        .environmentObject(meter)
531
                }
Bogdan Timofte authored 2 weeks ago
532

            
Bogdan Timofte authored 2 weeks ago
533
                if meter.supportsRecordingView {
534
                    actionStripDivider(height: currentActionHeight)
535
                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
536
                        recordingViewVisibility.toggle()
Bogdan Timofte authored 2 weeks ago
537
                    }
Bogdan Timofte authored 2 weeks ago
538
                    .sheet(isPresented: $recordingViewVisibility) {
539
                        RecordingView(visibility: $recordingViewVisibility)
Bogdan Timofte authored 2 weeks ago
540
                            .environmentObject(meter)
541
                    }
Bogdan Timofte authored 2 weeks ago
542
                }
Bogdan Timofte authored 2 weeks ago
543

            
Bogdan Timofte authored 2 weeks ago
544
                actionStripDivider(height: currentActionHeight)
545
                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
546
                    measurementsViewVisibility.toggle()
547
                }
548
                .sheet(isPresented: $measurementsViewVisibility) {
549
                    MeasurementsView(visibility: $measurementsViewVisibility)
550
                        .environmentObject(meter.measurements)
551
                }
552
            }
553
            .padding(actionStripPadding)
554
            .frame(width: stripWidth)
Bogdan Timofte authored 2 weeks ago
555

            
Bogdan Timofte authored 2 weeks ago
556
            HStack {
557
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
558
                stripContent
559
                    .meterCard(
560
                        tint: embedded ? meter.color : Color.secondary,
561
                        fillOpacity: embedded ? 0.08 : 0.10,
562
                        strokeOpacity: embedded ? 0.14 : 0.16,
563
                        cornerRadius: embedded ? 24 : 22
564
                    )
Bogdan Timofte authored 2 weeks ago
565
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
566
            }
567
        }
Bogdan Timofte authored 2 weeks ago
568
        .frame(height: currentActionHeight + (actionStripPadding * 2))
569
    }
570

            
571
    private func connectionActionArea(compact: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
572
        let connected = meter.operationalState >= .peripheralConnectionPending
Bogdan Timofte authored 2 weeks ago
573
        let tint = connected ? disconnectActionTint : connectActionTint
Bogdan Timofte authored 2 weeks ago
574

            
Bogdan Timofte authored 2 weeks ago
575
        return Group {
576
            if meter.operationalState == .notPresent {
Bogdan Timofte authored 2 weeks ago
577
                HStack(spacing: 10) {
578
                    Image(systemName: "exclamationmark.triangle.fill")
579
                        .foregroundColor(.orange)
580
                    Text("Not found at this time.")
581
                        .fontWeight(.semibold)
582
                    Spacer()
583
                }
Bogdan Timofte authored 2 weeks ago
584
                .padding(compact ? 12 : 16)
Bogdan Timofte authored 2 weeks ago
585
                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
Bogdan Timofte authored 2 weeks ago
586
            } else {
Bogdan Timofte authored 2 weeks ago
587
                Button(action: {
Bogdan Timofte authored 2 weeks ago
588
                    if meter.operationalState < .peripheralConnectionPending {
Bogdan Timofte authored 2 weeks ago
589
                        meter.connect()
Bogdan Timofte authored 2 weeks ago
590
                    } else {
Bogdan Timofte authored 2 weeks ago
591
                        meter.disconnect()
592
                    }
593
                }) {
Bogdan Timofte authored 2 weeks ago
594
                    HStack(spacing: 12) {
Bogdan Timofte authored 2 weeks ago
595
                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
Bogdan Timofte authored 2 weeks ago
596
                            .foregroundColor(tint)
597
                            .frame(width: 30, height: 30)
598
                            .background(Circle().fill(tint.opacity(0.12)))
Bogdan Timofte authored 2 weeks ago
599
                        Text(connected ? "Disconnect" : "Connect")
600
                            .fontWeight(.semibold)
Bogdan Timofte authored 2 weeks ago
601
                            .foregroundColor(.primary)
Bogdan Timofte authored 2 weeks ago
602
                        Spacer()
Bogdan Timofte authored 2 weeks ago
603
                    }
Bogdan Timofte authored 2 weeks ago
604
                    .padding(.horizontal, 18)
Bogdan Timofte authored 2 weeks ago
605
                    .padding(.vertical, compact ? 10 : 14)
Bogdan Timofte authored 2 weeks ago
606
                    .frame(maxWidth: .infinity)
Bogdan Timofte authored 2 weeks ago
607
                    .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored 2 weeks ago
608
                }
Bogdan Timofte authored 2 weeks ago
609
                .buttonStyle(.plain)
Bogdan Timofte authored 2 weeks ago
610
            }
611
        }
612
    }
Bogdan Timofte authored 2 weeks ago
613

            
Bogdan Timofte authored 2 weeks ago
614
    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
615
        Button(action: action) {
Bogdan Timofte authored 2 weeks ago
616
            VStack(spacing: compact ? 8 : 10) {
Bogdan Timofte authored 2 weeks ago
617
                Image(systemName: icon)
Bogdan Timofte authored 2 weeks ago
618
                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
619
                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
Bogdan Timofte authored 2 weeks ago
620
                    .background(Circle().fill(tint.opacity(0.14)))
Bogdan Timofte authored 2 weeks ago
621
                Text(title)
Bogdan Timofte authored 2 weeks ago
622
                    .font((compact ? Font.caption : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
623
                    .multilineTextAlignment(.center)
624
                    .lineLimit(2)
625
                    .minimumScaleFactor(0.9)
Bogdan Timofte authored 2 weeks ago
626
            }
Bogdan Timofte authored 2 weeks ago
627
            .foregroundColor(tint)
Bogdan Timofte authored 2 weeks ago
628
            .frame(width: width, height: height)
Bogdan Timofte authored 2 weeks ago
629
            .contentShape(Rectangle())
Bogdan Timofte authored 2 weeks ago
630
        }
631
        .buttonStyle(.plain)
632
    }
633

            
Bogdan Timofte authored 2 weeks ago
634
    private var visibleActionButtonCount: CGFloat {
635
        meter.supportsRecordingView ? 3 : 2
636
    }
637

            
638
    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
Bogdan Timofte authored 2 weeks ago
639
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
640
        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
641
        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
Bogdan Timofte authored 2 weeks ago
642
        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
643
    }
644

            
Bogdan Timofte authored 2 weeks ago
645
    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
646
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
647
        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
648
    }
649

            
Bogdan Timofte authored 2 weeks ago
650
    private func actionStripDivider(height: CGFloat) -> some View {
Bogdan Timofte authored 2 weeks ago
651
        Rectangle()
652
            .fill(Color.secondary.opacity(0.16))
Bogdan Timofte authored 2 weeks ago
653
            .frame(width: actionDividerWidth, height: max(44, height - 22))
654
    }
655

            
656
    private var statusBadge: some View {
657
        Text(statusText)
658
            .font(.caption.weight(.bold))
659
            .padding(.horizontal, 12)
660
            .padding(.vertical, 6)
661
            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
Bogdan Timofte authored 2 weeks ago
662
    }
663

            
664
    private var connectActionTint: Color {
665
        Color(red: 0.20, green: 0.46, blue: 0.43)
666
    }
667

            
668
    private var disconnectActionTint: Color {
669
        Color(red: 0.66, green: 0.39, blue: 0.35)
670
    }
671

            
Bogdan Timofte authored 2 weeks ago
672
    private var statusText: String {
673
        switch meter.operationalState {
674
        case .notPresent:
675
            return "Missing"
676
        case .peripheralNotConnected:
677
            return "Ready"
678
        case .peripheralConnectionPending:
679
            return "Connecting"
680
        case .peripheralConnected:
681
            return "Linked"
682
        case .peripheralReady:
683
            return "Preparing"
684
        case .comunicating:
685
            return "Syncing"
686
        case .dataIsAvailable:
687
            return "Live"
688
        }
689
    }
690

            
691
    private var statusColor: Color {
692
        Meter.operationalColor(for: meter.operationalState)
Bogdan Timofte authored 2 weeks ago
693
    }
Bogdan Timofte authored 2 weeks ago
694
}
Bogdan Timofte authored 2 weeks ago
695

            
696

            
697
private struct MeterInfoCard<Content: View>: View {
698
    let title: String
699
    let tint: Color
700
    @ViewBuilder var content: Content
701

            
702
    var body: some View {
703
        VStack(alignment: .leading, spacing: 12) {
704
            Text(title)
705
                .font(.headline)
706
            content
707
        }
708
        .frame(maxWidth: .infinity, alignment: .leading)
709
        .padding(18)
710
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
711
    }
712
}
713

            
714
private struct MeterInfoRow: View {
715
    let label: String
716
    let value: String
717

            
718
    var body: some View {
719
        HStack {
720
            Text(label)
721
            Spacer()
722
            Text(value)
723
                .foregroundColor(.secondary)
724
                .multilineTextAlignment(.trailing)
725
        }
726
        .font(.footnote)
727
    }
728
}
Bogdan Timofte authored 2 weeks ago
729

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

            
732
private struct IOSOnlyNavBar: ViewModifier {
733
    let apply: Bool
734
    let title: String
735
    let showRSSI: Bool
736
    let rssi: Int
737
    let meter: Meter
738

            
739
    @ViewBuilder
740
    func body(content: Content) -> some View {
741
        if apply {
742
            content
743
                .navigationBarTitle(title)
744
                .toolbar {
745
                    ToolbarItemGroup(placement: .navigationBarTrailing) {
746
                        if showRSSI {
747
                            RSSIView(RSSI: rssi)
748
                                .frame(width: 18, height: 18)
749
                        }
750
                        NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
751
                            Image(systemName: "gearshape.fill")
752
                        }
753
                    }
754
                }
755
        } else {
756
            content
757
        }
758
    }
759
}