Newer Older
660 lines | 24.908kb
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
37

            
38
    @State var dataGroupsViewVisibility: Bool = false
Bogdan Timofte authored 2 weeks ago
39
    @State var recordingViewVisibility: Bool = false
Bogdan Timofte authored 2 weeks ago
40
    @State var measurementsViewVisibility: Bool = false
Bogdan Timofte authored 2 weeks ago
41
    @State private var selectedMeterTab: MeterTab = .connection
Bogdan Timofte authored 2 weeks ago
42
    private var myBounds: CGRect { UIScreen.main.bounds }
Bogdan Timofte authored 2 weeks ago
43
    private let actionStripPadding: CGFloat = 10
44
    private let actionDividerWidth: CGFloat = 1
Bogdan Timofte authored 2 weeks ago
45
    private let actionButtonMaxWidth: CGFloat = 156
46
    private let actionButtonMinWidth: CGFloat = 88
47
    private let actionButtonHeight: CGFloat = 108
Bogdan Timofte authored 2 weeks ago
48
    private let pageHorizontalPadding: CGFloat = 12
49
    private let pageVerticalPadding: CGFloat = 12
50
    private let contentCardPadding: CGFloat = 16
Bogdan Timofte authored 2 weeks ago
51

            
Bogdan Timofte authored 2 weeks ago
52
    var body: some View {
Bogdan Timofte authored 2 weeks ago
53
        GeometryReader { proxy in
54
            let landscape = isLandscape(size: proxy.size)
55

            
56
            Group {
57
                if landscape {
58
                    landscapeDeck(size: proxy.size)
59
                } else {
Bogdan Timofte authored 2 weeks ago
60
                    portraitContent(size: proxy.size)
Bogdan Timofte authored 2 weeks ago
61
                }
62
            }
63
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 weeks ago
64
            #if !targetEnvironment(macCatalyst)
Bogdan Timofte authored 2 weeks ago
65
            .navigationBarHidden(landscape)
Bogdan Timofte authored 2 weeks ago
66
            #endif
Bogdan Timofte authored 2 weeks ago
67
        }
68
        .background(meterBackground)
Bogdan Timofte authored 2 weeks ago
69
        .navigationBarTitle(meter.name.isEmpty ? "Meter" : meter.name)
Bogdan Timofte authored 2 weeks ago
70
        .navigationBarItems(trailing: HStack (spacing: 6) {
71
            if meter.operationalState > .notPresent {
72
                RSSIView(RSSI: meter.btSerial.RSSI)
73
                    .frame(width: 18, height: 18)
74
                    .padding(.leading, 6)
75
                    .padding(.vertical)
76
            }
77
            NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
78
                Image(systemName: "gearshape.fill")
79
                    .padding(.vertical)
80
            }
81
        })
82
    }
83

            
Bogdan Timofte authored 2 weeks ago
84
    private func portraitContent(size: CGSize) -> some View {
85
        portraitSegmentedDeck(size: size)
86
    }
87

            
88
    private func landscapeDeck(size: CGSize) -> some View {
89
        landscapeSegmentedDeck(size: size)
90
    }
91

            
92
    private func landscapeSegmentedDeck(size: CGSize) -> some View {
93
        VStack(spacing: 0) {
94
            segmentedTabBar(horizontalPadding: 12)
95

            
96
            landscapeSegmentedContent(size: size)
97
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
98
                .id(selectedMeterTab)
99
                .transition(.opacity.combined(with: .move(edge: .trailing)))
100
        }
101
        .animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
102
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
103
        .onAppear {
104
            normalizeSelectedTab()
105
        }
106
        .onChange(of: availableMeterTabs) { _ in
107
            normalizeSelectedTab()
108
        }
109
    }
110

            
111
    private func portraitSegmentedDeck(size: CGSize) -> some View {
112
        VStack(spacing: 0) {
113
            segmentedTabBar(horizontalPadding: 16)
114

            
115
            portraitSegmentedContent(size: size)
116
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
117
                .id(selectedMeterTab)
118
                .transition(.opacity.combined(with: .move(edge: .trailing)))
119
        }
120
        .animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
121
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
122
        .onAppear {
123
            normalizeSelectedTab()
124
        }
125
        .onChange(of: availableMeterTabs) { _ in
126
            normalizeSelectedTab()
127
        }
128
    }
