Newer Older
473 lines | 19.192kb
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 {
14

            
15
    @EnvironmentObject private var meter: Meter
16

            
17
    @State var dataGroupsViewVisibility: Bool = false
Bogdan Timofte authored 2 weeks ago
18
    @State var recordingViewVisibility: Bool = false
Bogdan Timofte authored 2 weeks ago
19
    @State var measurementsViewVisibility: Bool = false
20
    private var myBounds: CGRect { UIScreen.main.bounds }
Bogdan Timofte authored 2 weeks ago
21
    private let actionStripPadding: CGFloat = 10
22
    private let actionDividerWidth: CGFloat = 1
Bogdan Timofte authored 2 weeks ago
23
    private let actionButtonMaxWidth: CGFloat = 156
24
    private let actionButtonMinWidth: CGFloat = 88
25
    private let actionButtonHeight: CGFloat = 108
Bogdan Timofte authored 2 weeks ago
26

            
Bogdan Timofte authored 2 weeks ago
27
    var body: some View {
Bogdan Timofte authored 2 weeks ago
28
        GeometryReader { proxy in
29
            let landscape = isLandscape(size: proxy.size)
30

            
31
            Group {
32
                if landscape {
33
                    landscapeDeck(size: proxy.size)
34
                } else {
35
                    portraitContent
36
                }
37
            }
38
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
39
            .navigationBarHidden(landscape)
40
        }
41
        .background(meterBackground)
42
        .navigationBarTitle("Meter")
43
        .navigationBarItems(trailing: HStack (spacing: 6) {
44
            if meter.operationalState > .notPresent {
45
                RSSIView(RSSI: meter.btSerial.RSSI)
46
                    .frame(width: 18, height: 18)
47
                    .padding(.leading, 6)
48
                    .padding(.vertical)
49
            }
50
            NavigationLink(destination: MeterInfoView().environmentObject(meter)) {
51
                Image(systemName: "info.circle.fill")
52
                    .padding(.vertical)
53
            }
54
            NavigationLink(destination: MeterSettingsView().environmentObject(meter)) {
55
                Image(systemName: "gearshape.fill")
56
                    .padding(.vertical)
57
            }
58
        })
59
    }
60

            
61
    private var portraitContent: some View {
Bogdan Timofte authored 2 weeks ago
62
        ScrollView {
Bogdan Timofte authored 2 weeks ago
63
            VStack(alignment: .leading, spacing: 16) {
Bogdan Timofte authored 2 weeks ago
64
                connectionCard(showsActions: meter.operationalState == .dataIsAvailable)
Bogdan Timofte authored 2 weeks ago
65

            
Bogdan Timofte authored 2 weeks ago
66
                if meter.operationalState == .dataIsAvailable {
Bogdan Timofte authored 2 weeks ago
67
                    LiveView()
68
                        .padding(18)
69
                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
70

            
Bogdan Timofte authored 2 weeks ago
71
                    if meter.measurements.power.context.isValid {
72
                        MeasurementChartView()
73
                            .environmentObject(meter.measurements)
74
                            .frame(minHeight: myBounds.height / 3.4)
75
                            .padding(16)
76
                            .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
Bogdan Timofte authored 2 weeks ago
77
                    }
Bogdan Timofte authored 2 weeks ago
78
                }
79
            }
Bogdan Timofte authored 2 weeks ago
80
            .padding()
Bogdan Timofte authored 2 weeks ago
81
        }
Bogdan Timofte authored 2 weeks ago
82
    }
83

            
84
    private func landscapeDeck(size: CGSize) -> some View {
85
        TabView {
86
            landscapeFace {
Bogdan Timofte authored 2 weeks ago
87
                connectionCard(compact: true, showsActions: meter.operationalState == .dataIsAvailable)
Bogdan Timofte authored 2 weeks ago
88
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
89
            }
Bogdan Timofte authored 2 weeks ago
90

            
91
            if meter.operationalState == .dataIsAvailable {
92
                landscapeFace {
93
                    LiveView(compactLayout: true, availableSize: size)
94
                        .padding(16)
Bogdan Timofte authored 2 weeks ago
95
                        .frame(maxWidth: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
96
                        .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
97
                }
98

            
99
                if meter.measurements.power.context.isValid {
100
                    landscapeFace {
101
                        MeasurementChartView()
102
                            .environmentObject(meter.measurements)
103
                            .frame(height: max(250, size.height - 44))
104
                            .padding(10)
105
                            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
106
                            .meterCard(tint: meter.color, fillOpacity: 0.12, strokeOpacity: 0.20)
107
                    }
108
                }
Bogdan Timofte authored 2 weeks ago
109
            }
Bogdan Timofte authored 2 weeks ago
110
        }
111
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
112
    }
113

            
114
    private func landscapeFace<Content: View>(@ViewBuilder content: () -> Content) -> some View {
115
        content()
116
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
117
        .padding(.horizontal, 12)
Bogdan Timofte authored 2 weeks ago
118
        .padding(.vertical, 12)
Bogdan Timofte authored 2 weeks ago
119
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
120
    }
Bogdan Timofte authored 2 weeks ago
121

            
Bogdan Timofte authored 2 weeks ago
122
    private var meterBackground: some View {
123
        LinearGradient(
124
            colors: [
125
                meter.color.opacity(0.22),
126
                Color.secondary.opacity(0.08),
127
                Color.clear
128
            ],
129
            startPoint: .topLeading,
130
            endPoint: .bottomTrailing
131
        )
132
        .ignoresSafeArea()
133
    }
134

            
135
    private func isLandscape(size: CGSize) -> Bool {
136
        size.width > size.height
137
    }
138

            
Bogdan Timofte authored 2 weeks ago
139
    private func connectionCard(compact: Bool = false, showsActions: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
140
        VStack(alignment: .leading, spacing: compact ? 12 : 18) {
Bogdan Timofte authored 2 weeks ago
141
            HStack(alignment: .top) {
142
                VStack(alignment: .leading, spacing: 6) {
143
                    Text(meter.name)
Bogdan Timofte authored 2 weeks ago
144
                        .font(.system(compact ? .title3 : .title2, design: .rounded).weight(.bold))
Bogdan Timofte authored 2 weeks ago
145
                    Text(meter.deviceModelSummary)
146
                        .font(.subheadline.weight(.semibold))
147
                        .foregroundColor(.secondary)
148
                }
149
                Spacer()
Bogdan Timofte authored 2 weeks ago
150
                Group {
151
                    if compact {
152
                        HStack(spacing: 8) {
153
                            statusBadge
154
                            if meter.operationalState > .notPresent {
155
                                Text("RSSI \(meter.btSerial.RSSI)")
156
                                    .font(.caption)
157
                                    .foregroundColor(.secondary)
158
                            }
159
                        }
160
                    } else {
161
                        VStack(alignment: .trailing, spacing: 6) {
162
                            statusBadge
163
                            if meter.operationalState > .notPresent {
164
                                Text("RSSI \(meter.btSerial.RSSI)")
165
                                    .font(.caption)
166
                                    .foregroundColor(.secondary)
167
                            }
168
                        }
Bogdan Timofte authored 2 weeks ago
169
                    }
170
                }
171
            }
Bogdan Timofte authored 2 weeks ago
172

            
Bogdan Timofte authored 2 weeks ago
173
            if compact {
174
                Spacer(minLength: 0)
175
            }
176

            
177
            connectionActionArea(compact: compact)
Bogdan Timofte authored 2 weeks ago
178

            
179
            if showsActions {
180
                VStack(spacing: compact ? 10 : 12) {
181
                    Rectangle()
182
                        .fill(Color.secondary.opacity(0.12))
183
                        .frame(height: 1)
184

            
185
                    actionGrid(compact: compact, embedded: true)
186
                }
187
            }
Bogdan Timofte authored 2 weeks ago
188
        }
Bogdan Timofte authored 2 weeks ago
189
        .padding(compact ? 16 : 20)
190
        .frame(maxWidth: .infinity, maxHeight: compact ? .infinity : nil, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
191
        .meterCard(tint: meter.color, fillOpacity: 0.22, strokeOpacity: 0.24)
192
    }
193

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

            
197
        return GeometryReader { proxy in
Bogdan Timofte authored 2 weeks ago
198
            let buttonWidth = actionButtonWidth(for: proxy.size.width)
Bogdan Timofte authored 2 weeks ago
199
            let stripWidth = actionStripWidth(for: buttonWidth)
Bogdan Timofte authored 2 weeks ago
200
            let stripContent = HStack(spacing: 0) {
201
                meterSheetButton(icon: "square.grid.2x2.fill", title: meter.dataGroupsTitle, tint: .teal, width: buttonWidth, height: currentActionHeight, compact: compact) {
202
                    dataGroupsViewVisibility.toggle()
203
                }
204
                .sheet(isPresented: $dataGroupsViewVisibility) {
205
                    DataGroupsView(visibility: $dataGroupsViewVisibility)
206
                        .environmentObject(meter)
207
                }
Bogdan Timofte authored 2 weeks ago
208

            
Bogdan Timofte authored 2 weeks ago
209
                if meter.supportsRecordingView {
210
                    actionStripDivider(height: currentActionHeight)
211
                    meterSheetButton(icon: "gauge.with.dots.needle.50percent", title: "Charge Record", tint: .pink, width: buttonWidth, height: currentActionHeight, compact: compact) {
212
                        recordingViewVisibility.toggle()
Bogdan Timofte authored 2 weeks ago
213
                    }
Bogdan Timofte authored 2 weeks ago
214
                    .sheet(isPresented: $recordingViewVisibility) {
215
                        RecordingView(visibility: $recordingViewVisibility)
Bogdan Timofte authored 2 weeks ago
216
                            .environmentObject(meter)
217
                    }
Bogdan Timofte authored 2 weeks ago
218
                }
Bogdan Timofte authored 2 weeks ago
219

            
Bogdan Timofte authored 2 weeks ago
220
                actionStripDivider(height: currentActionHeight)
221
                meterSheetButton(icon: "clock.arrow.circlepath", title: "App History", tint: .blue, width: buttonWidth, height: currentActionHeight, compact: compact) {
222
                    measurementsViewVisibility.toggle()
223
                }
224
                .sheet(isPresented: $measurementsViewVisibility) {
225
                    MeasurementsView(visibility: $measurementsViewVisibility)
226
                        .environmentObject(meter.measurements)
227
                }
228
            }
229
            .padding(actionStripPadding)
230
            .frame(width: stripWidth)
Bogdan Timofte authored 2 weeks ago
231

            
Bogdan Timofte authored 2 weeks ago
232
            HStack {
233
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
234
                stripContent
235
                    .meterCard(
236
                        tint: embedded ? meter.color : Color.secondary,
237
                        fillOpacity: embedded ? 0.08 : 0.10,
238
                        strokeOpacity: embedded ? 0.14 : 0.16,
239
                        cornerRadius: embedded ? 24 : 22
240
                    )
Bogdan Timofte authored 2 weeks ago
241
                Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
242
            }
243
        }
Bogdan Timofte authored 2 weeks ago
244
        .frame(height: currentActionHeight + (actionStripPadding * 2))
245
    }
246

            
247
    private func connectionActionArea(compact: Bool = false) -> some View {
Bogdan Timofte authored 2 weeks ago
248
        let connected = meter.operationalState >= .peripheralConnectionPending
Bogdan Timofte authored 2 weeks ago
249
        let tint = connected ? disconnectActionTint : connectActionTint
Bogdan Timofte authored 2 weeks ago
250

            
Bogdan Timofte authored 2 weeks ago
251
        return Group {
252
            if meter.operationalState == .notPresent {
Bogdan Timofte authored 2 weeks ago
253
                HStack(spacing: 10) {
254
                    Image(systemName: "exclamationmark.triangle.fill")
255
                        .foregroundColor(.orange)
256
                    Text("Not found at this time.")
257
                        .fontWeight(.semibold)
258
                    Spacer()
259
                }
Bogdan Timofte authored 2 weeks ago
260
                .padding(compact ? 12 : 16)
Bogdan Timofte authored 2 weeks ago
261
                .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.18)
Bogdan Timofte authored 2 weeks ago
262
            } else {
Bogdan Timofte authored 2 weeks ago
263
                Button(action: {
Bogdan Timofte authored 2 weeks ago
264
                    if meter.operationalState < .peripheralConnectionPending {
Bogdan Timofte authored 2 weeks ago
265
                        meter.connect()
Bogdan Timofte authored 2 weeks ago
266
                    } else {
Bogdan Timofte authored 2 weeks ago
267
                        meter.disconnect()
268
                    }
269
                }) {
Bogdan Timofte authored 2 weeks ago
270
                    HStack(spacing: 12) {
Bogdan Timofte authored 2 weeks ago
271
                        Image(systemName: connected ? "xmark.circle.fill" : "bolt.horizontal.circle.fill")
Bogdan Timofte authored 2 weeks ago
272
                            .foregroundColor(tint)
273
                            .frame(width: 30, height: 30)
274
                            .background(Circle().fill(tint.opacity(0.12)))
Bogdan Timofte authored 2 weeks ago
275
                        Text(connected ? "Disconnect" : "Connect")
276
                            .fontWeight(.semibold)
Bogdan Timofte authored 2 weeks ago
277
                            .foregroundColor(.primary)
Bogdan Timofte authored 2 weeks ago
278
                        Spacer()
Bogdan Timofte authored 2 weeks ago
279
                    }
Bogdan Timofte authored 2 weeks ago
280
                    .padding(.horizontal, 18)
Bogdan Timofte authored 2 weeks ago
281
                    .padding(.vertical, compact ? 10 : 14)
Bogdan Timofte authored 2 weeks ago
282
                    .frame(maxWidth: .infinity)
Bogdan Timofte authored 2 weeks ago
283
                    .meterCard(tint: tint, fillOpacity: 0.14, strokeOpacity: 0.20)
Bogdan Timofte authored 2 weeks ago
284
                }
Bogdan Timofte authored 2 weeks ago
285
                .buttonStyle(.plain)
Bogdan Timofte authored 2 weeks ago
286
            }
287
        }
288
    }
Bogdan Timofte authored 2 weeks ago
289

            
Bogdan Timofte authored 2 weeks ago
290
    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
291
        Button(action: action) {
Bogdan Timofte authored 2 weeks ago
292
            VStack(spacing: compact ? 8 : 10) {
Bogdan Timofte authored 2 weeks ago
293
                Image(systemName: icon)
Bogdan Timofte authored 2 weeks ago
294
                    .font(.system(size: compact ? 18 : 20, weight: .semibold))
295
                    .frame(width: compact ? 34 : 40, height: compact ? 34 : 40)
Bogdan Timofte authored 2 weeks ago
296
                    .background(Circle().fill(tint.opacity(0.14)))
Bogdan Timofte authored 2 weeks ago
297
                Text(title)
Bogdan Timofte authored 2 weeks ago
298
                    .font((compact ? Font.caption : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
299
                    .multilineTextAlignment(.center)
300
                    .lineLimit(2)
301
                    .minimumScaleFactor(0.9)
Bogdan Timofte authored 2 weeks ago
302
            }
Bogdan Timofte authored 2 weeks ago
303
            .foregroundColor(tint)
Bogdan Timofte authored 2 weeks ago
304
            .frame(width: width, height: height)
Bogdan Timofte authored 2 weeks ago
305
            .contentShape(Rectangle())
Bogdan Timofte authored 2 weeks ago
306
        }
307
        .buttonStyle(.plain)
308
    }
309

            
Bogdan Timofte authored 2 weeks ago
310
    private var visibleActionButtonCount: CGFloat {
311
        meter.supportsRecordingView ? 3 : 2
312
    }
313

            
314
    private func actionButtonWidth(for availableWidth: CGFloat) -> CGFloat {
Bogdan Timofte authored 2 weeks ago
315
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
316
        let contentWidth = availableWidth - (actionStripPadding * 2) - dividerWidth
317
        let fittedWidth = floor(contentWidth / visibleActionButtonCount)
Bogdan Timofte authored 2 weeks ago
318
        return min(actionButtonMaxWidth, max(actionButtonMinWidth, fittedWidth))
319
    }
320

            
Bogdan Timofte authored 2 weeks ago
321
    private func actionStripWidth(for buttonWidth: CGFloat) -> CGFloat {
322
        let dividerWidth = actionDividerWidth * max(visibleActionButtonCount - 1, 0)
323
        return (buttonWidth * visibleActionButtonCount) + dividerWidth + (actionStripPadding * 2)
324
    }
325

            
Bogdan Timofte authored 2 weeks ago
326
    private func actionStripDivider(height: CGFloat) -> some View {
Bogdan Timofte authored 2 weeks ago
327
        Rectangle()
328
            .fill(Color.secondary.opacity(0.16))
Bogdan Timofte authored 2 weeks ago
329
            .frame(width: actionDividerWidth, height: max(44, height - 22))
330
    }
331

            
332
    private var statusBadge: some View {
333
        Text(statusText)
334
            .font(.caption.weight(.bold))
335
            .padding(.horizontal, 12)
336
            .padding(.vertical, 6)
337
            .meterCard(tint: statusColor, fillOpacity: 0.24, strokeOpacity: 0.30, cornerRadius: 999)
Bogdan Timofte authored 2 weeks ago
338
    }
339

            
340
    private var connectActionTint: Color {
341
        Color(red: 0.20, green: 0.46, blue: 0.43)
342
    }
343

            
344
    private var disconnectActionTint: Color {
345
        Color(red: 0.66, green: 0.39, blue: 0.35)
346
    }
347

            
Bogdan Timofte authored 2 weeks ago
348
    private var statusText: String {
349
        switch meter.operationalState {
350
        case .notPresent:
351
            return "Missing"
352
        case .peripheralNotConnected:
353
            return "Ready"
354
        case .peripheralConnectionPending:
355
            return "Connecting"
356
        case .peripheralConnected:
357
            return "Linked"
358
        case .peripheralReady:
359
            return "Preparing"
360
        case .comunicating:
361
            return "Syncing"
362
        case .dataIsAvailable:
363
            return "Live"
364
        }
365
    }
366

            
367
    private var statusColor: Color {
368
        Meter.operationalColor(for: meter.operationalState)
Bogdan Timofte authored 2 weeks ago
369
    }
Bogdan Timofte authored 2 weeks ago
370
}
Bogdan Timofte authored 2 weeks ago
371

            
372
private struct MeterInfoView: View {
373
    @EnvironmentObject private var meter: Meter
374

            
375
    var body: some View {
376
        ScrollView {
377
            VStack(spacing: 14) {
378
                MeterInfoCard(title: "Overview", tint: meter.color) {
379
                    MeterInfoRow(label: "Name", value: meter.name)
380
                    MeterInfoRow(label: "Displayed Model", value: meter.deviceModelSummary)
381
                    MeterInfoRow(label: "Advertised Model", value: meter.modelString)
382
                    MeterInfoRow(label: "Working Voltage", value: meter.documentedWorkingVoltage)
383
                    MeterInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
384
                }
385

            
386
                MeterInfoCard(title: "Identifiers", tint: .blue) {
387
                    MeterInfoRow(label: "MAC", value: meter.btSerial.macAddress.description)
388
                    if meter.modelNumber != 0 {
389
                        MeterInfoRow(label: "Model Code", value: "\(meter.modelNumber)")
390
                    }
391
                }
392

            
Bogdan Timofte authored 2 weeks ago
393
                MeterInfoCard(title: "Screen Reporting", tint: .orange) {
394
                    if meter.reportsCurrentScreenIndex {
395
                        MeterInfoRow(label: "Current Screen", value: meter.currentScreenDescription)
396
                        Text("The active screen index is reported by the meter and mapped by the app to a known label.")
397
                            .font(.footnote)
398
                            .foregroundColor(.secondary)
399
                    } else {
400
                        MeterInfoRow(label: "Current Screen", value: "Not Reported")
401
                        Text("The current screen is not reported by the device payload, or we have not yet identified where and how the protocol announces it.")
402
                            .font(.footnote)
403
                            .foregroundColor(.secondary)
404
                    }
405
                }
406

            
Bogdan Timofte authored 2 weeks ago
407
                if meter.operationalState == .dataIsAvailable {
408
                    MeterInfoCard(title: "Live Device Details", tint: .indigo) {
409
                        if !meter.firmwareVersion.isEmpty {
410
                            MeterInfoRow(label: "Firmware", value: meter.firmwareVersion)
411
                        }
412
                        if meter.serialNumber != 0 {
413
                            MeterInfoRow(label: "Serial", value: "\(meter.serialNumber)")
414
                        }
415
                        if meter.bootCount != 0 {
416
                            MeterInfoRow(label: "Boot Count", value: "\(meter.bootCount)")
417
                        }
418
                    }
419
                } else {
420
                    MeterInfoCard(title: "Live Device Details", tint: .secondary) {
421
                        Text("Connect to the meter to load firmware, serial, and boot details.")
422
                            .font(.footnote)
423
                            .foregroundColor(.secondary)
424
                    }
425
                }
426
            }
427
            .padding()
428
        }
429
        .background(
430
            LinearGradient(
431
                colors: [meter.color.opacity(0.14), Color.clear],
432
                startPoint: .topLeading,
433
                endPoint: .bottomTrailing
434
            )
435
            .ignoresSafeArea()
436
        )
437
        .navigationBarTitle("Meter Info")
Bogdan Timofte authored 2 weeks ago
438
        .navigationBarItems(trailing: RSSIView(RSSI: meter.btSerial.RSSI).frame(width: 18, height: 18))
Bogdan Timofte authored 2 weeks ago
439
    }
440
}
441

            
442
private struct MeterInfoCard<Content: View>: View {
443
    let title: String
444
    let tint: Color
445
    @ViewBuilder var content: Content
446

            
447
    var body: some View {
448
        VStack(alignment: .leading, spacing: 12) {
449
            Text(title)
450
                .font(.headline)
451
            content
452
        }
453
        .frame(maxWidth: .infinity, alignment: .leading)
454
        .padding(18)
455
        .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
456
    }
457
}
458

            
459
private struct MeterInfoRow: View {
460
    let label: String
461
    let value: String
462

            
463
    var body: some View {
464
        HStack {
465
            Text(label)
466
            Spacer()
467
            Text(value)
468
                .foregroundColor(.secondary)
469
                .multilineTextAlignment(.trailing)
470
        }
471
        .font(.footnote)
472
    }
473
}