Newer Older
364 lines | 11.853kb
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
Bogdan Timofte authored a week ago
18
        case settings
Bogdan Timofte authored 2 weeks ago
19

            
20
        var title: String {
21
            switch self {
22
            case .connection: return "Home"
23
            case .live: return "Live"
24
            case .chart: return "Chart"
Bogdan Timofte authored a week ago
25
            case .settings: return "Settings"
Bogdan Timofte authored 2 weeks ago
26
            }
27
        }
28

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

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

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

            
Bogdan Timofte authored 2 weeks ago
44
    @State private var selectedMeterTab: MeterTab = .connection
Bogdan Timofte authored 2 weeks ago
45
    @State private var navBarTitle: String = "Meter"
46
    @State private var navBarShowRSSI: Bool = false
47
    @State private var navBarRSSI: Int = 0
Bogdan Timofte authored 2 weeks ago
48

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

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

            
96
    // MARK: - Custom navigation header for Designed-for-iPad on Mac
97

            
98
    private var macNavigationHeader: some View {
99
        HStack(spacing: 12) {
100
            Button {
101
                dismiss()
102
            } label: {
103
                HStack(spacing: 4) {
104
                    Image(systemName: "chevron.left")
105
                        .font(.body.weight(.semibold))
106
                    Text("USB Meters")
107
                }
108
                .foregroundColor(.accentColor)
109
            }
110
            .buttonStyle(.plain)
111

            
112
            Text(meter.name.isEmpty ? "Meter" : meter.name)
113
                .font(.headline)
114
                .lineLimit(1)
115

            
116
            Spacer()
117

            
Bogdan Timofte authored 2 weeks ago
118
            if meter.operationalState > .notPresent {
Bogdan Timofte authored 2 weeks ago
119
                RSSIView(RSSI: meter.btSerial.averageRSSI)
Bogdan Timofte authored 2 weeks ago
120
                    .frame(width: 18, height: 18)
121
            }
Bogdan Timofte authored 2 weeks ago
122

            
123
        }
124
        .padding(.horizontal, 16)
125
        .padding(.vertical, 10)
126
        .background(
127
            Rectangle()
128
                .fill(.ultraThinMaterial)
129
                .ignoresSafeArea(edges: .top)
130
        )
131
        .overlay(alignment: .bottom) {
132
            Rectangle()
133
                .fill(Color.secondary.opacity(0.12))
134
                .frame(height: 1)
135
        }
Bogdan Timofte authored 2 weeks ago
136
    }
137

            
Bogdan Timofte authored 2 weeks ago
138
    private func portraitContent(size: CGSize) -> some View {
139
        portraitSegmentedDeck(size: size)
140
    }
141

            
142
    private func landscapeDeck(size: CGSize) -> some View {
143
        landscapeSegmentedDeck(size: size)
144
    }
145

            
146
    private func landscapeSegmentedDeck(size: CGSize) -> some View {
147
        VStack(spacing: 0) {
148
            segmentedTabBar(horizontalPadding: 12)
149

            
150
            landscapeSegmentedContent(size: size)
151
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
152
                .id(selectedMeterTab)
153
                .transition(.opacity.combined(with: .move(edge: .trailing)))
154
        }
155
        .animation(.easeInOut(duration: 0.22), value: selectedMeterTab)
156
        .animation(.easeInOut(duration: 0.22), value: availableMeterTabs)
157
        .onAppear {
158
            normalizeSelectedTab()
159
        }
160
        .onChange(of: availableMeterTabs) { _ in
161
            normalizeSelectedTab()
162
        }
163
    }