129

            
130
    private func segmentedTabBar(horizontalPadding: CGFloat) -> some View {
131
        HStack {
132
            Spacer(minLength: 0)
133

            
134
            HStack(spacing: 8) {
135
                ForEach(availableMeterTabs, id: \.self) { tab in
136
                    let isSelected = selectedMeterTab == tab
137

            
138
                    Button {
139
                        withAnimation(.easeInOut(duration: 0.2)) {
140
                            selectedMeterTab = tab
141
                        }
142
                    } label: {
143
                        HStack(spacing: 6) {
144
                            Image(systemName: tab.systemImage)
145
                                .font(.subheadline.weight(.semibold))
146
                            Text(tab.title)
147
                                .font(.subheadline.weight(.semibold))
148
                                .lineLimit(1)
149
                        }
150
                        .foregroundColor(isSelected ? .white : .primary)
151
                        .padding(.horizontal, 10)
152
                        .padding(.vertical, 7)
153
                        .frame(maxWidth: .infinity)
154
                        .background(
155
                            Capsule()
156
                                .fill(isSelected ? meter.color : Color.secondary.opacity(0.12))
157
                        )
Bogdan Timofte authored 2 weeks ago
158
                    }
Bogdan Timofte authored 2 weeks ago
159
                    .buttonStyle(.plain)
160
                    .accessibilityLabel(tab.title)
Bogdan Timofte authored 2 weeks ago
161
                }
162
            }
Bogdan Timofte authored 2 weeks ago
163
            .frame(maxWidth: 420)
164
            .padding(6)
165
            .background(
166
                RoundedRectangle(cornerRadius: 14, style: .continuous)
167
                    .fill(Color.secondary.opacity(0.10))
168
            )
169

            
170
            Spacer(minLength: 0)
171
        }
172
        .padding(.horizontal, horizontalPadding)
173
        .padding(.top, 10)
174
        .padding(.bottom, 8)
175
        .background(
176
            Rectangle()
177
                .fill(.ultraThinMaterial)
178
                .opacity(0.78)
179
                .ignoresSafeArea(edges: .top)
180
        )
181
        .overlay(alignment: .bottom) {
182
            Rectangle()
183
                .fill(Color.secondary.opacity(0.12))
184
                .frame(height: 1)
Bogdan Timofte authored 2 weeks ago
185
        }
Bogdan Timofte authored 2 weeks ago
186
    }
187

            
Bogdan Timofte authored 2 weeks ago
188
    @ViewBuilder
189
    private func landscapeSegmentedContent(size: CGSize) -> some View {
190
        switch selectedMeterTab {
191
        case .connection:
192
            landscapeConnectionPage
193
        case .live:
194
            if meter.operationalState == .dataIsAvailable {
195
                landscapeLivePage(size: size)
196
            } else {
197
                landscapeConnectionPage
Bogdan Timofte authored 2 weeks ago
198
            }
Bogdan Timofte authored 2 weeks ago
199
        case .chart:
200
            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
201
                landscapeChartPage(size: size)
202
            } else {
203
                landscapeConnectionPage
204
            }
205
        }
206
    }
Bogdan Timofte authored 2 weeks ago
207

            
Bogdan Timofte authored 2 weeks ago
208
    @ViewBuilder
209
    private func portraitSegmentedContent(size: CGSize) -> some View {
210
        switch selectedMeterTab {
211
        case .connection:
212
            portraitConnectionPage(size: size)
213
        case .live:
Bogdan Timofte authored 2 weeks ago
214
            if meter.operationalState == .dataIsAvailable {
Bogdan Timofte authored 2 weeks ago
215
                portraitLivePage(size: size)
216
            } else {
217
                portraitConnectionPage(size: size)
218
            }
219
        case .chart:
220
            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
221
                portraitChartPage
222
            } else {
223
                portraitConnectionPage(size: size)
224
            }
225
        }
226
    }
Bogdan Timofte authored 2 weeks ago
227

            
Bogdan Timofte authored 2 weeks ago
228
    private func portraitConnectionPage(size: CGSize) -> some View {
229
        portraitFace {
230
            VStack(alignment: .leading, spacing: 12) {
231
                connectionCard(
232
                    compact: prefersCompactPortraitConnection(for: size),
233
                    showsActions: meter.operationalState == .dataIsAvailable
234
                )
235

            
236
                homeInfoPreview
237
            }
238
        }
239
    }
