Newer Older
698 lines | 26.545kb
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: MeterInfoView().environmentObject(meter)) {
78
                Image(systemName: "info.circle.fill")
79
                    .padding(.vertical)
80
            }
81
            NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
82
                Image(systemName: "gearshape.fill")
83
                    .padding(.vertical)
84
            }
85
        })
86
    }
87

            
Bogdan Timofte authored 2 weeks ago
88
    private func portraitContent(size: CGSize) -> some View {
89
        portraitSegmentedDeck(size: size)
90
    }
91

            
92
    private func landscapeDeck(size: CGSize) -> some View {
93
        landscapeSegmentedDeck(size: size)
94
    }
95

            
96
    private func landscapeSegmentedDeck(size: CGSize) -> some View {
97
        VStack(spacing: 0) {
98
            segmentedTabBar(horizontalPadding: 12)
99

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

            
115
    private func portraitSegmentedDeck(size: CGSize) -> some View {
116
        VStack(spacing: 0) {
117
            segmentedTabBar(horizontalPadding: 16)
118

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

            
134
    private func segmentedTabBar(horizontalPadding: CGFloat) -> some View {
135
        HStack {
136
            Spacer(minLength: 0)
137

            
138
            HStack(spacing: 8) {
139
                ForEach(availableMeterTabs, id: \.self) { tab in
140
                    let isSelected = selectedMeterTab == tab
141

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

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

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

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

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

            
240
                homeInfoPreview
241
            }
242
        }
243
    }
244

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

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

            
263
    private var landscapeConnectionPage: some View {
264
        landscapeFace {
265
            VStack(alignment: .leading, spacing: 12) {
266
                connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
267

            
268
                homeInfoPreview
269
            }
270
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
271
        }
272
    }
273

            
274
    private var homeInfoPreview: some View {
275
        VStack(spacing: 12) {
276
            MeterInfoCard(title: "Overview", tint: meter.color) {
277
                MeterInfoRow(label: "Name", value: meter.name)
278
                MeterInfoRow(label: "Model", value: meter.deviceModelName)
279
                if !meter.firmwareVersion.isEmpty {
280
                    MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
Bogdan Timofte authored 2 weeks ago
281
                }
Bogdan Timofte authored 2 weeks ago
282
            }
Bogdan Timofte authored 2 weeks ago
283

            
284
            MeterInfoCard(title: "Identifiers", tint: .blue) {
285
                MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
286
            }
287
        }
288
        .padding(.horizontal, pageHorizontalPadding)
289
    }
290

            
291
    private func landscapeLivePage(size: CGSize) -> some View {
292
        landscapeFace {
293
            LiveView(compactLayout: true, availableSize: size)
294
                .padding(contentCardPadding)
295
                .frame(maxWidth: .infinity, alignment: .topLeading)
296
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
297
        }
298
    }
299

            
300
    private func landscapeChartPage(size: CGSize) -> some View {
301
        landscapeFace {
302
            MeasurementChartView()
303
                .environmentObject(meter.measurements)
304
                .frame(height: max(250, size.height - 44))
305
                .padding(contentCardPadding)
306
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
307
                .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
308
        }
309
    }
310

            
311
    private var availableMeterTabs: [MeterTab] {
312
        var tabs: [MeterTab] = [.connection]
313

            
314
        if meter.operationalState == .dataIsAvailable {
315
            tabs.append(.live)
316

            
317
            if meter.measurements.power.context.isValid {
318
                tabs.append(.chart)
319
            }
320
        }
321

            
322
        return tabs
323
    }
324

            
325
    private func normalizeSelectedTab() {
326
        guard availableMeterTabs.contains(selectedMeterTab) else {
327
            withAnimation(.easeInOut(duration: 0.22)) {
328
                selectedMeterTab = .connection
329
            }
330
            return
331
        }
332
    }
333

            
334
    private func prefersCompactPortraitConnection(for size: CGSize) -> Bool {
335
        size.height < 760 || size.width < 380
336
    }
337

            
338
    private func portraitFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
339
        ScrollView {
340
            content()
341
                .frame(maxWidth: .infinity, alignment: .topLeading)
342
                .padding(.horizontal, pageHorizontalPadding)
343
                .padding(.vertical, pageVerticalPadding)
Bogdan Timofte authored 2 weeks ago
344
        }
345
    }
346

            
347
    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
348
        content()
349
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
350
        .padding(.horizontal, pageHorizontalPadding)
351
        .padding(.vertical, pageVerticalPadding)
Bogdan Timofte authored 2 weeks ago
352
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
353
    }
Bogdan Timofte authored 2 weeks ago
354

            
Bogdan Timofte authored 2 weeks ago
355
    private var meterBackground: some View {
356
        LinearGradient(
357
            colors: [
358
                meter.color.opacity(0.22),
359
                Color.secondary.opacity(0.08),
360
                Color.clear
361
            ],
362
            startPoint: .topLeading,
363
            endPoint: .bottomTrailing
364
        )
365
        .ignoresSafeArea()
366
    }
367

            
368
    private func isLandscape(size: CGSize) -> Bool {
369
        size.width > size.height
370
    }
371

            
Bogdan Timofte authored 2 weeks ago
372
    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
373
        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
Bogdan Timofte authored 2 weeks ago
374
            HStack(alignment: .top) {
Bogdan Timofte authored 2 weeks ago
375
                meterIdentity(compact: compact)
Bogdan Timofte authored 2 weeks ago
376
                Spacer()
Bogdan Timofte authored 2 weeks ago
377
                statusBadge
Bogdan Timofte authored 2 weeks ago
378
            }
Bogdan Timofte authored 2 weeks ago
379

            
Bogdan Timofte authored 2 weeks ago
380
            if compact {
381
                Spacer(minLength: 0)
382
            }
383

            
384
            connectionActionArea(compact: compact)
Bogdan Timofte authored 2 weeks ago
385

            
386
            if showsActions {
387
                VStack(spacing: compact ? 10 : 12) {
388
                    Rectangle()
389
                        .fill(Color.secondary.opacity(0.12))
390
                        .frame(height: 1)
391

            
392
                    actionGrid(compact: compact, embedded: true)
393
                }
394
            }
Bogdan Timofte authored 2 weeks ago
395
        }
Bogdan Timofte authored 2 weeks ago
396
        .padding(compact ? 16 : 20)
397
        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
398
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
399
    }
400

            
Bogdan Timofte authored 2 weeks ago
401
    private func meterIdentity(compact: Bool) -> some View {
402
        HStack(alignment: .firstTextBaseline, spacing: 8) {
403
            Text(meter.name)
404
                .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
405
                .lineLimit(1)
406
                .minimumScaleFactor(0.8)
407

            
408
            Text(meter.deviceModelName)
409
                .font((compact ? Font.caption : .subheadline).weight(.semibold))
410
                .foregroundColor(.secondary)
411
                .lineLimit(1)
412
                .minimumScaleFactor(0.8)
413
        }
414
    }
415

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

            
419
        return GeometryReader { proxy in
Bogdan Timofte authored 2 weeks ago
420
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
Bogdan Timofte authored 2 weeks ago
421
            let stripWidth = actionStripWidth(for: buttonWidth)
Bogdan Timofte authored 2 weeks ago
422
            let stripContent = HStack(spacing: 0) {
423
                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
424
                    dataGroupsViewVisibility.toggle()
425
                }
426
                .sheet(isPresented: $dataGroupsViewVisibility) {
427
                    DataGroupsView(visibility: $dataGroupsViewVisibility)
428
                        .environmentObject(meter)
429
                }
Bogdan Timofte authored 2 weeks ago
430

            
Bogdan Timofte authored 2 weeks ago
431
                if meter.supportsRecordingView {
432
                    actionStripDivider(height: currentActionHeight)
433
                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
434
                        recordingViewVisibility.toggle()
Bogdan Timofte authored 2 weeks ago
435
                    }
Bogdan Timofte authored 2 weeks ago
436
                    .sheet(isPresented: $recordingViewVisibility) {
437
                        RecordingView(visibility: $recordingViewVisibility)
Bogdan Timofte authored 2 weeks ago
438
                            .environmentObject(meter)
439
                    }
Bogdan Timofte authored 2 weeks ago
440
                }
Bogdan Timofte authored 2 weeks ago
441

            
Bogdan Timofte authored 2 weeks ago
442
                actionStripDivider(height: currentActionHeight)
443
                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
444
                    measurementsViewVisibility.toggle()
445
                }