164

            
165
    private func portraitSegmentedDeck(size: CGSize) -> some View {
166
        VStack(spacing: 0) {
167
            segmentedTabBar(horizontalPadding: 16)
168

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

            
184
    private func segmentedTabBar(horizontalPadding: CGFloat) -> some View {
185
        HStack {
186
            Spacer(minLength: 0)
187

            
188
            HStack(spacing: 8) {
189
                ForEach(availableMeterTabs, id: \.self) { tab in
190
                    let isSelected = selectedMeterTab == tab
191

            
192
                    Button {
193
                        withAnimation(.easeInOut(duration: 0.2)) {
194
                            selectedMeterTab = tab
195
                        }
196
                    } label: {
197
                        HStack(spacing: 6) {
198
                            Image(systemName: tab.systemImage)
199
                                .font(.subheadline.weight(.semibold))
200
                            Text(tab.title)
201
                                .font(.subheadline.weight(.semibold))
202
                                .lineLimit(1)
203
                        }
204
                        .foregroundColor(isSelected ? .white : .primary)
205
                        .padding(.horizontal, 10)
206
                        .padding(.vertical, 7)
207
                        .frame(maxWidth: .infinity)
208
                        .background(
209
                            Capsule()
210
                                .fill(isSelected ? meter.color : Color.secondary.opacity(0.12))
211
                        )
Bogdan Timofte authored 2 weeks ago
212
                    }
Bogdan Timofte authored 2 weeks ago
213
                    .buttonStyle(.plain)
214
                    .accessibilityLabel(tab.title)
Bogdan Timofte authored 2 weeks ago
215
                }
216
            }
Bogdan Timofte authored 2 weeks ago
217
            .frame(maxWidth: 420)
218
            .padding(6)
219
            .background(
220
                RoundedRectangle(cornerRadius: 14, style: .continuous)
221
                    .fill(Color.secondary.opacity(0.10))
222
            )
223

            
224
            Spacer(minLength: 0)
225
        }
226
        .padding(.horizontal, horizontalPadding)
227
        .padding(.top, 10)
228
        .padding(.bottom, 8)
229
        .background(
230
            Rectangle()
231
                .fill(.ultraThinMaterial)
232
                .opacity(0.78)
233
                .ignoresSafeArea(edges: .top)
234
        )
235
        .overlay(alignment: .bottom) {
236
            Rectangle()
237
                .fill(Color.secondary.opacity(0.12))
238
                .frame(height: 1)
Bogdan Timofte authored 2 weeks ago
239
        }
Bogdan Timofte authored 2 weeks ago
240
    }
241

            
Bogdan Timofte authored 2 weeks ago
242
    @ViewBuilder
243
    private func landscapeSegmentedContent(size: CGSize) -> some View {
244
        switch selectedMeterTab {
245
        case .connection:
Bogdan Timofte authored a week ago
246
            MeterConnectionTabView(size: size, isLandscape: true)
Bogdan Timofte authored 2 weeks ago
247
        case .live:
248
            if meter.operationalState == .dataIsAvailable {
Bogdan Timofte authored a week ago
249
                MeterLiveTabView(size: size, isLandscape: true)
Bogdan Timofte authored 2 weeks ago
250
            } else {
Bogdan Timofte authored a week ago
251
                MeterConnectionTabView(size: size, isLandscape: true)
Bogdan Timofte authored 2 weeks ago
252
            }
Bogdan Timofte authored 2 weeks ago
253
        case .chart:
254
            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
Bogdan Timofte authored a week ago
255
                MeterChartTabView(size: size, isLandscape: true)
Bogdan Timofte authored 2 weeks ago
256
            } else {
Bogdan Timofte authored a week ago
257
                MeterConnectionTabView(size: size, isLandscape: true)
Bogdan Timofte authored 2 weeks ago
258
            }
Bogdan Timofte authored a week ago
259
        case .settings:
Bogdan Timofte authored a week ago
260
            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
261
                withAnimation(.easeInOut(duration: 0.22)) {
262
                    selectedMeterTab = .connection
263
                }
264
            }
Bogdan Timofte authored 2 weeks ago
265
        }
266
    }
Bogdan Timofte authored 2 weeks ago
267

            
Bogdan Timofte authored 2 weeks ago
268
    @ViewBuilder