240

            
241
    private func portraitLivePage(size: CGSize) -> some View {
242
        portraitFace {
243
            LiveView(compactLayout: prefersCompactPortraitConnection(for: size), availableSize: size)
244
                .padding(contentCardPadding)
245
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
246
        }
247
    }
248

            
249
    private var portraitChartPage: some View {
250
        portraitFace {
251
            MeasurementChartView()
252
                .environmentObject(meter.measurements)
253
                .frame(minHeight: myBounds.height / 3.4)
254
                .padding(contentCardPadding)
255
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
256
        }
257
    }
258

            
259
    private var landscapeConnectionPage: some View {
260
        landscapeFace {
261
            VStack(alignment: .leading, spacing: 12) {
262
                connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
263

            
264
                homeInfoPreview
265
            }
266
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
267
        }
268
    }
269

            
270
    private var homeInfoPreview: some View {
Bogdan Timofte authored 2 weeks ago
271
        VStack(spacing: 14) {
Bogdan Timofte authored 2 weeks ago
272
            MeterInfoCard(title: "Overview", tint: meter.color) {
273
                MeterInfoRow(label: "Name", value: meter.name)
Bogdan Timofte authored 2 weeks ago
274
                MeterInfoRow(label: "Device Model", value: meter.deviceModelName)
275
                MeterInfoRow(label: "Advertised Model", value: meter.modelString)
276
                MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
277
                MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
Bogdan Timofte authored 2 weeks ago
278
            }
Bogdan Timofte authored 2 weeks ago
279

            
280
            MeterInfoCard(title: "Identifiers", tint: .blue) {
281
                MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
Bogdan Timofte authored 2 weeks ago
282
                if meter.modelNumber != 0 {
283
                    MeterInfoRow(label: "Model Identifier", value: "\(meter.modelNumber)")
284
                }
285
            }
286

            
287
            MeterInfoCard(title: "Screen Reporting", tint: .orange) {
288
                if meter.reportsCurrentScreenIndex {
289
                    MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription)
290
                    Text("The active screen index is reported by the meter and mapped by the app to a known label.")
291
                        .font(.footnote)
292
                        .foregroundColor(.secondary)
293
                } else {
294
                    MeterInfoRow(label: "Current Screen", value: "Not Reported")
295
                    Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
296
                        .font(.footnote)
297
                        .foregroundColor(.secondary)
298
                }
299
            }
300

            
301
            MeterInfoCard(title: "Live Device Details", tint: .indigo) {
302
                if meter.operationalState == .dataIsAvailable {
303
                    if !meter.firmwareVersion.isEmpty {
304
                        MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
305
                    }
306
                    if meter.supportsChargerDetection {
307
                        MeterInfoRow(label: "Detected Charger", value: meter.chargerTypeDescription)
308
                    }
309
                    if meter.serialNumber != 0 {
310
                        MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
311
                    }
312
                    if meter.bootCount != 0 {
313
                        MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
314
                    }
315
                } else {
316
                    Text("Connect to the meter to load firmware, serial, and boot details.")
317
                        .font(.footnote)
318
                        .foregroundColor(.secondary)
319
                }
Bogdan Timofte authored 2 weeks ago
320
            }
321
        }
322
        .padding(.horizontal, pageHorizontalPadding)
323
    }
324

            
325
    private func landscapeLivePage(size: CGSize) -> some View {
326
        landscapeFace {
327
            LiveView(compactLayout: true, availableSize: size)
328
                .padding(contentCardPadding)
329
                .frame(maxWidth: .infinity, alignment: .topLeading)
330
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
331
        }
332
    }
333

            
334
    private func landscapeChartPage(size: CGSize) -> some View {
335
        landscapeFace {
336
            MeasurementChartView()
337
                .environmentObject(meter.measurements)
338
                .frame(height: max(250, size.height - 44))
339
                .padding(contentCardPadding)
340
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
341
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
342
        }
343
    }
344

            
345
    private var availableMeterTabs: [MeterTab] {
346
        var tabs: [MeterTab] = [.connection]
347

            
348
        if meter.operationalState == .dataIsAvailable {
349
            tabs.append(.live)
350

            
351
            if meter.measurements.power.context.isValid {
352
                tabs.append(.chart)
353
            }
354
        }
355

            
356
        return tabs
357
    }