446
                .sheet(isPresented: $measurementsViewVisibility) {
447
                    MeasurementsView(visibility: $measurementsViewVisibility)
448
                        .environmentObject(meter.measurements)
449
                }
450
            }
451
            .padding(actionStripPadding)
452
            .frame(width: stripWidth)
Bogdan Timofte authored 2 weeks ago
453

            
Bogdan Timofte authored 2 weeks ago
454
            HStack {
455
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
456
                stripContent
457
                    .meterCard(
458
                        tint: embedded ? meter.color : Color.secondary,
459
                        fillOpacity: embedded ? 0.08 : 0.10,
460
                        strokeOpacity: embedded ? 0.14 : 0.16,
461
                        cornerRadius: embedded ? 24 : 22
462
                    )
Bogdan Timofte authored 2 weeks ago
463
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
464
            }
465
        }
Bogdan Timofte authored 2 weeks ago
466
        .frame(height: currentActionHeight + (actionStripPadding * 2))
467
    }
468

            
469
    private func connectionActionArea(compact: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
470
        let connected = meter.operationalState >= .peripheralConnectionPending
Bogdan Timofte authored 2 weeks ago
471
        let tint = connected ? disconnectActionTint : connectActionTint
Bogdan Timofte authored 2 weeks ago
472

            
Bogdan Timofte authored 2 weeks ago
473
        return Group {
474
            if meter.operationalState == .notPresent {
Bogdan Timofte authored 2 weeks ago
475
                HStack(spacing: 10) {
476
                    Image(systemName: "exclamationmark.triangle.fill")
477
                        .foregroundColor(.orange)
478
                    Text("Not found at this time.")
479
                        .fontWeight(.semibold)
480
                    Spacer()
481
                }
Bogdan Timofte authored 2 weeks ago
482
                .padding(compact ? 12 : 16)
Bogdan Timofte authored 2 weeks ago
483
                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
Bogdan Timofte authored 2 weeks ago
484
            } else {
Bogdan Timofte authored 2 weeks ago
485
                Button(action: {
Bogdan Timofte authored 2 weeks ago
486
                    if meter.operationalState < .peripheralConnectionPending {
Bogdan Timofte authored 2 weeks ago
487
                        meter.connect()
Bogdan Timofte authored 2 weeks ago
488
                    } else {
Bogdan Timofte authored 2 weeks ago
489
                        meter.disconnect()
490
                    }
491
                }) {
Bogdan Timofte authored 2 weeks ago
492
                    HStack(spacing: 12) {
Bogdan Timofte authored 2 weeks ago
493
                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
Bogdan Timofte authored 2 weeks ago
494
                            .foregroundColor(tint)
495
                            .frame(width: 30, height: 30)
496
                            .background(Circle().fill(tint.opacity(0.12)))
Bogdan Timofte authored 2 weeks ago
497
                        Text(connected ? "Disconnect" : "Connect")
498
                            .fontWeight(.semibold)
Bogdan Timofte authored 2 weeks ago
499
                            .foregroundColor(.primary)
Bogdan Timofte authored 2 weeks ago
500
                        Spacer()
Bogdan Timofte authored 2 weeks ago
501
                    }
Bogdan Timofte authored 2 weeks ago
502
                    .padding(.horizontal, 18)
Bogdan Timofte authored 2 weeks ago
503
                    .padding(.vertical, compact ? 10 : 14)
Bogdan Timofte authored 2 weeks ago
504
                    .frame(maxWidth: .infinity)
Bogdan Timofte authored 2 weeks ago
505
                    .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored 2 weeks ago
506
                }
Bogdan Timofte authored 2 weeks ago
507
                .buttonStyle(.plain)
Bogdan Timofte authored 2 weeks ago
508
            }
509
        }
510
    }