269
    private func portraitSegmentedContent(size: CGSize) -> some View {
270
        switch selectedMeterTab {
271
        case .connection:
Bogdan Timofte authored a week ago
272
            MeterConnectionTabView(size: size, isLandscape: false)
Bogdan Timofte authored 2 weeks ago
273
        case .live:
Bogdan Timofte authored 2 weeks ago
274
            if meter.operationalState == .dataIsAvailable {
Bogdan Timofte authored a week ago
275
                MeterLiveTabView(size: size, isLandscape: false)
Bogdan Timofte authored 2 weeks ago
276
            } else {
Bogdan Timofte authored a week ago
277
                MeterConnectionTabView(size: size, isLandscape: false)
Bogdan Timofte authored 2 weeks ago
278
            }
279
        case .chart:
280
            if meter.measurements.power.context.isValid && meter.operationalState == .dataIsAvailable {
Bogdan Timofte authored a week ago
281
                MeterChartTabView(size: size, isLandscape: false)
Bogdan Timofte authored 2 weeks ago
282
            } else {
Bogdan Timofte authored a week ago
283
                MeterConnectionTabView(size: size, isLandscape: false)
Bogdan Timofte authored 2 weeks ago
284
            }
Bogdan Timofte authored a week ago
285
        case .settings:
Bogdan Timofte authored a week ago
286
            MeterSettingsTabView(isMacIPadApp: Self.isMacIPadApp) {
287
                withAnimation(.easeInOut(duration: 0.22)) {
288
                    selectedMeterTab = .connection
Bogdan Timofte authored 2 weeks ago
289
                }
290
            }
Bogdan Timofte authored 2 weeks ago
291
        }
292
    }
293

            
294
    private var availableMeterTabs: [MeterTab] {
295
        var tabs: [MeterTab] = [.connection]
296

            
297
        if meter.operationalState == .dataIsAvailable {
298
            tabs.append(.live)
299

            
300
            if meter.measurements.power.context.isValid {
301
                tabs.append(.chart)
302
            }
303
        }
304

            
Bogdan Timofte authored a week ago
305
        tabs.append(.settings)
306

            
Bogdan Timofte authored 2 weeks ago
307
        return tabs
308
    }
309

            
310
    private func normalizeSelectedTab() {
311
        guard availableMeterTabs.contains(selectedMeterTab) else {
312
            withAnimation(.easeInOut(duration: 0.22)) {
313
                selectedMeterTab = .connection
314
            }
315
            return
316
        }
317
    }
318

            
Bogdan Timofte authored 2 weeks ago
319
    private var meterBackground: some View {
320
        LinearGradient(
321
            colors: [
322
                meter.color.opacity(0.22),
323
                Color.secondary.opacity(0.08),
324
                Color.clear
325
            ],
326
            startPoint: .topLeading,
327
            endPoint: .bottomTrailing
328
        )
329
        .ignoresSafeArea()
330
    }
331

            
332
    private func isLandscape(size: CGSize) -> Bool {
333
        size.width > size.height
334
    }
335

            
Bogdan Timofte authored a week ago
336
}
337

            
Bogdan Timofte authored 2 weeks ago
338
// MARK: - Conditional navigation bar modifier (skipped on Designed-for-iPad / Mac)
339

            
340
private struct IOSOnlyNavBar: ViewModifier {
341
    let apply: Bool
342
    let title: String
343
    let showRSSI: Bool
344
    let rssi: Int
345
    let meter: Meter
346

            
347
    @ViewBuilder
348
    func body(content: Content) -> some View {
349
        if apply {
350
            content
351
                .navigationBarTitle(title)
352
                .toolbar {
353
                    ToolbarItemGroup(placement: .navigationBarTrailing) {
354
                        if showRSSI {
355
                            RSSIView(RSSI: rssi)
356
                                .frame(width: 18, height: 18)
357
                        }
358
                    }
359
                }
360
        } else {
361
            content
362
        }
363
    }
364
}