358

            
359
    private func normalizeSelectedTab() {
360
        guard availableMeterTabs.contains(selectedMeterTab) else {
361
            withAnimation(.easeInOut(duration: 0.22)) {
362
                selectedMeterTab = .connection
363
            }
364
            return
365
        }
366
    }
367

            
368
    private func prefersCompactPortraitConnection(for size: CGSize) -> Bool {
369
        size.height < 760 || size.width < 380
370
    }
371

            
372
    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
373
        ScrollView {
374
            content()
375
                .frame(maxWidth: .infinity, alignment: .topLeading)
376
                .padding(.horizontal, pageHorizontalPadding)
377
                .padding(.vertical, pageVerticalPadding)
Bogdan Timofte authored 2 weeks ago
378
        }
379
    }
380

            
381
    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
382
        content()
383
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
384
        .padding(.horizontal, pageHorizontalPadding)
385
        .padding(.vertical, pageVerticalPadding)
Bogdan Timofte authored 2 weeks ago
386
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
387
    }
Bogdan Timofte authored 2 weeks ago
388

            
Bogdan Timofte authored 2 weeks ago
389
    private var meterBackground: some View {
390
        LinearGradient(
391
            colors: [
392
                meter.color.opacity(0.22),
393
                Color.secondary.opacity(0.08),
394
                Color.clear
395
            ],
396
            startPoint: .topLeading,
397
            endPoint: .bottomTrailing
398
        )
399
        .ignoresSafeArea()
400
    }
401

            
402
    private func isLandscape(size: CGSize) -> Bool {
403
        size.width > size.height
404
    }
405

            
Bogdan Timofte authored 2 weeks ago
406
    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
407
        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
Bogdan Timofte authored 2 weeks ago
408
            HStack(alignment: .top) {
Bogdan Timofte authored 2 weeks ago
409
                meterIdentity(compact: compact)
Bogdan Timofte authored 2 weeks ago
410
                Spacer()
Bogdan Timofte authored 2 weeks ago
411
                statusBadge
Bogdan Timofte authored 2 weeks ago
412
            }
Bogdan Timofte authored 2 weeks ago
413

            
Bogdan Timofte authored 2 weeks ago
414
            if compact {
415
                Spacer(minLength: 0)
416
            }
417

            
418
            connectionActionArea(compact: compact)
Bogdan Timofte authored 2 weeks ago
419

            
420
            if showsActions {
421
                VStack(spacing: compact ? 10 : 12) {
422
                    Rectangle()
423
                        .fill(Color.secondary.opacity(0.12))
424
                        .frame(height: 1)
425

            
426
                    actionGrid(compact: compact, embedded: true)
427
                }
428
            }
Bogdan Timofte authored 2 weeks ago
429
        }
Bogdan Timofte authored 2 weeks ago
430
        .padding(compact ? 16 : 20)
431
        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
432
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
433
    }
434

            
Bogdan Timofte authored 2 weeks ago
435
    private func meterIdentity(compact: Bool) -> some View {
436
        HStack(alignment: .firstTextBaseline, spacing: 8) {
437
            Text(meter.name)
438
                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
439
                .lineLimit(1)
440
                .minimumScaleFactor(0.8)
441

            
442
            Text(meter.deviceModelName)
443
                .font((compact ? Font.caption : .subheadline).weight(.semibold))
444
                .foregroundColor(.secondary)
445
                .lineLimit(1)
446
                .minimumScaleFactor(0.8)
447
        }
448
    }
449

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

            
453
        return GeometryReader { proxy in
Bogdan Timofte authored 2 weeks ago
454
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
Bogdan Timofte authored 2 weeks ago
455
            let stripWidth = actionStripWidth(for: buttonWidth)
Bogdan Timofte authored 2 weeks ago
456
            let stripContent = HStack(spacing: 0) {
457
                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
458
                    dataGroupsViewVisibility.toggle()
459
                }
460
                .sheet(isPresented: $dataGroupsViewVisibility) {
461
                    DataGroupsView(visibility: $dataGroupsViewVisibility)
462
                        .environmentObject(meter)
463
                }
Bogdan Timofte authored 2 weeks ago
464

            
Bogdan Timofte authored 2 weeks ago
465
                if meter.supportsRecordingView {
466
                    actionStripDivider(height: currentActionHeight)
467
                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
468
                        recordingViewVisibility.toggle()
Bogdan Timofte authored 2 weeks ago
469
                    }
Bogdan Timofte authored 2 weeks ago
470
                    .sheet(isPresented: $recordingViewVisibility) {
471
                        RecordingView(visibility: $recordingViewVisibility)
Bogdan Timofte authored 2 weeks ago
472
                            .environmentObject(meter)
473
                    }
Bogdan Timofte authored 2 weeks ago
474
                }