Bogdan Timofte authored 2 weeks ago
511

            
Bogdan Timofte authored 2 weeks ago
512
    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
513
        Button(action: action) {
Bogdan Timofte authored 2 weeks ago
514
            VStack(spacing: compact ? 8 : 10) {
Bogdan Timofte authored 2 weeks ago
515
                Image(systemName: icon)
Bogdan Timofte authored 2 weeks ago
516
                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
517
                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
Bogdan Timofte authored 2 weeks ago
518
                    .background(Circle().fill(tint.opacity(0.14)))
Bogdan Timofte authored 2 weeks ago
519
                Text(title)
Bogdan Timofte authored 2 weeks ago
520
                    .font((compact ? Font.caption : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
521
                    .multilineTextAlignment(.center)
522
                    .lineLimit(2)
523
                    .minimumScaleFactor(0.9)
Bogdan Timofte authored 2 weeks ago
524
            }
Bogdan Timofte authored 2 weeks ago
525
            .foregroundColor(tint)
Bogdan Timofte authored 2 weeks ago
526
            .frame(width: width, height: height)
Bogdan Timofte authored 2 weeks ago
527
            .contentShape(Rectangle())
Bogdan Timofte authored 2 weeks ago
528
        }
529
        .buttonStyle(.plain)
530
    }
531

            
Bogdan Timofte authored 2 weeks ago
532
    private var visibleActionButtonCount: CGFloat {
533
        meter.supportsRecordingView ? 3 : 2
534
    }
535

            
536
    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
Bogdan Timofte authored 2 weeks ago
537
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
538
        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
539
        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
Bogdan Timofte authored 2 weeks ago
540
        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
541
    }