Bogdan Timofte authored 2 weeks ago
475

            
Bogdan Timofte authored 2 weeks ago
476
                actionStripDivider(height: currentActionHeight)
477
                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
478
                    measurementsViewVisibility.toggle()
479
                }
480
                .sheet(isPresented: $measurementsViewVisibility) {
481
                    MeasurementsView(visibility: $measurementsViewVisibility)
482
                        .environmentObject(meter.measurements)
483
                }
484
            }
485
            .padding(actionStripPadding)
486
            .frame(width: stripWidth)
Bogdan Timofte authored 2 weeks ago
487

            
Bogdan Timofte authored 2 weeks ago
488
            HStack {
489
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
490
                stripContent
491
                    .meterCard(
492
                        tint: embedded ? meter.color : Color.secondary,
493
                        fillOpacity: embedded ? 0.08 : 0.10,
494
                        strokeOpacity: embedded ? 0.14 : 0.16,
495
                        cornerRadius: embedded ? 24 : 22
496
                    )
Bogdan Timofte authored 2 weeks ago
497
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
498
            }
499
        }
Bogdan Timofte authored 2 weeks ago
500
        .frame(height: currentActionHeight + (actionStripPadding * 2))
501
    }
502

            
503
    private func connectionActionArea(compact: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
504
        let connected = meter.operationalState >= .peripheralConnectionPending
Bogdan Timofte authored 2 weeks ago
505
        let tint = connected ? disconnectActionTint : connectActionTint
Bogdan Timofte authored 2 weeks ago
506

            
Bogdan Timofte authored 2 weeks ago
507
        return Group {
508
            if meter.operationalState == .notPresent {
Bogdan Timofte authored 2 weeks ago
509
                HStack(spacing: 10) {
510
                    Image(systemName: "exclamationmark.triangle.fill")
511
                        .foregroundColor(.orange)
512
                    Text("Not found at this time.")
513
                        .fontWeight(.semibold)
514
                    Spacer()
515
                }
Bogdan Timofte authored 2 weeks ago
516
                .padding(compact ? 12 : 16)
Bogdan Timofte authored 2 weeks ago
517
                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
Bogdan Timofte authored 2 weeks ago
518
            } else {
Bogdan Timofte authored 2 weeks ago
519
                Button(action: {
Bogdan Timofte authored 2 weeks ago
520
                    if meter.operationalState < .peripheralConnectionPending {
Bogdan Timofte authored 2 weeks ago
521
                        meter.connect()
Bogdan Timofte authored 2 weeks ago
522
                    } else {
Bogdan Timofte authored 2 weeks ago
523
                        meter.disconnect()
524
                    }
525
                }) {
Bogdan Timofte authored 2 weeks ago
526
                    HStack(spacing: 12) {
Bogdan Timofte authored 2 weeks ago
527
                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
Bogdan Timofte authored 2 weeks ago
528
                            .foregroundColor(tint)
529
                            .frame(width: 30, height: 30)
530
                            .background(Circle().fill(tint.opacity(0.12)))
Bogdan Timofte authored 2 weeks ago
531
                        Text(connected ? "Disconnect" : "Connect")
532
                            .fontWeight(.semibold)
Bogdan Timofte authored 2 weeks ago
533
                            .foregroundColor(.primary)
Bogdan Timofte authored 2 weeks ago
534
                        Spacer()
Bogdan Timofte authored 2 weeks ago
535
                    }
Bogdan Timofte authored 2 weeks ago
536
                    .padding(.horizontal, 18)
Bogdan Timofte authored 2 weeks ago
537
                    .padding(.vertical, compact ? 10 : 14)
Bogdan Timofte authored 2 weeks ago
538
                    .frame(maxWidth: .infinity)
Bogdan Timofte authored 2 weeks ago
539
                    .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored 2 weeks ago
540
                }
Bogdan Timofte authored 2 weeks ago
541
                .buttonStyle(.plain)
Bogdan Timofte authored 2 weeks ago
542
            }
543
        }
544
    }