542

            
Bogdan Timofte authored 2 weeks ago
543
    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
544
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
545
        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
546
    }
547

            
Bogdan Timofte authored 2 weeks ago
548
    private func actionStripDivider(height: CGFloat) -> some View {
Bogdan Timofte authored 2 weeks ago
549
        Rectangle()
550
            .fill(Color.secondary.opacity(0.16))
Bogdan Timofte authored 2 weeks ago
551
            .frame(width: actionDividerWidth, height: max(44, height - 22))
552
    }
553

            
554
    private var statusBadge: some View {
555
        Text(statusText)
556
            .font(.caption.weight(.bold))
557
            .padding(.horizontal, 12)
558
            .padding(.vertical, 6)
559
            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
Bogdan Timofte authored 2 weeks ago
560
    }
561

            
562
    private var connectActionTint: Color {
563
        Color(red: 0.20, green: 0.46, blue: 0.43)
564
    }
565

            
566
    private var disconnectActionTint: Color {
567
        Color(red: 0.66, green: 0.39, blue: 0.35)
568
    }
569

            
Bogdan Timofte authored 2 weeks ago
570
    private var statusText: String {
571
        switch meter.operationalState {
572
        case .notPresent:
573
            return "Missing"
574
        case .peripheralNotConnected:
575
            return "Ready"
576
        case .peripheralConnectionPending:
577
            return "Connecting"
578
        case .peripheralConnected:
579
            return "Linked"
580
        case .peripheralReady:
581
            return "Preparing"
582
        case .comunicating:
583
            return "Syncing"
584
        case .dataIsAvailable:
585
            return "Live"
586
        }
587
    }
588

            
589
    private var statusColor: Color {
590
        Meter.operationalColor(for: meter.operationalState)
Bogdan Timofte authored 2 weeks ago
591
    }