Bogdan Timofte authored 2 weeks ago
545

            
Bogdan Timofte authored 2 weeks ago
546
    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
547
        Button(action: action) {
Bogdan Timofte authored 2 weeks ago
548
            VStack(spacing: compact ? 8 : 10) {
Bogdan Timofte authored 2 weeks ago
549
                Image(systemName: icon)
Bogdan Timofte authored 2 weeks ago
550
                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
551
                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
Bogdan Timofte authored 2 weeks ago
552
                    .background(Circle().fill(tint.opacity(0.14)))
Bogdan Timofte authored 2 weeks ago
553
                Text(title)
Bogdan Timofte authored 2 weeks ago
554
                    .font((compact ? Font.caption : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
555
                    .multilineTextAlignment(.center)
556
                    .lineLimit(2)
557
                    .minimumScaleFactor(0.9)
Bogdan Timofte authored 2 weeks ago
558
            }
Bogdan Timofte authored 2 weeks ago
559
            .foregroundColor(tint)
Bogdan Timofte authored 2 weeks ago
560
            .frame(width: width, height: height)
Bogdan Timofte authored 2 weeks ago
561
            .contentShape(Rectangle())
Bogdan Timofte authored 2 weeks ago
562
        }
563
        .buttonStyle(.plain)
564
    }
565

            
Bogdan Timofte authored 2 weeks ago
566
    private var visibleActionButtonCount: CGFloat {
567
        meter.supportsRecordingView ? 3 : 2
568
    }
569

            
570
    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
Bogdan Timofte authored 2 weeks ago
571
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
572
        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
573
        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
Bogdan Timofte authored 2 weeks ago
574
        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
575
    }
576

            
Bogdan Timofte authored 2 weeks ago
577
    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
578
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
579
        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
580
    }
581

            
Bogdan Timofte authored 2 weeks ago
582
    private func actionStripDivider(height: CGFloat) -> some View {
Bogdan Timofte authored 2 weeks ago
583
        Rectangle()
584
            .fill(Color.secondary.opacity(0.16))
Bogdan Timofte authored 2 weeks ago
585
            .frame(width: actionDividerWidth, height: max(44, height - 22))
586
    }
587

            
588
    private var statusBadge: some View {
589
        Text(statusText)
590
            .font(.caption.weight(.bold))
591
            .padding(.horizontal, 12)
592
            .padding(.vertical, 6)
593
            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
Bogdan Timofte authored 2 weeks ago
594
    }
595

            
596
    private var connectActionTint: Color {
597
        Color(red: 0.20, green: 0.46, blue: 0.43)
598
    }
599

            
600
    private var disconnectActionTint: Color {
601
        Color(red: 0.66, green: 0.39, blue: 0.35)
602
    }
603

            
Bogdan Timofte authored 2 weeks ago
604
    private var statusText: String {
605
        switch meter.operationalState {
606
        case .notPresent:
607
            return "Missing"
608
        case .peripheralNotConnected:
609
            return "Ready"
610
        case .peripheralConnectionPending:
611
            return "Connecting"
612
        case .peripheralConnected:
613
            return "Linked"
614
        case .peripheralReady:
615
            return "Preparing"
616
        case .comunicating:
617
            return "Syncing"
618
        case .dataIsAvailable:
619
            return "Live"
620
        }
621
    }
622

            
623
    private var statusColor: Color {
624
        Meter.operationalColor(for: meter.operationalState)
Bogdan Timofte authored 2 weeks ago
625
    }
Bogdan Timofte authored 2 weeks ago
626
}
Bogdan Timofte authored 2 weeks ago
627

            
628

            
629
private struct MeterInfoCard<Content: View>: View {
630
    let title: String
631
    let tint: Color
632
    @ViewBuilder var content: Content
633

            
634
    var body: some View {
635
        VStack(alignment: .leading, spacing: 12) {
636
            Text(title)
637
                .font(.headline)
638
            content
639
        }
640
        .frame(maxWidth: .infinity, alignment: .leading)
641
        .padding(18)
642
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
643
    }
644
}
645

            
646
private struct MeterInfoRow: View {
647
    let label: String
648
    let value: String
649

            
650
    var body: some View {
651
        HStack {
652
            Text(label)
653
            Spacer()
654
            Text(value)
655
                .foregroundColor(.secondary)
656
                .multilineTextAlignment(.trailing)
657
        }
658
        .font(.footnote)
659
    }
660
}