Bogdan Timofte authored 2 weeks ago
592
}
Bogdan Timofte authored 2 weeks ago
593

            
594
private struct MeterInfoView: View {
595
    @EnvironmentObject private var meter: Meter
596

            
597
    var body: some View {
598
        ScrollView {
599
            VStack(spacing: 14) {
600
                MeterInfoCard(title: "Overview", tint: meter.color) {
601
                    MeterInfoRow(label: "Name", value: meter.name)
Bogdan Timofte authored 2 weeks ago
602
                    MeterInfoRow(label: "Device Model", value: meter.deviceModelName)
Bogdan Timofte authored 2 weeks ago
603
                    MeterInfoRow(label: "Advertised Model", value: meter.modelString)
604
                    MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
605
                    MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
606
                }
607

            
608
                MeterInfoCard(title: "Identifiers", tint: .blue) {
609
                    MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
610
                    if meter.modelNumber != 0 {
Bogdan Timofte authored 2 weeks ago
611
                        MeterInfoRow(label: "Model Identifier", value: "\(meter.modelNumber)")
Bogdan Timofte authored 2 weeks ago
612
                    }
613
                }
614

            
Bogdan Timofte authored 2 weeks ago
615
                MeterInfoCard(title: "Screen Reporting", tint: .orange) {
616
                    if meter.reportsCurrentScreenIndex {
617
                        MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription)
618
                        Text("The active screen index is reported by the meter and mapped by the app to a known label.")
619
                            .font(.footnote)
620
                            .foregroundColor(.secondary)
621
                    } else {
622
                        MeterInfoRow(label: "Current Screen", value: "Not Reported")
623
                        Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
624
                            .font(.footnote)
625
                            .foregroundColor(.secondary)
626
                    }
627
                }
628

            
Bogdan Timofte authored 2 weeks ago
629
                if meter.operationalState == .dataIsAvailable {
630
                    MeterInfoCard(title: "Live Device Details", tint: .indigo) {
631
                        if !meter.firmwareVersion.isEmpty {
632
                            MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
633
                        }
Bogdan Timofte authored 2 weeks ago
634
                        if meter.supportsChargerDetection {
635
                            MeterInfoRow(label: "Detected Charger", value: meter.chargerTypeDescription)
636
                        }
Bogdan Timofte authored 2 weeks ago
637
                        if meter.serialNumber != 0 {
638
                            MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
639
                        }
640
                        if meter.bootCount != 0 {
641
                            MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
642
                        }
643
                    }
644
                } else {
645
                    MeterInfoCard(title: "Live Device Details", tint: .secondary) {
646
                        Text("Connect to the meter to load firmware, serial, and boot details.")
647
                            .font(.footnote)
648
                            .foregroundColor(.secondary)
649
                    }
650
                }
651
            }
652
            .padding()
653
        }
654
        .background(
655
            LinearGradient(
656
                colors: [meter.color.opacity(0.14), Color.clear],
657
                startPoint: .topLeading,
658
                endPoint: .bottomTrailing
659
            )
660
            .ignoresSafeArea()
661
        )
Bogdan Timofte authored 2 weeks ago
662
        .navigationBarTitle("Device Info")
Bogdan Timofte authored 2 weeks ago
663
        .navigationBarItems(trailing: RSSIView(RSSI: meter.btSerial.RSSI).frame(width: 18, height: 18))
Bogdan Timofte authored 2 weeks ago
664
    }
665
}
666

            
667
private struct MeterInfoCard<Content: View>: View {
668
    let title: String
669
    let tint: Color
670
    @ViewBuilder var content: Content
671

            
672
    var body: some View {
673
        VStack(alignment: .leading, spacing: 12) {
674
            Text(title)
675
                .font(.headline)
676
            content
677
        }
678
        .frame(maxWidth: .infinity, alignment: .leading)
679
        .padding(18)
680
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
681
    }
682
}
683

            
684
private struct MeterInfoRow: View {
685
    let label: String
686
    let value: String
687

            
688
    var body: some View {
689
        HStack {
690
            Text(label)
691
            Spacer()
692
            Text(value)
693
                .foregroundColor(.secondary)
694
                .multilineTextAlignment(.trailing)
695
        }
696
        .font(.footnote)
697
    }
698
}